上个月把博客迭代了一个大版本,顺便整理一下我都做了哪些优化,以及踩了什么坑。

关于 SEO 的系统性介绍可以看如何优化网站的SEO,这篇只讲实操

本站原架构采用 Vue 3 SPA + Spring Boot,典型的单页应用。

大概的问题有这些:

  1. 首屏载入速度太慢,甚至因此导致了 FCP 测试超时

  2. 页面 SEO 元数据不全,不利于搜索引擎理解

  3. 单页应用,页面全靠 JavaScript 动态注入,爬虫读取不到内容

1. 添加 robots.txt 与 Sitemap

txt
User-agent: *
Allow: /
Allow: /api/user/articles/sitemap.xml
Disallow: /admin/
Disallow: /api/
Sitemap: https://blog.hazenix.top/api/user/articles/sitemap.xml

后端新增 /sitemap.xml 端点,查询已发布文章的 slug + 更新时间,组织为 XML Sitemap,把固定页面和动态页面都纳入 sitemap 中

2. 性能优化(提升网站响应速度与 Core Web Vitals)

LCP 视角

LCP (Largest Contentful Paint,最大内容渲染)衡量页面最大可见元素的渲染完成时间,合格线 ≤ 2.5s。以下优化主要围绕减少首屏资源体积、缩短关键渲染路径。

添加骨架屏

html
// 优化前
<div id="app"></div>

修复之前,浏览器拿到的 HTML 里面啥也没有。必须等 Vue 的 JS 下载完、跑起来、渲染出 DOM,用户才能看到第一个像素。所以 Lightroom 测试显示为 NO_FCP(没有任何内容渲染)

html
// 优化后
// 在 `<div id="app">` 内加了纯 HTML + inline CSS 的骨架屏,浏览器 JS 执行前就立即渲染 
<div id="app">
  <div style="min-height:100vh; display:flex; align-items:center; justify-content:center;">
    <h1>Hazenix's Blog</h1>
    <p>Only those who keep walking light up the long road of time</p>
  </div>
</div>

修复后,浏览器拿到 HTML 的瞬间就能把这些字画出来——0ms 延迟,纯 HTML + inline CSS,不需要等任何 JS。

减包 + 懒加载

优化前前端打包后的 dist 目录太大,首屏要下载的东西太多,拖慢了 LCP。针对打包体积和加载速度做了一系列优化:

视频转移到 OSS:原树洞页面的 sunrise.mp4 背景视频打包在 dist 里,占了 34MB。改为上传到阿里云 OSS,在元素进入视口后才开始下载。dist 体积从 45MB 降到 15MB

图片转 WebP:头像 avatar.jpg(53KB → 25KB)、微信二维码 Hazenix.png(223KB → 44KB),用 sharp 转为 WebP,总共减少约 200KB

移除 Font Awesome CDN:30 处图标全部迁移到 Element Plus 自带的图标库(@element-plus/icons-vue),消除了一次外部 CSS 请求。

Element Plus 按需导入

原来 main.js 里是全局导入 import ElementPlus from 'element-plus',CSS 和所有组件全部打包。改为 unplugin-vue-components + unplugin-auto-import 按需导入,CSS 体积从 389KB 降到约 60KB

Brotli 预压缩

使用 vite-plugin-compression 在构建时预先生成 .br.gz 文件,Nginx 直接返回压缩后的资源,不用实时压缩,响应速度更快 传输体积进一步减少

例如 TableOfContents chunk:1685KB → 400KB(Brotli 压缩后),element-plus chunk:878KB → 233KB。

列表 API 响应减重

文章列表接口原来返回所有文章的完整正文 HTML,单次响应 396KB。优化之后只查标题、时间、统计数据,396KB → 5KB,减小 98.7%

java
// 修复前:select * from article(含正文)
List<ArticleDetailVO> list = articleService.getArticleList(query);

// 修复后:select 仅必要字段(不含 content)
List<ArticleShortVO> list = articleService.getArticleShortList(query);

封面图使用 OSS 缩略

