Accept-CH响应头:让首屏布局不再跳动

为什么我给 Nginx 加了一条 Accept-CH 响应头

事情起于一次 Core Web Vitals 报告:移动端 CLS(累积布局偏移)长期在 0.18 左右,而我在 CSS 里明明给所有图片、广告位、字体都写了固定宽高。查了三天才发现,问题出在浏览器拿到的视口信息和服务器以为的不一致——服务器下发的首屏 HTML 是按桌面端结构生成的,浏览器在移动网络上又做了一次重排,广告位在重排后才撑开,于是整页往下跳了一下。

解决方案不是改 CSS,而是让浏览器在首次请求时就把关键的环境信息带给服务器:Accept-CH 响应头 + Critical-CH。这套机制叫 Client Hints(客户端提示),是 Chrome 系浏览器从 89 版本开始逐步推开的。这篇把这个头的用法、坑、以及和 SEO/性能的关系讲清楚。

一、Accept-CH 到底是什么

以前服务器想判断设备,只能靠 User-Agent 字符串。UA 又长又乱、被各种浏览器冻缩(User-Agent Reduction),还涉及指纹追踪的隐私问题。Client Hints 的思路是:服务器明确声明"我需要哪些信息",浏览器在后续请求里通过请求头把这些信息带上来。

服务器声明的方式就是响应头:

Accept-CH: Viewport-Width, DPR, Device-Memory, Save-Data, Sec-CH-Prefers-Color-Scheme

浏览器看到这个头之后,会在下一次同源请求时把这些提示作为请求头发上来,例如:

Viewport-Width: 412
DPR: 2.625
Device-Memory: 4
Sec-CH-Prefers-Color-Scheme: dark

注意"下一次"这三个字:Client Hints 天生有一次延迟。首次请求拿不到这些值,服务器只能给一个保守的默认响应。这对首屏 LCP 并不利,于是有了 Critical-CH。

二、Critical-CH:让浏览器在首屏就重发一次

把需要的提示同时列进 Critical-CH,浏览器会在收到响应后立刻用带上这些头的请求重试一次,服务器这次就能返回精确的响应。代价是多一次请求(通常是几十毫秒,走同一个连接),收益是首屏布局从第一次渲染就正确。

add_header Accept-CH "Viewport-Width, DPR, Device-Memory, Sec-CH-Prefers-Color-Scheme" always;
add_header Critical-CH "Viewport-Width, DPR" always;

两条规则:

  • Critical-CH 里列出的必须同时在 Accept-CH 里出现,否则浏览器忽略它。这是最容易犯的错,写了两行但忘了同步。
  • 只把"影响首屏布局"的放进去。全部塞进 Critical-CH 会让重试请求变重,得不偿失。我一般只放 Viewport-Width 和 DPR。

三、缓存策略:不加 Vary 会出大事故

这是整套机制里最危险的地方。Viewport-Width 是请求头,如果你的 HTML 响应被 CDN 或 Nginx 的 proxy_cache 缓存了,而缓存键里不含这个头,那么第一个访客(比如桌面端)拿到的 HTML 会被发给后面所有移动端访客。

必须显式声明变体:

add_header Vary "Viewport-Width, DPR" always;

但 Vary 有个副作用:每个不同的视口宽度都会生成一份独立缓存对象。手机视口宽度有几十种取值,缓存命中率会崩。正确的做法是把宽度分档,而不是原样使用:

map $http_viewport_width $vw_class {
    default      desktop;
    "~^[0-3]"    narrow;      # 300~399
    "~^[4-5]"    mid;         # 400~599
    "~^[6-9]"    tablet;      # 600~999
}
# 缓存键里用分档后的值,而不是原始头
proxy_cache_key "$scheme$host$request_uri$vw_class";

如果你用的是 Cloudflare 之类的 CDN,情况更复杂:边缘缓存键默认不含 Client Hints,需要在 Transform Rules 里把分档值拼进 Cache Key。做不到就干脆别对 HTML 用 CDN 缓存,只在源站做分档——很多静态站在这一步选了保守方案,也是对的。

四、客户端提示也是"权限":需要 Permissions-Policy

部分提示(尤其是高熵提示,如 Device-Memory、Sec-CH-UA-Full-Version)受 Permissions-Policy 控制。如果策略里被禁用,即使写了 Accept-CH,浏览器也不会发送。默认情况下大多数提示是允许的,但如果你站点上有一条全局的收紧策略:

add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

那就要确认没有把 ch-viewport-width 之类的项设为空。调试方法很简单——打开 DevTools 的 Network 面板,看第二个请求里到底有没有 Viewport-Width 头。没有的话,要么是 Accept-CH 没生效,要么是被策略挡了,要么是浏览器不支持。

五、兼容性:只有 Chrome 系认,Safari/Firefox 不认

这是必须接受的前提:Safari 和 Firefox 基本不支持 Client Hints。所以服务端逻辑必须有回退路径,不能假设这些头一定存在。Nginx 里的写法:

map $http_viewport_width $layout {
    default        desktop;   # 没有头就按桌面处理,需要 JS 兜底
    "~^[0-3]"      narrow;
    "~^[4-5]"      mid;
    "~^[6-9]"      tablet;
}

