我的博客里放了不少自己喜欢的东西:动态背景、音乐播放器、照片墙,还有桌面上的小角色。页面看着热闹,但用起来偶尔需要等很久,手机上尤其明显。
这次我和 Codex 一起梳理代码,给优化定了一个很直接的规矩:一项一项改,每项都测前后数据,提升明显且功能正常才保留,否则回滚。
最终保留了八项改进。在同一套本地受控测试下,手机首页搜索可交互时间的中位数从 11.56 秒降到 1.05 秒,桌面从 4.96 秒降到 1.16 秒。这里的“一秒”有明确的测量范围,后面会把条件说明白。
封面使用这次整理时的博客音乐页面截图。它展示网站的外观,不是测速截图。
先理清架构:静态文章也会被客户端拖慢
这个项目分成两部分。XHBlogs 是访客端,使用 Next.js App Router 和 React;文章、杂谈等内容来自本地 Markdown,部分配置保存在 TypeScript 文件里。my-blog-manager 是本地管理端,前端配合 FastAPI 保存草稿、管理图片,再将内容同步到访客端。
文章页面可以提前生成,但访客打开页面以后,还要下载图片、启动播放器、挂载动画组件,并让 React 接管交互。服务器已经生成 HTML,不等于用户立刻就能看到和操作页面。
沿着这条链路看下来,问题比较分散:启动遮罩延后了内容显示,部分组件重复挂载;手机上隐藏的桌面组件仍然启动;首页传了不需要的文章正文;背景和相册直接使用大图;评论脚本也挤在文章首屏加载。
测量方法:先把“快了”变成可核对的数据
这次测的是本机运行的 Next.js 生产构建,没有拿开发模式的首次编译耗时作为访问速度。首页时间实验使用以下条件:
- 手机视口 390 × 844,桌面视口 1365 × 900。
- 浏览器 CPU 限速 4 倍,下载带宽约 10Mbps,延迟 40ms。
- 每组每种设备运行 5 次,使用新的浏览器上下文,取中位数。
- 时间收益至少减少 15%,并且超过 100ms;资源体积或持续执行量至少减少 30%。
“正文可见”检查内容是否已经显示、是否被遮挡;“搜索可交互”还检查 React 是否完成输入绑定,并实际输入验证。它们都不表示网站所有功能已经加载完,也不能当成线上用户的 INP 指标。
为了排除互联网波动,首页实验固定了外部图片和音乐界面请求的响应。音乐接口另做实验,用固定等待 300ms 的上游模拟请求,验证真实 Next.js HTTP 路由和缓存。它能证明缓存有效,不能代表网易云在线接口的实际速度。
还有一个环境限制:本机无法访问 Google Fonts。所有对照构建都使用一致的字体测试响应;博客实际使用的本地 FFYS 字体没有改动。图片和脚本体积实验则不做网络限速,等请求稳定后统计完整响应体。
八项改动,各自解决了什么
1. 让已生成的内容及时显示,保持组件稳定挂载
原来的启动流程会把页面内容藏起来,等启动画面结束再显示;主题初始化时,组件树的结构变化还会带来重复挂载。一个可见的结果是,初始音乐请求发生了两次。
这次移除了阻挡首屏的显示门槛,让主题 Provider 的结构保持稳定,并提前读取主题偏好。页面过渡也不再从隐藏首屏开始。
单独做完这一项,手机正文可见时间从 11.56 秒变成 0.62 秒,搜索可交互从 11.56 秒变成 7.91 秒;桌面可交互从 4.96 秒变成 1.20 秒。初始音乐请求从两次变成一次。
这个差别很有意思:内容已经看得见了,手机上的交互仍要等。它提醒我,首屏“露出来”和首屏“能用”需要分开测。
2. 手机不挂载桌面专属组件
CSS 把组件隐藏,并不会阻止组件执行 JavaScript、发请求或创建画布。原来手机屏幕里看不到的桌面特效,仍可能在后台工作。
现在通过媒体查询决定是否挂载桌面组件,并在窗口宽度变化时正常挂载、卸载。手机端模型资源请求从 4 个变为 0 个。同阶段的手机长任务总时长中位数从约 1066ms 降到 705ms。
宽度切换也做了检查:手机放大成桌面时角色画布会出现,缩回手机时会清理。
3. 点击动画只在有动画时运行
点击涟漪原先即使没有涟漪,也会持续调度画布绘制。这种工作平时不容易看到,却一直占用执行机会。
改成点击后启动动画、最后一个涟漪结束后停止,卸载时取消剩余帧。五次真实浏览器空闲采样中,点击特效画布的清空次数从中位数 137 次/秒降到 0;实际点击仍能画出涟漪,然后停下来。
这里的“0”只针对这个点击特效画布,不表示整个网站没有其他动画。
4. 首页只传搜索和卡片需要的字段
首页读取 Markdown 时,把文章正文也放进了传给客户端组件的数据。但是标题搜索和文章卡片并不需要完整正文。
移除这部分多余数据后,首页 HTML 响应体从 99.2KB 降到 53.4KB,RSC 响应体从 62.1KB 降到 20.1KB。搜索真实文章标题、空结果和文章跳转都通过了检查。
内容仍然在文章详情页完整展示,首页少搬运了一份用不到的正文。
5. 背景图按屏幕尺寸加载,轮播按需准备
背景是这次资源体积里非常明显的一项。原来首屏实际收到的背景图片响应体合计约 16.47MB,几张大图同时参与加载。
现在预先生成手机和桌面使用的 WebP 变体,首屏只加载当前需要的背景。轮到下一张之前,再准备并解码下一张,完成后做渐变切换。原图保留。
五次独立访问中,手机首屏背景图片响应体降到 66.8KB,桌面降到 213.1KB。这测的是初次背景加载;轮播继续运行时,仍会按顺序加载后面的背景。
6. 音乐缓存成功结果,失败后允许恢复
歌曲名称、歌手和歌词不需要每次访问都重新取一遍。歌曲详情和歌词并行请求,分别缓存校验成功的结果,并加上超时处理。
在固定 300ms 上游的对照实验中,重复请求中位数从 312.1ms 降到 4.9ms。首次没有缓存的请求仍需要访问上游,这项收益主要体现在后续请求。
独立审查还找到了一个坑:HTTP 200 不等于业务成功。 如果把返回错误内容或损坏 JSON 的 HTTP 200 响应也缓存起来,临时失败可能被放大成持续失败。因此最终实现先校验数据,只缓存成功值;歌词失败也不会让整首歌不可用。
注入歌曲详情错误、歌词错误和损坏 JSON 后,下一次请求都能恢复。
7. 照片列表用缩略图,点开再看原图
照片墙的预览区没必要直接下载所有原始大图。现在列表使用 640 和 1280 两档响应式 WebP 缩略图,点击全屏查看时仍加载原图。
同一组照片墙预览图片的响应体,从 13.23MB 降到 367.4KB,减少约 97.2%。
这项也留下了一次撤回记录。第一版统一使用质量 82 编码,有一张照片没有通过预先设定的画质门槛。候选被删除后重新编码,对需要的照片提高质量,而不是放宽标准。
最终 92 个照片变体全部通过归一化像素误差与比例检查;再加上 12 个背景变体,共检查了 104 个版本。像素误差只能作为量化门槛,不能代替所有人的主观观感,原图也因此始终保留。
8. 评论靠近可视区时再加载
文章评论使用 Gitalk,但读者刚打开长文章时,通常还没到评论区。让评论依赖在首屏就加载,会和文章本身争用资源。
现在在评论区域靠近可视区时再加载组件,预留位置减少布局变化。带 OAuth 回调参数时立即启动;浏览器不支持 IntersectionObserver 时,也会直接加载。
五次手机文章访问中,滚动前收到的 JavaScript 响应体从 1.51MB 降到 722.1KB,减少约 52.1%。这里统计的是解压后的脚本响应体,不是 gzip 后的网络传输量。滚动到评论区后,初始化也正常。
各项分别与修改前对照,不能将这些百分比直接相加。背景为初次加载,照片为列表预览,脚本为滚动到评论区之前的响应体。
组合验收:最后快到了什么程度
八项改动各自达标后,又对组合版本做了每种设备 5 次的完整首屏测试。
- 手机:正文可见 11.56 秒 → 0.64 秒;搜索可交互 11.56 秒 → 1.05 秒,减少约 90.9%。
- 桌面:正文可见 4.96 秒 → 0.72 秒;搜索可交互 4.96 秒 → 1.16 秒,减少约 76.6%。
- 实验室 LCP 中位数:手机 10.95 秒 → 0.69 秒,桌面 4.67 秒 → 0.78 秒。
每组 5 次、中位数;本地生产构建、固定外部响应和相同限速条件。图中的时间不是完整音乐、评论、所有背景全部加载完成的时间。
功能上还检查了搜索、主题切换与刷新保持、手机和桌面宽度切换、相册原图查看、文章和其他页面导航。完整首屏测试没有记录到未捕获的浏览器错误。
本文记录的是本地受控实验。线上 CDN、跨地区访问、真实第三方服务和服务器冷启动,还需要部署后的实际数据验证。对一个想长期折腾的博客来说,让每次修改都能解释、能复测、能撤回,比只追求某一次好看的跑分更实用。

