静态资源加速的四个静默失效点:preload 缺 crossorigin、字体闪烁、HTTP/3 未协商与 Vary 缺失

静态资源「差一点点」变慢,往往不是带宽的事

个人站长做性能优化,第一反应通常是加带宽、上 CDN、装缓存插件。但有一类变慢非常顽固:服务器 CPU 与带宽都很空闲,CDN 也接了,用户却反馈「打开有点慢,尤其是手机上」。这类问题通常落在静态资源的交付链路细节上:预加载没有生效、字体造成文字闪烁、协议升级没真正落地。这些点的共同特征是——改对了几乎没有感知,改错了却持续拖慢首屏。

本文分四层讲清这四件事:预加载为什么没生效、字体为什么会闪烁、HTTP/2 与 HTTP/3 怎么确认真的生效、以及如何验证优化确实带来了提升而不是自我安慰。每一层都给出可直接执行的验证命令。

第一层:preload 写了却没生效,多半是四个前置条件没满足

预加载(<link rel="preload">)的原理是让浏览器在解析到该标签时提前发起资源请求,把「等 CSS 再发现字体」串行链路变成并行。它的效果非常直观,但失效的方式也非常多。

条件一:必须带 as 属性。 缺了 as,浏览器不知道资源类型,预加载会被直接忽略,只在控制台留一条警告,很容易被忽略。

条件二:必须带正确的 crossorigin(针对字体)。 这是字体预加载失效的头号原因。字体请求是 CORS 请求,如果预加载时没带 crossorigin,浏览器会认为「预加载的这份」与「实际需要的这份」不是同一个请求,于是重新发一次,预加载白做。

<!-- 正确:字体预加载必须同时带 as 和 crossorigin -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font"
      type="font/woff2" crossorigin="anonymous">

<!-- 错误:缺 crossorigin,字体会被重复请求,控制台会有警告 -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font">

条件三:预加载的资源必须真的会被用到。 如果预加载了一张只在移动端才用的图,桌面用户就白下载了一份,反而抢占了关键资源的带宽。预加载是「插队」,插错了队伍比不插更糟。

条件四:不要预加载太多。 浏览器对预加载的资源会提示「未使用」警告,超过一定数量后,预加载的优先级优势会被稀释。经验法则是首屏关键路径上的资源不超过 3 到 4 个。

验证方法很直接,看浏览器是否报了未使用警告:

# 用控制台检查(Chrome DevTools Console):
# "The resource ... was preloaded using link preload but not used within
#  a few seconds from the window's load event" → 预加载了但没用上
# 用 curl 确认字体真的响应了正确的 CORS 头
curl -sI https://example.com/fonts/inter-var.woff2 | grep -i 'access-control-allow-origin'

第二层:字体闪烁(FOIT / FOUT),关键是 font-display 与自托管

字体造成的体验问题有两种:文字先隐形再出现(FOIT),或者先用系统字体渲染然后「跳」一下(FOUT)。它们都来自同一个机制——浏览器等待字体文件的这段时间里该如何渲染文字,这个行为由 font-display 控制。

@font-face {
    font-family: 'Inter';
    src: url('/fonts/inter-var.woff2') format('woff2');
    font-weight: 100 900;
    font-display: swap;        /* 先用系统字体显示,字体到了再替换:不闪空白 */
    /* 可选值:
       block    → 短暂隐形后出现(易 FOIT)
       swap     → 立即用后备字体,最不卡首屏(推荐内容站)
       fallback → 极短隐形 + 短替换期,超过就永久用后备
       optional → 由浏览器决定,网络慢时干脆不用自定义字体(最稳) */
}

对内容站,推荐 swapoptional:文字第一时间可见,比字形完美更重要。这里有两个常被忽略的配套细节:

  • 第三方字体服务会拖慢首屏。 引用外部字体 CDN 意味着多一次 DNS 解析 + 一次 TLS 握手,中文字体包还往往体积巨大(几百 KB 到几 MB)。自托管并按需子集化是内容站性价比最高的优化:只打包页面真正用到的字符集,一个中文字体包能从 3MB 压到几十 KB。
  • 字体度量差异会导致明显的布局跳动(CLS)。 自定义字体和系统字体的字宽不同,swap 之后文字会重新排版,页面元素跟着位移。缓解方式是让后备字体尽量接近目标字体(font-family 后备链里放同类别字体),或者使用 size-adjust 调整后备字体的度量,减少替换时的跳动幅度。
@font-face {
    font-family: 'Inter';
    src: url('/fonts/inter-var.woff2') format('woff2');
    font-display: swap;
    size-adjust: 107%;          /* 让后备字体宽度接近 Inter,减少布局跳动 */
}
/* 后备链按同类别字体排列,替换时形变最小 */
body { font-family: 'Inter', -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif; }

第三层:HTTP/2 与 HTTP/3,怎么确认真的生效了

很多站长以为加了 CDN 就自动用上了 HTTP/2 / HTTP/3,实际上是否生效取决于四件事:服务端是否开启、ALPN 是否协商成功、CDN 是否透传、客户端是否支持。任何一环断了都会静默回落到 HTTP/1.1,而你在配置里看不出任何异常。

确认服务端监听与协议开启:

# Nginx 中显式开启 HTTP/2(1.25+ 推荐用 http2 on 指令,旧写法是 listen ... http2)
server {
    listen 443 ssl;
    http2 on;
    listen 443 quic reuseport;      # HTTP/3 需要 QUIC 支持
    add_header Alt-Svc 'h3=":443"; ma=86400' always;   # 告知客户端可用 h3
    ssl_early_data on;              # 0-RTT,配合安全策略取舍
}