对不支持的浏览器,靠 CSS 媒体查询和 srcset 自行适配即可,服务端只做"锦上添花"。别为了这条头把首屏做成必须依赖它的逻辑——那会导致 Safari 用户看到完全错误的布局。

顺带提一句:Save-Data 这个提示很实用。它在用户开启省流量模式时为 on,可以用来关闭自动播放、降级大图:

map $http_save_data $img_quality {
    default    q85;
    "on"       q60;
}

六、和 SEO 的关系:CLS 是排名信号

回到最初的问题。Client Hints 本身不是 SEO 手段,但它服务的 CLS 是 Google 的页面体验信号之一。我的实测结果是移动端 CLS 从 0.18 降到 0.04,LCP 基本不变(甚至因为多了一次重试请求,个别页面慢了 20~30ms),但因为布局稳定,用户滚动深度和停留时间都变好了。指标改善只是结果,不是目的。

要提醒的是:Googlebot 用的是无头 Chrome,它支持 Client Hints,但抓取时的视口宽度固定为 411×731 左右(移动优先索引)。如果你的服务端按视口分档渲染不同内容,务必保证分档不影响正文内容和内链,否则会出现"桌面端和移动端被索引成两套内容"的隐患。安全做法是:分档只影响图片尺寸和广告位占位,绝不影响文字内容与链接结构。

七、上线检查清单

最后给一份我自己的核对清单,按顺序做,能避开九成的坑:

  • 1. Accept-CH 与 Critical-CH 同时配置,且后者是前者的子集;
  • 2. 加 Vary 头,且缓存键用分档值而非原始视口宽度;
  • 3. 用 curl -I 确认响应头真的出了(很多人加在 location ~ \.php$ 里,结果静态 HTML 根本没走那段):
curl -sI https://example.com/ | grep -iE 'accept-ch|critical-ch|vary'
  • 4. 用 DevTools Network 看第二个请求是否带上 Viewport-Width;
  • 5. 在 Safari 里打开首页,确认布局没有错乱(回退路径生效);
  • 6. 跑一次 PageSpeed Insights,对比移动端 CLS 前后数据;
  • 7. 观察 CDN 的缓存命中率一周,如果掉得厉害,说明缓存键分档没做对。

一条响应头,牵动缓存、权限、兼容、SEO 四件事。这也是我一直说的:服务器配置里最贵的从来不是写那行代码,而是想清楚它在整条链路里会碰到谁。

八、把 Accept-CH 和缓存、CDN 一起验一遍

前面讲的是配置,这一节讲验收。因为这套机制最典型的翻车不是"头没生效",而是"头生效了但缓存串了"——用户看到的是别人的布局,而且只在部分网络路径上复现,极难排查。

第一步,确认源站确实下发。用 curl -I 看响应头,注意必须请求一个真实的 HTML 路径,而不是首页重定向后的 301:

curl -sI -H 'User-Agent: Mozilla/5.0 (Linux; Android 13)' https://example.com/article/123 | grep -iE 'accept-ch|critical-ch|vary|cache-control'

如果 Vary 里没有出现你声称变化的那个维度,那缓存一定会在某个时刻串号。宁可不加 Accept-CH,也不要加了却不加 Vary——前者只是没优化,后者是线上故障。

第二步,模拟不同视口验证缓存键。连续发两次请求,第一次带 Viewport-Width: 375,第二次带 Viewport-Width: 1440,比较返回的 HTML 是否有差异,以及响应头里 X-Cache(或 CDN 的对应头)是否都是 HIT:

for w in 375 1440; do
  curl -s -D - -o /dev/null -H "Viewport-Width: $w" https://example.com/article/123 | grep -iE 'x-cache|x-cache-status|age'
done

如果第二次是 MISS,说明缓存键区分开了,行为正确;如果第二次就是 HIT 且内容与第一次完全相同,缓存键没生效,需要检查 proxy_cache_key 或 CDN 的 Cache Key 规则。

第三步,验证 Critical-CH 的重试行为。这一步只能靠 DevTools:打开 Network,勾选 "Preserve log",访问首页,应当看到第一个文档请求之后紧跟一个同 URL 的第二个请求,且第二条带有 Client Hints 请求头。若没有第二个请求,多半是 Critical-CH 里的项没在 Accept-CH 中出现,或者浏览器版本太旧。

第四步,观察一周的缓存命中率。分档做对之后,缓存对象数量会增加 2~4 倍,但命中率不应明显下降(因为档位有限)。如果命中率掉了 20% 以上,说明某个环节仍在用原始视口宽度做键——最常见的是 CDN 的默认 Cache Key 里带了 Vary 的全部维度,而 Nginx 的 Vary 又写成了原始头名。

最后提醒一句:这套配置改完之后,不要立刻清理 CDN 全量缓存。让旧对象自然过期,同时观察错误率和 CLS 指标,等新对象铺开后再做全量刷新,否则会在一个时间窗里出现新旧两套渲染并存,比不改还乱。

Last modification:October 28th, 2026 at 07:24 pm

Leave a Comment