告别UA字符串考古:Client Hints迁移实战指南 📅 发布时间:2026/9/16 5:19:34 👁 浏览次数: 做前端监控和用户画像分析的同学对 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.0Chrome 的 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;v120, Google Chrome;v120, Not_A Brand;v8 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-ArchCPU 架构高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;v99这种奇奇怪怪的字符串。这是 Chrome 主动引入的 GREASE 机制目的就是往品牌列表里随机掺入虚构品牌防止网站把稳定的品牌列表本身当成一种高指纹特征。这个设计思路借鉴自 TLS 的 GREASEGenerate 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-equivAccept-CH contentSec-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;vversion的键值对形式出现所以可以先把整个字符串按逗号分割再对每个片段做引号内容提取。import re def parse_sec_ch_ua(header_value: str | None) - list[dict]: if not header_value: return [] result [] # 匹配 Brand;vversion 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 90Edge 90Safari 16.4Firefoxnavigator.userAgentData低熵支持支持部分支持不支持getHighEntropyValues()高熵支持支持部分字段不支持请求头Sec-CH-UA-*支持支持有限有限UA 冻结/版本降级已生效已生效未完全冻结已冻结Safari 和 Firefox 的情况要单独拿出来说Safari 16.4 开始跟进低熵 API但高熵字段列表不一致使用前需要做能力检测Firefox 则长期对navigator.userAgentData持保留态度Mozilla 团队公开表态认为这个 API 存在指纹风险因此一直没有跟进实现。对 Firefox 的降级就只能继续解析 UA 字符串——好在 Firefox 的 UA 相对稳定风险可控。5.2 踩坑1GREASE 品牌污染统计我第一次接入后看统计发现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 踩坑3CDN 缓存导致声明时长不可控另一个和首跳问题容易混淆的坑是缓存假如你给接口配置了 CDN 缓存或反向代理缓存浏览器返回的 HTML 或者 JS 可能是直接命中缓存的此时Accept-CH声明虽然还是能带上它是响应头的一部分但浏览器对持久化声明的记忆时长会变得不可控。原本还可以通过Accept-CH-Lifetime这个字段让浏览器长期记住要带哪些提示头但这个字段在 Chrome 新版本里已经进入弃用流程因为它要么永久生效要么立即失效缓存粒度太粗暴。我更推荐用“响应头 meta 双写”来规避这个不确定性问题。5.5 降级与共存的最终建议无论把 Client Hints 说得多么美好现实中还是会有不支持它的浏览器、隐私插件或者企业安全策略把它禁了。所以我的最终建议是Client Hints 当作主要数据源UA 解析当作降级兜底。判断依据是“这个环境里是否存在navigator.userAgentData”存在优先用它不存在再用 UA。逻辑不复杂但能给两边都留好退路。整体迁移下来我的直观感受是Client Hints 并没有让浏览器信息获取一步到位但对服务端来说至少告别了用“字符串考古”去猜用户设备的阶段。数据源稳定之后你才能把精力放到真正重要的业务判断上去。如果你手头也有一套被 UA 折腾到头秃的统计或风控系统不妨先拿一个低频场景试水把声明、透传、降级三条链路跑通再逐步放大范围。这个过渡值得早做。