其中 Alt-Svc 是 HTTP/3 生效的关键:浏览器只有在收到这个响应头、并且成功用 QUIC 连上一次之后,才会在后续访问中优先使用 HTTP/3。所以「刚配置完立刻测出 h3」是不现实的,需要两次访问。

验证是否真的协商成功:

# 用 curl 强制 HTTP/2 请求,看服务端是否接受(返回 200 且协议为 HTTP/2)
curl -sI --http2 https://example.com/ -w 'proto=%{http_version}' -o /dev/null; echo

# 用 OpenSSL 看 ALPN 协商结果(服务端是否宣告 h2)
openssl s_client -connect example.com:443 -alpn h2 -servername example.com < /dev/null 2>/dev/null \
  | grep -i 'ALPN'

# 若有 nghttp 工具,直接看协商详情
nghttp -nv https://example.com/ 2>&1 | grep -iE 'protocol|negotiated'

# HTTP/3 需要专门的客户端验证(curl 需编译 QUIC 支持)
curl --http3 -sI https://example.com/ -w 'proto=%{http_version}' -o /dev/null; echo

最关键的判断依据:CDN 是否透传。 如果 CDN 回源用 HTTP/1.1,或者 CDN 本身不支持 h3,那么无论你源站怎么配,用户侧都得不到 HTTP/3。判断方式是看响应头里的 viaserver 以及 CDN 面板里的协议开关,别只看自己的 Nginx 配置。协议优化的收益在「高延迟、多请求」场景最明显,如果你的站首屏只有两三个请求,HTTP/3 带来的提升会很有限,不必为它牺牲稳定性。

第四层:怎么确认优化真的有用,而不是自我安慰

性能优化最大的陷阱是「凭感觉」。改完配置感觉快了,实际上可能是网络波动或者缓存命中的巧合。要拿到可信结论,必须固定测量条件:同一个 URL、同一台机器、同样的网络、连续测多次,然后看中位数而不是最好的一次。单次测量永远会骗你,因为网络抖动、CDN 节点漂移、浏览器缓存状态都会让同一次请求出现两三倍的差异。下面这几条命令固定了关键变量,可以在改配置前后各跑一遍做对比。

# 1) 固定测量首字节时间(TTFB)与总耗时,多次取中位数,避免单次波动误导
for i in 1 2 3 4 5; do
  curl -o /dev/null -s -w 'dns=%{time_namelookup} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}' \
    "https://example.com/?_=$RANDOM"; echo
done

# 2) 分离「缓存命中」与「回源」两种情况分别测
curl -o /dev/null -s -w 'normal total=%{time_total}' https://example.com/style.css; echo
curl -o /dev/null -s -w 'no-cache total=%{time_total}' -H 'Cache-Control: no-cache' https://example.com/style.css; echo

# 3) 检查资源是否真的被压缩与长缓存
curl -sI -H 'Accept-Encoding: gzip, br' https://example.com/style.css \
  | grep -iE 'content-encoding|cache-control|content-length|vary'

第 3 条是很多人忽略的核心:必须同时看 Vary。如果响应是压缩过的却没有 Vary: Accept-Encoding,CDN 或中间缓存可能把压缩版本发给不支持压缩的客户端,或者把未压缩版本发给所有人,导致优化时好时坏、无法复现。这类问题最难查的地方在于——它只在「换了一台客户端或换了一个 CDN 节点」之后才出现,而你自己测试时永远是好的。

还要区分两种"变快":缓存命中变快是假快,回源变快才是真快。 很多站长感觉优化成功后,实际上只是 CDN 边缘缓存命中了,回源链路一点没变。一旦缓存过期或者被刷新,用户立刻又慢回来。所以测量时一定要显式对比「带 no-cache 请求」的那一组数据——它代表的是最坏情况下的真实速度,也是搜索引擎爬虫第一次抓取时体验到的速度,因为爬虫几乎从不带本地缓存。

另外提醒一点:优化要有可回滚的记录。 每次只改一项,改完记录测量数据,再改下一项。一次性改五个配置,测出变快了你也不知道是哪一项起了作用;测出变慢了你更不知道该回滚哪一个。个人站长没有团队帮你 A/B 测试,靠「一次一项 + 固定测量」来替代是最务实的办法。

一张按收益排序的执行清单

  1. 先修字体: 自托管 + 子集化 + font-display: swap。中文字体包体积大,这一项收益最高。
  2. 再修预加载: 只预加载首屏 3 到 4 个关键资源,字体务必带 crossorigin,并在控制台确认没有「未使用」警告。
  3. 然后确认压缩与缓存头: Content-EncodingCache-ControlVary 三者同时正确才算完成。
  4. 最后才是协议升级: HTTP/2 优先确认 ALPN 协商;HTTP/3 需要 Alt-Svc 与两次访问才生效,CDN 侧不透传就没有意义。
  5. 全程固定测量: 一次一项、多次取中位数、TTFB 与 total 分开看,避免被单次波动骗。

总结

静态资源优化的难点从来不是「知不知道要压缩、要上 CDN」,而是每一个优化点都依赖若干前置条件,而这些条件不满足时过程是静默的:preload 缺了 crossorigin 就白做、字体没设 font-display 就闪、HTTP/3 没有 Alt-Svc 就永远协商不上、压缩没有 Vary 就在 CDN 上时好时坏。所以正确的做法不是把优化项全部勾上,而是每改一项就用固定条件的多次测量证明它确实生效了,再动下一项。对个人站长来说,能解释清楚「为什么这一项有效、怎么验证它有效」,比堆满一堆优化清单更有价值,因为只有这样,下一次变慢时你才有能力定位,而不是重新把清单再抄一遍。

Last modification:September 24th, 2026 at 01:25 pm

Leave a Comment