文章列表/推荐卡片展示的封面图直接用了 OSS 原图,一张截屏 PNG 动辄 1.4MB。首页 5 篇文章就拉 7MB 图片流量,将 LCP 拖慢到了 6s

优化后直接使用 OSS 提供的缩略图 (列表里只需要 400px 宽的小缩略图),降低带宽占用

异步组件拆分 —— 把重型依赖踢出首屏

ArticleDetail.vueMarkdownRendererCommentListTableOfContents 都是静态 import,导致 Vite 把它们全部打进同一个 chunk——markdown-it(200KB)、mermaid(包含 dagre/d3/katex,1.2MB)、highlight.js(300KB)一起挤在 1.67MB 的 chunk 里,首页明明不需要这些,也跟着下载

优化后重型组件异步加载,用户点进文章页才下载markdown 渲染、代码高亮、mermaid 画图等重型依赖

第三方脚本延迟 + 关键资源预加载

GA4 和 Umami 统计脚本的 async/defer 在模拟 3G 下仍然会和主 JS 竞争首屏带宽。把它们全部移到 window.load 事件之后才初始化:

html
<script>
  function loadThirdParty() {
    // Umami + GA4 统统在 load 之后才加载
    var u = document.createElement('script');
    u.defer = true;
    u.src = 'https://monitor.hazenix.top/script.js';
    // ...
  }
  window.addEventListener('load', loadThirdParty);
</script>

同时在 <head> 里加 LCP 关键图片的预加载和 fetchpriority="high",让头像和 JS 并行下载:

html
<link rel="preload" as="image" href="/avatar.webp" fetchpriority="high">

CLS(布局偏移)

CLS 衡量页面加载过程中视觉内容的意外偏移,合格线 ≤ 0.1。

使用 CSS Containment 防布局偏移

Vue 挂载替换预渲染内容时,如果动态数据(文章列表、热门文章)与预渲染时的快照不同,容器高度变化会产生 CLS。给关键区域加 min-heightcontain: layout style

html
<div class="lg:col-span-2" style="min-height:400px;contain:layout style;">
  <!-- 文章列表 -->
</div>
<div style="min-height:250px;contain:layout style;">
  <!-- 推荐区域 -->
</div>

CSS contain: layout style 告诉浏览器这个区域内的布局变化不会影响外部元素,避免级联重排。

效果:/home 的 CLS 从 0.165 降到 0

Nginx 安全加固 + 缓存策略收紧

安全头和缓存策略也是 PageSpeed “Best Practices” 评分项,之前一直缺了几项。

Content-Security-Policy:限制浏览器只能加载可信来源的资源,防止 XSS 和数据注入攻击:

nginx
add_header Content-Security-Policy "default-src 'self'; 
  script-src 'self' 'unsafe-inline' https://monitor.hazenix.top 
    https://www.googletagmanager.com https://www.google-analytics.com; 
  style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; 
  img-src 'self' https://blog-hazenix-top.oss-cn-heyuan.aliyuncs.com data:; 
  connect-src 'self' https://blog.hazenix.top/api https://www.google-analytics.com; 
  font-src 'self' https://fonts.gstatic.com data:; 
  frame-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'" always;

解释:

  • script-src 只允许本站 + Google Analytics / Umami 的脚本
  • style-src 允许本站 + Google Fonts 样式表
  • img-src 允许本站 + OSS 图片 + data URI(base64 内联图)
  • object-src 'none' 禁止 Flash / Java 插件(现代浏览器已不需要)
  • base-uri 'self' 防止 <base> 标签劫持

同时加了 Permissions-Policy 禁止地理位置、麦克风、摄像头(博客用不到)。

缓存策略细化:之前的图片缓存一刀切 30 天。assets/ 目录下的资源(JS/CSS/图片)带内容哈希,文件名变了自动失效,可以放心开 1 年 immutable

nginx
# 带哈希 → 1 年强缓存
location ~* ^/assets/.*\.(js|css)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}
# 根目录静态资源(无哈希)→ 30 天
location ~* ^/[^/]+\.(jpg|png|webp)$ {
    expires 30d;
    add_header Cache-Control "public";
}

