博客的默认主题换成流萤之后,上一轮测的是页面动起来卡不卡:滚动、动画、明暗切换。这一轮看的是页面来得快不快:手机尺寸、不带缓存,第一次打开要下载多少东西,最大的那块内容什么时候画出来,首屏有没有东西跳来跳去。
先放结果,四个页面的冷加载流量:
| 页面 | 改前 | 改后 |
|---|---|---|
| 首页 | 1495 KB | 638 KB |
| 文章页 | 1606 KB | 749 KB |
| 归档 | 1266 KB | 409 KB |
| 留言板 | 2599 KB | 1508 KB |
下面按发现的顺序记录:一个没人用的字体,一张发现得太晚的大图,一组尺寸不对的评论照片,以及改照片时撞上的一个渲染问题。
一、怎么测
用本机 Chrome 的无头模式,通过 CDP 控制。视口设成 390×844、3 倍屏,开触摸模拟,禁用缓存,打开页面后等 15 秒,收集四类数据:
- 每个请求的传输字节数,按资源类型和域名汇总;
- 最大内容绘制(LCP)和布局偏移(CLS),都带上对应的元素;
- head 里阻塞渲染的样式表和脚本;
- 页面上每张图的原始宽度和显示宽度。
字节数来自 Network 域的事件,LCP 和 CLS 用页面里的 PerformanceObserver 记录(节选):
// 每个请求结束时记下实际传输的字节数
if (x.method === 'Network.loadingFinished') {
const r = reqs.get(x.params.requestId);
if (r) r.bytes = x.params.encodedDataLength;
}
// 页面里:最大内容绘制和布局偏移,都带上元素
new PerformanceObserver((list) => list.getEntries().forEach((e) => {
lcp.push({ t: Math.round(e.startTime), size: e.size, el: e.element && e.element.className });
})).observe({ type: 'largest-contentful-paint', buffered: true });
new PerformanceObserver((list) => list.getEntries().forEach((e) => {
if (!e.hadRecentInput) cls.push({ v: e.value, src: e.sources.map((s) => s.node && s.node.className) });
})).observe({ type: 'layout-shift', buffered: true });
有一点要先说明:从这台电脑连服务器,首字节时间在 2.6 到 5.1 秒之间跳,前后两次测出来的 LCP 毫秒数没法直接比。字节数和请求数不受网络波动影响,下面主要看它们。
二、849 KB 的字体,页面上一个字都没用
首页一共 44 个请求、1495 KB,其中字体 4 个、887 KB。单是一个 lxgwwenkaiscreen-site-22cb6c14a1.woff2 就有 849 KB,占了整页的一半以上。
这是霞鹜文楷的站点子集,是原主题“云笺”的正文字体,按全站实际用到的字裁过,3637 个码点。五套新主题共用一个页头片段,里面照搬了云笺的预加载:
<link rel="preload" href="https://niu.hqxiaozou.top/blog-static/fonts/lxgwwenkaiscreen-site-22cb6c14a1.woff2"
as="font" type="font/woff2" crossorigin="anonymous">
可新主题都有自己的字体栈,把正文字体的变量换掉了。到底还有谁在用它?我在每个主题的首页、文章页和留言板上逐个检查有文字的元素,看 getComputedStyle 算出来的字体栈第一项是不是文楷:
| 主题 | 首页 | 文章页 | 留言板 |
|---|---|---|---|
| 画报 | 0 | 0 | 0 |
| 格致 | 0 | 0 | 0 |
| 星河 | 0 | 0 | 0 |
| 流萤 | 0 | 0 | 0 |
| 纸墨 | 85 | 298 | 136 |
只有纸墨还拿文楷当正文字体,另外四套一个字都没用到。
这里的关键在预加载本身。用 @font-face 声明的字体本来是按需下载的:页面上有文字要用它,浏览器才去拉,没人用就不下。预加载把这一步变成了无条件下载。所以光留着 @font-face 声明并不费流量,真正的问题是这条预加载。
改法是加个条件,只给纸墨。顺手把那个写满 unicode-range 的 @font-face 样式表(未压缩 24.8 KB,阻塞渲染)也一起加上条件:
<th:block th:if="${blogTheme == 'paper'}">
<link rel="preload" href="https://niu.hqxiaozou.top/blog-static/fonts/lxgwwenkaiscreen-site-22cb6c14a1.woff2"
as="font" type="font/woff2" crossorigin="anonymous">
</th:block>
<link rel="stylesheet" th:if="${blogTheme == 'paper'}"
th:href="@{/fonts/lxgw-wenkai-screen/lxgwwenkaiscreen.css(v=site-20260924a)}">
改完首页的字体从 887 KB 降到 38 KB,剩下的是两个图标字体子集和一个等宽字体。开头那张表里,首页、文章页、归档各少了 857 KB,基本都是它。
三、最大内容是一张 CSS 背景图
文章页、归档和留言板的 LCP 元素都是 div.ff-banner-img,也就是首屏那张大图。它不是 <img>,而是写在 style 上的 CSS 背景,电脑和手机各用一个尺寸:
<div class="ff-banner-img" style="--ff-bg-lg:url(…/thumbnail/1920x/…);--ff-bg-sm:url(…/thumbnail/900x/…)"></div>
.ff-banner-img { background-image: var(--ff-bg-lg); }
@media (max-width: 767px) {
.ff-banner-img { background-image: var(--ff-bg-sm, var(--ff-bg-lg)); }
}
浏览器有个预加载扫描器,HTML 还没解析完就会先扫出里面的 <img>、<link>、<script>,提前开始下载。CSS 背景图它看不见:要等样式表下载完、样式算完,浏览器才知道这个元素要用哪张图。而首页 head 里有 13 个阻塞渲染的样式表。
改法是在大图前面放两条图片预加载,断点和 CSS 一模一样,同一时刻只会命中一条:
<link rel="preload" as="image" th:href="${commons.bannerSmall(image)}" media="(max-width: 767px)" fetchpriority="high">
<link rel="preload" as="image" th:href="${commons.banner(image)}" media="(min-width: 768px)" fetchpriority="high">
更顺手的写法是 imagesrcset 加 imagesizes,让浏览器自己挑,但这里不能用。CSS 是按视口宽度切换的,不看像素密度:390 宽的 3 倍屏,CSS 用的是 900 宽那张;imagesrcset 却会按 390×3=1170 去挑 1920 宽那张。结果是预加载一张、背景再下一张,同一张图下两遍。所以预加载的条件必须照抄 CSS 的媒体查询。
改完实测,每个视口只发出一个大图请求,手机是 900 宽,电脑是 1920 宽。
四、显示 220px 的照片,下载的是 1080px
留言板是最重的页面:198 个请求、2599 KB,其中图片 1407 KB。
留言里的照片在手机上最多显示 220px 宽,竖图还要再被 260px 的最大高度压一下,实际只有 117px 宽;电脑上最多 300 到 320px。下载的却是 1080 宽的缩略图,单张 128 到 138 KB。
这些地址是服务端渲染时统一补的七牛处理参数,只有 1080 这一档。改成给浏览器三档,让它按屏幕挑:
<img src="…/a.jpg?imageMogr2/thumbnail/1080x/quality/78/format/webp/ignore-error/1"
srcset="…/a.jpg?imageMogr2/thumbnail/480x/quality/78/format/webp/ignore-error/1 480w,
…/a.jpg?imageMogr2/thumbnail/720x/quality/78/format/webp/ignore-error/1 720w,
…/a.jpg?imageMogr2/thumbnail/1080x/quality/78/format/webp/ignore-error/1 1080w"
sizes="(max-width: 768px) 240px, 320px">
有两个地方要留意。
src 留着 1080 宽。点开照片看大图的预览,读的是 img.src,而 src 返回的永远是属性里写的地址,不是浏览器从 srcset 里挑中的那张(那张在 currentSrc 里)。所以列表里加载小图,点开照样是 1080 宽。
邮件不能带 srcset。有人回复时,评论正文也会发进邮件。邮件那边会把图片下载下来作为内嵌附件,测试要求 HTML 里不能留下图床地址,srcset 里的地址会漏过去。所以只给网页那条渲染路径加。
改完手机 3 倍屏挑的是 720 宽,电脑 1 倍屏挑的是 480 宽,点开大图仍然是 1080 宽。
改完一看,srcset 没了
单元测试都过了,本地起服务一看留言板的源码,照片的 src 带上了处理参数,srcset 却一个都没有。单独调那个规范化函数,输出里明明有 srcset。
原因是评论正文在列表查询里被规范化了两遍,分页查询内部一次,外面又一次,代码注释里写着“同一批记录可能被处理两次”。规范化的第一步是安全重建:把 <img> 标签拆开,只保留 src、alt、style 三个属性重新拼。于是第二遍时,srcset 被当成不认识的属性丢掉了;随后补 srcset 的那一步看到 src 已经带了参数,按“已经处理过的不动”跳了过去。
| 阶段 | src | srcset |
|---|---|---|
| 库里存的 | 原图地址 | 没有 |
| 第一遍 | 补上参数 | 补上 |
| 第二遍重建 | 带着参数 | 丢了 |
| 第二遍补 srcset | 带着参数 | 跳过 |
修法是让这一步幂等:src 结尾恰好是自己加的那串默认参数时,按原图重新生成一遍。
// 第二遍时 src 已带默认参数、srcset 已经丢了:去掉默认参数,按原图重新生成
String bare = url.endsWith(PARAMS) ? url.substring(0, url.length() - PARAMS.length()) : url;
再补一条测试,规范化两遍和一遍的结果必须完全一样。
改完留言板从 2599 KB 降到 1508 KB,其中图片从 1407 KB 降到 1174 KB。图片省得不算多,因为再往下的照片本来就是懒加载,首屏附近只有 4 张换成了小图。
五、首屏上下跳
最后一个问题不在流量上。首页大标题下面有一行打字机效果的副标题,在 390 宽的手机上会折成两行。打字时它一会儿一行一会儿两行,而大图里的内容是垂直居中的,于是上面的标题和下面的按钮跟着上下跳。15 秒里记到 84 次布局偏移,来源就是它们:
| 元素 | 位置变化 |
|---|---|
| 标题 h1 | 302 → 313 |
| 按钮组 | 453 → 464 |
改法是先量一遍每句话完整打出来时这一行有多高,取最大值写成最小高度,窗口尺寸变了再量一次:
function reserve() {
var keep = el.textContent, max = 0;
line.style.minHeight = '';
list.forEach(function (text) {
el.textContent = text;
max = Math.max(max, line.offsetHeight);
});
el.textContent = keep;
line.style.minHeight = max + 'px';
}
手机上这一行固定成两行高,标题和按钮不再跳。累计布局偏移从 0.054 降到 0.021,剩下的是居中的文字逐字变长时自身的位移。
总结
| 问题 | 根因 | 改法 |
|---|---|---|
| 字体白下 849 KB | 预加载照搬旧主题 | 只给纸墨 |
| 大图发现得晚 | CSS 背景图 | 同断点预加载 |
| 照片下载过大 | 只有 1080 一档 | srcset 三档 |
| srcset 消失 | 规范化跑两遍 | 改写做成幂等 |
| 首屏上下跳 | 副标题折行 | 预留最高一行 |
这一轮的几条经验:
预加载是强制下载。@font-face 没人用就不下,预加载不管用不用都下。多个主题共用的片段尤其要回头查一遍,当初为谁加的。
背景图当最大内容时,浏览器发现得晚。可以预加载,但条件要和 CSS 的切换条件完全一致,不然会下两遍。
加 srcset 之前,先想清楚还有谁在读 src。预览读 src,邮件不能带 srcset。渲染链路里可能有一段会跑两遍,改写要做成幂等的。
网络波动大的时候,看字节数和请求数,比看毫秒稳。

评论区 0