做前端监控和用户画像分析的同学,对 User-Agent 这个东西应该是又爱又恨。爱的是它一直是浏览器信息的主要来源,恨的是这些年 UA 字符串已经膨胀到几乎没法稳定解析了。我手头一个用户行为统计系统里,UA 解析规则维护了好几年,每次 Chrome、Edge 换版本,解析结果里就冒出一批“未知设备”;更别提大量自动化脚本伪造 UA,数据里的口径被冲得乱七八糟。后来我把注意力放到 Client Hints 上,花了两周把主要链路从 UA 迁了过来,才算是把这块信息重新握回手里。这篇文章就把我迁移和踩坑的过程完整写出来,给同样被 UA 坑过的人一条能直接落地的路线。
1. 先从 UA 说起:它到底是怎么走到今天这一步的
1.1 UA 的诞生、膨胀与“抢冒”历史
UA 字符串的历史可以追溯到上世纪 90 年代 NCSA Mosaic 浏览器。当时浏览器之间为了兼容服务器端的适配逻辑,开始发送一个简单的产品标识,这就是 UA 最初的形态。问题出在后面几乎所有厂商都采用了同一个策略:与其让服务器认不出来,不如把自己伪装成 Mosaic 的后代。
这就是为什么你会看到 IE 的 UA 里带着Mozilla/4.0,Chrome 的 UA 里带着Mozilla/5.0 ... Safari/537.36。每个后来者都往这串字符串里追加一层“我也是你认识的那个浏览器”的标记,谁也不愿意删掉前面的部分,因为删了可能会导致某些服务器功能降级或者直接拒绝服务。几十层兼容性叠加下来,UA 从一行短标识变成了一个长得像拼贴画一样的字符串。再加上各种 UA 嗅探和伪造脚本的掺杂,靠正则去解析它,几乎等于在做考古。
我见过有些团队的正则规则里,连like Gecko这种词都要当特征去匹配,结果自然是越来越脆弱。真实情况是,UA 里每一段信息的可信度都在下降:爬虫伪造、浏览器扩展篡改、企业安全软件重写,任何一层都可能让整串 UA 失真。拿这种东西去判断浏览器和操作系统,本质上是在跟历史包袱搏斗。
1.2 Chrome 决定冻结 UA,第三方生态跟着遭殃
Chrome 在 2020 年前后明确提出:要逐步降低 UA 串的信息含量,直至把它冻结成一个固定值,只保留少量兼容性信息。从 Chrome 101 开始,桌面端和移动端的 UA 版本号都开始被限制在主版本,再往后的完整版本、操作系统小版本、设备型号等字段逐步从 UA 串中移除。也就是说,服务器再想通过 UA 拿到 Chrome 120.0.6099.130 这种精确版本号和操作系统 build 号,已经拿不到了。
这个决定的直接后果就是:过去依赖 UA 做浏览器兼容监控、反爬风控、设备型号库匹配、广告归因的系统,全部面临数据断供。我见过不少团队在 Chrome 版本升级后,线上统计里出现大量空 deviceType 和空 browserVersion,排查根因才发现是 UA 解析库没跟上。
这里有个容易让人忽视的点:UA 冻结不等于 UA 完全消失,Chrome UA 仍然会保留到主版本(比如Chrome/120.0.0.0),所以本地简单看个浏览器大版本还能凑合;但一旦需要区分同主版本下的小版本、操作系统具体版本、设备是手机还是平板,UA 这条路基本就废了。这时候,Client Hints 几乎成了唯一一个被厂商正式支持的全新替代方案。
2. Client Hints 的核心思路:把“被动暴露”改成“按需声明”
2.1 一次请求里多出来的那组 Sec- 头
Client Hints 的设计哲学和 UA 完全相反。UA 是浏览器在每次请求里无条件把所有身份信息塞进一个字符串;Client Hints 则是服务器先告诉浏览器“我需要哪些信息”,浏览器再在响应后续请求时,按需把对应的字段带到请求头里。这个机制用几个简单请求头就能说清楚。
服务器响应头(或页面 meta 标签):
Accept-CH: Sec-CH-UA, Sec-CH-UA-Mobile, Sec-CH-UA-Platform浏览器收到之后,会在下一次请求这个源时,自动带上:
Sec-CH-UA: "Chromium";v="120", "Google Chrome";v="120", "Not_A Brand";v="8" Sec-CH-UA-Mobile: ?0 Sec-CH-UA-Platform: "Windows"注意这里的Sec-CH-UA-Mobile取值是?0或者?1,这是结构化字段的布尔类型表示法,可别按字符串去解析。这个细节我在后面还会再提一次,因为它真的很容易写错。
2.2 低熵字段与高熵字段的分工
Client Hints 把字段分成两类。低熵字段(low entropy)像平台、是否移动端、品牌主版本这类信息,隐私风险相对可控,只要服务器声明Accept-CH,浏览器就会直接放行;高熵字段(high entropy)则包括完整版本号、操作系统小版本、CPU 架构、设备型号等精粒度信息,Chrome 在实现上会单独控制,而且不是所有高熵字段都能随便拿到——比如在桌面端你问 model(设备型号),返回基本是空字符串,因为桌面端本就不存在“机型”这个概念。
从 HTTP 请求头看,常用字段可以列成一张表:
| 字段 | 含义 | 熵级别 |
|---|---|---|
Sec-CH-UA | 浏览器品牌和主版本列表 | 低 |
Sec-CH-UA-Mobile | 是否移动端(?0/?1) | 低 |
Sec-CH-UA-Platform | 操作系统平台名 | 低 |
Sec-CH-UA-Platform-Version | 操作系统版本 | 高 |
Sec-CH-UA-Arch | CPU 架构 | 高 |
Sec-CH-UA-Bitness | 架构位数 | 高 |
Sec-CH-UA-Model | 设备型号 | 高 |
Sec-CH-UA-Full-Version | 浏览器完整版本 | 高 |
Sec-CH-UA-Full-Version-List | 完整品牌版本列表 | 高 |
Sec-CH-UA-WoW64 | 是否通过 WoW64 环境运行 | 高 |
高熵信息一般不会直接出现在普通的Accept-CH声明的请求流程里,要获取它们基本靠两条路:要么通过前端 APIgetHighEntropyValues()异步拉取,要么在小范围内依赖响应头里的特定字段。后面我会具体展开。
2.3 GREASE 品牌机制:防止“列表即指纹”
第一次看Sec-CH-UA输出的人都会产生一个疑问:Not_A Brand是什么鬼?甚至有时候是"Not A(Brand";v="99"这种奇奇怪怪的字符串。这是 Chrome 主动引入的 GREASE 机制,目的就是往品牌列表里随机掺入虚构品牌,防止网站把稳定的品牌列表本身当成一种高指纹特征。
这个设计思路借鉴自 TLS 的 GREASE(Generate Random Extensions And Sustain Extensibility)。它保证了任何网站在两次会话里拿到的 brands 列表可能并不完全一致,这样就无法用 brands 列表来稳定地标记一个用户。解析Sec-CH-UA时必须忽略这些虚构品牌,如果你拿整个列表做 hash 或者当身份特征,数据会直接失真。
3. 服务端接入实战:别让一层 Nginx 把事儿搞砸
3.1 让 Nginx 正确透传 Client Hints
接入 Client Hints 最容易踩而大多数人没意识到的一步,是 Nginx 或 CDN 默认会丢弃非标准请求头。你明明在业务代码里已经写了Accept-CH的响应头,浏览器也老老实实返回了Sec-CH-*请求头,结果到了应用层一打印 headers 全是空的——大概率就是中间层没透传。
所以在 Nginx 层必须显式把这些头放行:
# 反向代理场景下,把 Sec-CH-* 带上来 location /api/ { proxy_set_header Sec-CH-UA $http_sec_ch_ua; proxy_set_header Sec-CH-UA-Mobile $http_sec_ch_ua_mobile; proxy_set_header Sec-CH-UA-Platform $http_sec_ch_ua_platform; }如果应用前面还有一层 CDN,需要在 CDN 控制台把Sec-CH-*请求头加入透传白名单;如果做的是 WAF 网关,同样要检查默认规则会不会把它当作未知头拦截。这一步比写业务代码更容易出问题,而且报错表现非常隐蔽,后面我会专门讲一次完整的排查案例。
3.2 让浏览器“记住”你要的字段
光透传还不行。服务端还得主动向浏览器声明,告诉它后续请求需要携带哪些字段。两个办法,优先推荐用响应头:
add_header Accept-CH "Sec-CH-UA, Sec-CH-UA-Mobile, Sec-CH-UA-Platform, Sec-CH-UA-Model" always;另一条路线是在 HTML 里放 meta 标签:
<meta http-equiv="Accept-CH" content="Sec-CH-UA, Sec-CH-UA-Mobile, Sec-CH-UA-Platform, Sec-CH-UA-Model">两者的区别在于:响应头方案不依赖页面结构,全站任意一个接口返回了它,浏览器就会记住这个声明;meta 方案则必须等页面 HTML 解析到之后才生效,适合主页面首屏和多入口应用。一般建议在入口 HTML 和主要 API 响应里各放一份,双保险。
这里再补充一个容易被忽略的细节:Accept-CH只有在安全上下文(HTTPS)下才会生效,HTTP 明文站点浏览器基本不会理会这组头。本地调试可以用 localhost,但线上机器配了证书之后才能看到完整效果。
3.3 后端解析 Sec-CH-UA 的姿势
在 Python/Node 里解析这一段带引号和逗号绞在一起的头,最稳妥的做法不是写正则去掰,而是按结构化字段的语法去解析。Chrome 的Sec-CH-UA总是以"Brand";v="version"的键值对形式出现,所以可以先把整个字符串按逗号分割,再对每个片段做引号内容提取。
import re def parse_sec_ch_ua(header_value: str | None) -> list[dict]: if not header_value: return [] result = [] # 匹配 "Brand";v="version" pattern = r'"([^"]+)";\s*v="([^"]+)"' for match in re.finditer(pattern, header_value): brand, version = match.groups() # 过滤 GREASE 虚构品牌 if brand.lower().startswith(("not", "not_a")): continue result.append({"brand": brand, "version": version}) return result注意,只把Chromium和Google Chrome两个品牌当作主要解析对象,其他品牌按 GREASE 忽略即可。如果你想拿到“用户当前用的是哪家浏览器”,判断逻辑通常是:品牌列表里有没有Google Chrome,有就是 Chrome;没有但带着Microsoft Edge,就是 Edge。这里有个坑:Chrome 里的 Chromium 品牌永远在,但Google Chrome可能在部分自动化环境下不返回,需要写 fallback。
3.4 首跳问题:第一次请求该信谁
Client Hints 有一个天然的先有鸡还是先有蛋问题:服务器第一次收到请求时,还没机会声明Accept-CH,所以这第一跳的请求头里是不会有Sec-CH-*的。也就是说,哪怕你部署好了整套服务,第一个用户的第一批接口请求,拿到的仍然只有 UA。
应对策略有三种。第一种,用 meta 标签在入口 HTML 里声明,加载页面后页面里的脚本请求就能带上头;第二种,接受首跳缺失,在这条请求上回退到 UA 解析,从第二个请求开始用 Client Hints 数据;第三种,如果业务非常较真,可以在关键页面下通过一个极小的返回头资源把声明提前打到浏览器。我实际项目里用的是“meta 声明 + UA 降级”的组合,首跳对统计结果的影响几乎可以忽略,因为首跳也会很快触发二次请求。
4. 前端视角:userAgentData 不再是“等着挨坑”
4.1 navigator.userAgentData 的基本用法
如果后端能拿到Sec-CH-*,前端其实有原生 API 可以直接读取同样信息,这就是navigator.userAgentData。拿它判断设备类型比过去的手段干净很多:
const uaData = navigator.userAgentData; // 低熵信息同步可用 const isMobile = uaData.mobile; // true/false const platform = uaData.platform; // "Windows" const brands = uaData.brands; // [{brand, version}, ...]过去我们判断“是不是手机上打开的页面”,十个团队有十种正则写法,什么Mobile/Android/iPhone/iPad到处匹配,误判率不低。用uaData.mobile一锤定音,至少从语义上直接拿到了浏览器的真实答案。
4.2 用高熵 API 补齐设备画像
需要完整版本号、CPU 架构、设备型号时,调用:
const hints = await navigator.userAgentData.getHighEntropyValues([ "architecture", "model", "platformVersion", "uaFullVersion", "fullVersionList", ]); // 输出示例 // architecture: "x86" // model: "" // platformVersion: "15.3.0" // uaFullVersion: "120.0.6099.130" // fullVersionList: [{brand: "Chromium", version: "120.0.6099.130"}, ...]这里有两个实际经验值得记录。第一,getHighEntropyValues()是个异步接口,调用后 Chrome 内部可能会做隐私相关的延迟或门控,所以前端在关键时间线里不要拿它做同步依赖。第二,高熵结果不是稳定返回的:桌面端的 model 一般是空字符串;平台版本在不同操作系统上格式也不同,Windows 返回的是版本号数组里的值,macOS 返回的是类似15.3.0的构建版本,使用前最好做一层归一化。
4.3 先别急着把所有 UA 判断都改掉
前端改 UA 判断有一个比较微妙的处境:许多现有第三方 SDK 和应用里,navigator.userAgent字符串已经被用来做反爬标记、渠道识别,或者已经写入了一些底层缓存键值。贸然把所有代码切换到userAgentData,可能导致需要同步更新后端上报字段、埋点字典和分析报表。
我的建议是分两步走:先把新增逻辑用userAgentData写,同时在后端保留一份 UA 原始字段用于问题排查;等下一个大版本再把历史 UA 逻辑下线。不要在混合过渡期删除 UA 解析,因为你永远不知道哪些匿名访客的浏览器不支持userAgentData。
5. 那些文档里不会写的坑:迁移避坑全记录
5.1 兼容性矩阵:哪些浏览器“中看不中用”
提到兼容性,很多文章只会给一张“支持 Chrome/Edge”的结论,实务上远不止这么简单。以下是我梳理的一份比较有区分度的对照表:
| 能力 | Chrome 90+ | Edge 90+ | Safari 16.4+ | Firefox |
|---|---|---|---|---|
navigator.userAgentData(低熵) | 支持 | 支持 | 部分支持 | 不支持 |
getHighEntropyValues()(高熵) | 支持 | 支持 | 部分字段 | 不支持 |
请求头Sec-CH-UA-* | 支持 | 支持 | 有限 | 有限 |
| UA 冻结/版本降级 | 已生效 | 已生效 | 未完全冻结 | 已冻结 |
Safari 和 Firefox 的情况要单独拿出来说:Safari 16.4 开始跟进低熵 API,但高熵字段列表不一致,使用前需要做能力检测;Firefox 则长期对navigator.userAgentData持保留态度,Mozilla 团队公开表态认为这个 API 存在指纹风险,因此一直没有跟进实现。对 Firefox 的降级就只能继续解析 UA 字符串——好在 Firefox 的 UA 相对稳定,风险可控。
5.2 踩坑1:GREASE 品牌污染统计
我第一次接入后看统计,发现Not_A Brand赫然排在浏览器品牌榜第二,当场以为自己解析错了。后来才明白这纯粹是 Chrome 的 GREASE 策略在起作用。这种假品牌在每次请求里可能都不一样,如果统计系统按品牌字符串直接 group by,就会凭空多出一堆噪音。正确做法是在写入统计表之前就过滤掉疑似 GREASE 的品牌,只保留能和其他字段对得上的真实品牌。
5.3 踩坑2:中间层吃掉了 Accept-CH 响应头
这是经常被忽略的:很多网关和反向代理默认只透传标准响应头给客户端,Accept-CH和Critical-CH很容易在中间层被静默丢弃。表现是浏览器开发者工具里能看到响应头里有Accept-CH,但抓包发现客户端根本收不到。排查链路时要把浏览器、代理、应用三层分开抓。解决办法很粗暴:在 Nginx 或网关配置里显式add_header Accept-CH ... always;,不仅让应用层返回,也让边缘节点自己返回一份。
5.4 踩坑3:CDN 缓存导致声明时长不可控
另一个和首跳问题容易混淆的坑是缓存:假如你给接口配置了 CDN 缓存或反向代理缓存,浏览器返回的 HTML 或者 JS 可能是直接命中缓存的,此时Accept-CH声明虽然还是能带上(它是响应头的一部分),但浏览器对持久化声明的记忆时长会变得不可控。原本还可以通过Accept-CH-Lifetime这个字段让浏览器长期记住要带哪些提示头,但这个字段在 Chrome 新版本里已经进入弃用流程,因为它要么永久生效要么立即失效,缓存粒度太粗暴。我更推荐用“响应头 + meta 双写”来规避这个不确定性问题。
5.5 降级与共存的最终建议
无论把 Client Hints 说得多么美好,现实中还是会有不支持它的浏览器、隐私插件或者企业安全策略把它禁了。所以我的最终建议是:Client Hints 当作主要数据源,UA 解析当作降级兜底。判断依据是“这个环境里是否存在navigator.userAgentData”,存在优先用它;不存在再用 UA。逻辑不复杂,但能给两边都留好退路。
整体迁移下来我的直观感受是:Client Hints 并没有让浏览器信息获取一步到位,但对服务端来说,至少告别了用“字符串考古”去猜用户设备的阶段。数据源稳定之后,你才能把精力放到真正重要的业务判断上去。如果你手头也有一套被 UA 折腾到头秃的统计或风控系统,不妨先拿一个低频场景试水,把声明、透传、降级三条链路跑通,再逐步放大范围。这个过渡,值得早做。