3. 结构化资料标记 & 优化 Title / Meta Description

优化前只有一行 document.title = ...

优化后在所有页面统一注入:title、description、keywords、OG 标签、Twitter Card、canonical URL、JSON-LD 结构化数据。

页面 结构化数据类型
首页 WebSite + SearchAction(启用 Google 站内搜索框)
文章页 Article + BreadcrumbList(面包屑导航)
分类/标签/关于/友链/留言板/历史 各页面独立标题 + 描述

404 页面额外设置了 robots: 'noindex',防止被搜索引擎索引。

4. 设置图片 ALT 属性

MarkdownRenderer 渲染的 <img> 标签自动添加 loading="lazy"(延迟加载,图片滚到视口附近才开始下载,不抢首屏带宽。)和 decoding="async"(异步解码,图片在后台线程解码,不阻塞主线程渲染),同时确保 alt 属性存在(帮助搜索引擎理解图片内容)。显著改善多图长文章场景下的 LCP

5. 解决单页应用的爬虫读取问题

单页应用的爬虫读取问题 的讲解参考上一篇文章的“## 9. JavaScript”部分

预渲染 + 增量更新

grtblog v2 架构 启发——“SSR 渲染从用户请求时转移到数据变更时”。

预渲染:构建时用 Puppeteer (无头浏览器)把所有页面(文章、分类、标签、静态页)渲染成完整 HTML,去掉 <script> 标签后存入 dist/prerender/。Nginx 直接返回预渲染的静态 HTML

增量更新:使用一个独立的 prerender-server(Node.js HTTP 服务),Spring Boot 端对文章/分类/标签增删改查 后通过 webhook 通知它**重渲染受影响页面。**不需要全量重建,只刷新变了的那几个页面。

测试优化前后的SEO

测试前后对比

Google Search Console 的数据要等 1-2 周才有,但以下工具的测试结果都是即时的。

优化前:

优化后:

优化前后总览

维度 优化前 优化后
robots.txt 不存在 可访问
Sitemap 数据 返回空 XML Sitemap,含 90+ 页面
文章页 meta description 自动填入文章摘要
文章页 OG 标签 og:title, og:image, og:url 齐全
文章页结构化数据 Article + BreadcrumbList
首页结构化数据 WebSite + SearchAction
其他页面 SEO 元数据 6 个页面缺失 全部接入 setSEO()
404 页面 HTTP 状态码 200(软 404) 404
图片加载 无优化 loading="lazy" + decoding="async"
爬虫内容获取 空壳 HTML 预渲染静态 HTML(Puppeteer)
普通用户首屏 空壳骨架屏等 Vue 加载 预渲染完整 HTML(瞬时 LCP)
分享按钮桌面端 “分享失败” 复制链接成功
dist 打包体积 45MB 15MB(减 67%)
图片格式 PNG/JPG 共 ~276KB WebP 共 ~70KB(减 75%)
Font Awesome CDN 外部请求 已移除(Element Plus 图标替代)
Element Plus 全量导入 389KB CSS 按需导入 ~60KB
异步组件拆分 无(1.67MB TOC chunk) MarkdownRenderer 等按需加载
文章列表 API 396KB(含正文 HTML) 5KB(仅标题/时间,减 98.7%)
封面图 原图 1.4MB/张(PNG) OSS 缩放 14KB/张(WebP,减 99%)
首页总流量 7,646KB 241KB(减 97%)
Brotli 传输压缩 构建时预生成 .br(再减 60%)
第三方脚本 首屏与 JS 竞争带宽 load 事件后延迟初始化
CSP 安全头 Content-Security-Policy + Permissions-Policy
静态资源缓存 图片 30d,无区分 哈希资源 1y immutable,SW no-cache
PageSpeed Mobile 53 99
PageSpeed Desktop 98 99
PageSpeed Best Practices 100
PageSpeed LCP (Mobile) 6.2s 1.8s
PageSpeed TBT (Mobile) 340ms 0ms
PageSpeed CLS 0.165 0
Lighthouse SEO 分数 ~50-60 90-100
Google Rich Results 未检测到 Article + BreadcrumbList