
做前端统计、广告归因、后端反爬策略的人大概都跟 User-Agent 这个字段相爱相杀过。早些年判断设备型号、区分移动端和桌面端、做灰度放量全指望从 UA 里抠信息。但 UA 这套机制本身已经快三十岁了格式越来越混乱信息越来越不可靠用户随便装个插件就能把它改得面目全非而且 Chrome 已经明确提出要逐步冻结 UA。这几年主推的 Client Hints 方案思路彻底变了浏览器不再给你一份“面子工程”式的自述而是按你的请求和权限精准告诉你它真实知道的设备能力。这篇文章我就把 Client Hints 的核心逻辑、服务端和前端接入方式以及我在实际落地过程中踩过的坑一次讲清楚。这套内容特别适合这几类人需要做设备识别、体验分层、广告归因和流量分析的开发者以及在 nginx、CDN 层处理过真实流量、被 UA 识别搞到头大的后端工程师。不管你对 UA 多熟读完以后再接到“帮我识别一下用户是什么设备”这种需求应该会更愿意优先考虑 Client Hints而不是继续在正则表达式里打转。1. 认识 User-Agent 的“历史包袱”为什么非换不可1.1 UA 的来历与格式混乱的根源先说一个很多人忽略的事实今天你在服务端日志里看到的绝大多数 UA都不完全是“真实”的。以最常见的 Chrome UA 为例Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36这串看起来非常“规矩”的字符从头到尾几乎都是历史妥协的产物。早期的 Mosaic 浏览器只发送简短标识后来 Netscape 为了兼容 Mosaic 页面把自己伪装成Mozilla/1.0。再后来 IE 出现为了拿到那些“只给 Mozilla 优化过”的页面又不得不在 UA 前面加上Mozilla/4.0。AppleWebKit 和 Safari 两个词混在 Chrome 的 UA 里更是因为 Chromium 项目为了通过当时网站的 UA 嗅探主动声明了自己兼容 WebKit 标准。说白了UA 是一份布满了兼容性伤疤的“历史自述”。每种浏览器都想让服务器相信自己“够格”渲染老页面于是大家竞相往字符串里塞关键词最后形成了一锅大杂烩。在今天看这段自我陈述既不需要服务器“信任”也没有任何加密或校验机制完全依赖浏览器的自觉。1.2 UA 识别的四大痛点伪装、误判、隐私与维护成本实际生产环境里UA 带来的问题远不止“丑”。我总结成四类第一是伪装成本极低。任何网络请求工具都能随便改 UA浏览器扩展也可以一键切换。对做内容安全和反爬的人来说UA 基本不能作为可信信号因为它连“自报家门都可能是假的”这件事都无法防御。第二是语义模糊导致误判频发。典型场景是 iPad。iPad 在请求桌面版网站时UA 会把系统字段伪装成 Macintosh 的Intel Mac OS X 10_15_7于是后端拿 UA 判断“是否移动设备”就会翻车。Windows 上还有Windows NT 6.1代表 Win7、Windows NT 10.0代表 Win10/Win11 的老问题同样的系统字符串无法区分版本。这类“同一值对应多种真实情况”的映射靠 UA 永远理不清。第三是隐私暴露不可控。UA 在每次请求里都会带上设备型号、系统版本、浏览器版本这些信息用户毫无拒绝能力。想做隐私保护的用户只能通过伪装 UA 的方式“自残式”防御这又进一步污染了统计数据。第四是维护成本高。为了从 UA 里解析出浏览器和系统版本你需要一份不断更新的正则规则库。新浏览器版本一出库就要跟着发版不然字段识别就断档。我在项目里换过几次 UA 解析库每次都要处理“突然不识别新版内核”的告警。Chrome 从 2020 年开始执行 User-Agent Reduction 计划本质上就是在“杀死”UA逐步把系统版本、完整版本号从 UA 字符串里剥离最终让 UA 冻结成一段固定的占位字符串。到时候想靠 UA 做精细识别基本就是做梦。这也是为什么必须提前了解 Client Hints。2. Client Hints 的工作原理从“自报家门”到“按需回答”2.1 Client Hints 是什么低熵与高熵的分级Client Hints缩写 UA-CH也叫 User-Agent Client Hints是把“浏览器主动自报”改成“服务器按需询问”的一套机制。核心思路是浏览器掌握的信息分为两类对隐私影响较小的低熵信息和影响较大的高熵信息分别采取不同处理。低熵信息是浏览器默认每次请求都会带上的不需要服务器特别声明。包括请求头含义示例值Sec-CH-UA浏览器品牌和主版本Not_A Brand;v8, Chromium;v120, Google Chrome;v120Sec-CH-UA-Mobile是否移动设备?0或?1Sec-CH-UA-Platform操作系统平台Windows、Android等高熵信息则是默认不给的必须由服务器在响应头Accept-CH里“点名”请求浏览器才会在后续请求中发送。高熵字段包括请求头含义示例值Sec-CH-UA-ArchCPU 架构x86、armSec-CH-UA-Bitness系统位宽64Sec-CH-UA-Full-Version-List完整版本列表Chromium;v120.0.6099.199等Sec-CH-UA-Model设备型号Pixel 8Sec-CH-UA-Platform-Version操作系统版本15.0.0Sec-CH-UA-WoW64是否 32 位进程在 64 位系统上?0你可能会问为什么不直接用旧的 UA偏要发明这么一整套新东西因为 UA 是“一刀切”地全量暴露而 CH 是“按需授予”。手机型号这类敏感信息只有在服务器明确说“我需要”的时候才给这比每一次请求都无差别丢出去要合理得多。从信息安全的角度看缩小隐式暴露面永远是更优解。2.2 HTTP 头协商流程三次对话背后的设计意图CH 的协商不是一次请求就完成的它在服务端和浏览器之间建立了一种“可持久化”的约定。简化流程如下浏览器发起首次请求默认带上低熵 CH 请求头。服务器在处理响应时通过Accept-CH响应头声明“我还需要这些高熵字段。”浏览器收到Accept-CH后把这份需求记录在域名维度后续所有该同站点的请求都会自动携带被点名的高熵字段。关键在于第 3 步是“记录之后后续生效”。所以首次请求的响应里服务端是无法拿到高熵数据的。如果你的首屏渲染逻辑强依赖设备型号就会遇到“第一次判断不出来”的空窗期。针对这个问题规范又给了Critical-CH。它在Accept-CH的基础上额外告诉浏览器“这些字段不仅需要而且必须在当前页面真正生效否则页面逻辑会出错。”浏览器如果发现本次请求里没有满足这些字段会主动重新发起一次请求把高熵字段补齐。代价是多一次 HTTP 往返所以只在真正必要的时候用不要所有接口都无脑加。这里涉及一个常见的取舍如果你只是做埋点统计、非关键的视觉效果调整没必要追求首屏就拿到高熵字段完全可以等第二次请求落到日志里再做归因。但如果你要在服务端决定“移动端该不该跳转下载页”那Sec-CH-UA-Mobile这种低熵信息其实已经够了也不需要Critical-CH。2.3 前端 APInavigator.userAgentData 怎么用除了 HTTP 头浏览器还暴露给 JavaScript 一套等价 APInavigator.userAgentData。它取代了老的navigator.userAgent用法也很直白。低熵部分可以直接同步读取console.log(navigator.userAgentData.brands); // [{brand: Not_A Brand, version: 8}, {brand: Chromium, version: 120}, {brand: Google Chrome, version: 120}] console.log(navigator.userAgentData.mobile); // true 或 false高熵部分则是异步的 Promise因为浏览器可能需要在后台完成权限判定const data await navigator.userAgentData.getHighEntropyValues([ architecture, platformVersion, model, fullVersionList, ]); console.log(data.architecture); // x86 console.log(data.platformVersion); // 15.0.0 console.log(data.model); // Pixel 8 console.log(data.fullVersionList); // [{brand: Chromium, version: 120.0.6099.199}, ...]注意getHighEntropyValues的入参数组是你请求的字段名白名单不是所有高熵字段都必须一次拿全按需索取就好。我实际开发中习惯写一个轻量封装把它包成 Promise并处理不支持时的降级async function getClientInfo() { if (!navigator.userAgentData) { return null; } try { return await navigator.userAgentData.getHighEntropyValues([ architecture, bitness, model, platformVersion, fullVersionList, ]); } catch (e) { return null; } }3. 从理论到落地服务端和前端完整接入指南3.1 服务端配置nginx 与 CDN 的关键点接入 CH 的第一件事是在服务端正确返回Accept-CH。以 nginx 为例server { listen 443 ssl; add_header Accept-CH Sec-CH-UA-Platform-Version, Sec-CH-UA-Model always; }always参数是为了确保在所有响应码里都带上这个头部包括 304 和错误页。很多新手在这里踩坑只写了add_header但没加always结果部分响应没有头浏览器记录不到约定后续请求就永远不会带高熵字段。如果你用了 CDN问题会更隐蔽。CDN 节点可能会把源站的Accept-CH响应头吞掉或者缓存了不带Accept-CH的响应。结果是源站已经配好了但浏览器始终收不到“请求高熵”的指令。这种情况需要去 CDN 控制台检查“源站响应头透传”和“自定义回源头”相关配置确保Accept-CH能从源站一路透传到浏览器。另外还要注意 HTTPS。CH 相关的头部和 API 都被定义为 secure context 才可用也就是说只有 HTTPS 页面以及 localhost才能正常协商。如果你拿 HTTP 环境测试大概率看到的是空数据。3.2 权限管理Permissions-Policy 如何控制高熵能力权限策略也是接入 CH 时必须想清楚的环节。Permissions-Policy响应头可以控制当前根页面以及所有嵌套 iframe 是否有权读取特定的高熵字段。默认情况下如果没有设置任何策略第三方 iframe 内调用navigator.userAgentData.getHighEntropyValues()是可能成功的。但如果你想收紧可以这样add_header Permissions-Policy ch-ua-arch(), ch-ua-model(), ch-ua-platform-version(self);含义解释ch-ua-arch()表示禁止任何上下文读取 CPU 架构。ch-ua-model()表示禁止读取设备型号。ch-ua-platform-version(self)表示只允许当前域名自己读取系统版本iframe 一律不给。这里要特别提醒如果你在响应里声明了Accept-CH: Sec-CH-UA-Model但又用Permissions-Policy把ch-ua-model禁了浏览器会按更严格的限制执行。也就是说Accept-CH与Permissions-Policy必须保持方向一致否则就会出现“服务端请求了但客户端不发”的矛盾局面。我见过一个真实案例团队给所有广告位 iframe 统一设置了严格的 Permissions-Policy结果主站自己也拿不到高熵信息排查了大半天才发现是策略作用域覆盖到了根文档。这个坑提醒我设置权限策略时一定要先明确“哪些域名是可信来源”再写白名单。3.3 兼容性方案旧浏览器降级与指纹库兜底CH 不是所有浏览器都支持接入前先看支持矩阵浏览器低熵 CH高熵 CHAccept-CH / getHighEntropyValuesChrome 89支持支持Edge 90支持支持Opera / Android WebView支持支持Safari 16.4部分支持有限支持Firefox暂不支持暂不支持主要兼容策略是“双轨并行”优先读navigator.userAgentData和 CH 请求头拿不到时再回退到老 UA 解析。前端典型写法function getDeviceInfo() { if (navigator.userAgentData) { return { mobile: navigator.userAgentData.mobile, platform: navigator.userAgentData.platform || , isCHSupported: true, }; } // 降级用 ua-parser-js 解析 navigator.userAgent const parser new UAParser(); const result parser.getResult(); return { mobile: result.device.type mobile, platform: result.os.name || , isCHSupported: false, }; }后端接口层面我建议把 CH 请求头和 UA 同时记录到日志并保留一个配置开关。在迁移初期所有下游策略统一读“UA 优先CH 辅助”等 CH 覆盖率达到预期后再切换为“CH 优先UA 兜底”。对于识别精度要求极高的场景广告反作弊、风控光靠 CH 仍然不够因为它只是“浏览器说出来的真相”不是“设备本身的硬指纹”。这类系统通常会把 CH 作为输入之一配合 WebGL、Canvas、音视频能力等信息综合判断。注意做这类方案一定要谨慎评估合规性避免采集范围超出业务必要。4. 常见问题与排查技巧实录4.1 高熵信息总拿不到的排查顺序我排查“高熵 CH 一直为空”时基本按这个顺序先确认页面是 HTTPS。不是的话其他都不用看了。打开浏览器 DevTools 的 Network 面板看响应头里有没有Accept-CH。没有就回去查源站配置和 CDN 透传。看请求头里有没有Sec-CH-UA-Platform-Version之类的字段。没有就说明浏览器没记录到服务器需求重点查上一步。看响应头里的Permissions-Policy确认字段没有被禁掉。检查是否处于隐私/无痕模式。无痕模式下部分高熵字段会被模糊化。确认代码里请求的字段名拼写是否正确。platformVersion和pltformVersion这类错误相当常见而且不会报错只会返回空。这里有一个我反复强调的细节Accept-CH是“作用在域名级”的协商结果。如果用户先访问了不带Accept-CH的域名变体比如www.example.com再跳到裸域名example.com这两个域名之间的 CH 约定是相互独立的。切换域名后浏览器又要重新发起一次协商。4.2 缓存爆炸Vary 与 CDN 带来的连锁问题CH 引入后最容易被忽略的坑在缓存层。同一个 URL因为访问者设备不同服务端可能返回不同的内容和响应头如果 CDN 或浏览器缓存把“为 Windows 生成的页面”复用给了 Android 用户就会出现设备类型错乱。解决方式就是在响应里设置Vary告诉缓存系统“这个响应会根据哪些请求头变化”。典型配置add_header Vary Sec-CH-UA, Sec-CH-UA-Mobile, Sec-CH-UA-Platform;如果服务端逻辑还依赖高熵字段比如根据设备型号返回不同的配置那Vary里也要加上对应的字段add_header Vary Sec-CH-UA, Sec-CH-UA-Mobile, Sec-CH-UA-Platform, Sec-CH-UA-Model;注意Vary字段越多缓存命中率越低。所以做缓存设计时要克制能用低熵字段解决的问题就不要把高熵字段加进Vary。我见过有团队把Sec-CH-UA-Full-Version-List也塞进Vary结果同一个页面在浏览器每次升级后都重新回源缓存命中率掉得很难看。另外很多 CDN 对Vary的处理是“只识别特定字段”尤其对自定义的Sec-前缀头部不一定支持。如果 CDN 不认它会默认不做缓存变体区分直接就缓存污染了。这种情况需要去 CDN 控制台找“基于请求头缓存”或“Vary 配置”的功能手动指定按 CH 字段区分。4.3 隐私模式、内部页面和移动浏览器的特殊行为无痕模式是 CH 落地时常见的“意外”。Chrome 在无痕模式下会把高熵字段的返回值“降级”一些字段直接变成空字符串另一些被替换成通用值。这样做的目的是防止通过 CH 拼接出用户身份。所以你如果在无痕模式调试时发现model是空的不是代码 bug而是隐私保护机制在起作用。移动端还有一个细节安卓上的 WebView 和 Chrome App 的 CH 行为基本一致但 iOS 上第三方的 WKWebView 受到更多限制。Safari 本身对高熵 CH 的支持就有限而 WKWebView 内是否透传这些字段取决于 App 是否配置了对应的userAgent策略。简而言之移动端和 WebView 环境必须单独测试不能只看桌面 Chrome 的结果。还有一个鲜为人知的限制浏览器内部页面比如chrome://、edge://不参与 CH 协商在这些页面里调用navigator.userAgentData会得到非常保守的值。这解释了为什么有些自动化测试脚本在内部页面跑会拿不到数据误以为是环境问题。5. 对项目实践的一些个人总结5.1 从 UA 到 CH设备识别方案该怎么迁移完整迁移 CH 不建议一刀切。我的经验是分成三步走第一步建立双轨日志。把每个请求的User-Agent、低熵 CH 和高熵 CH 都记录下来连续积累一到两周。这一步的目的是验证 CH 在你真实用户群里的覆盖率和字段完整性顺便发现有问题的浏览器组合。第二步梳理下游消费场景。把你现在所有依赖 UA 的逻辑列一遍移动端跳转体验分层广告归因反爬策略给每类逻辑标注“需要什么信息”再映射到对应的 CH 字段。这个映射表是迁移的核心资产业务需求UA 时代的做法CH 时代的替代方案判断是否手机匹配Mobile/AndroidSec-CH-UA-Mobile: ?1判断操作系统匹配Windows NT/iPhone OSSec-CH-UA-Platform判断浏览器版本匹配Chrome/120Sec-CH-UA或Sec-CH-UA-Full-Version-List获取设备型号匹配iPhone14,2等Sec-CH-UA-Model获取系统版本匹配Windows NT 10.0Sec-CH-UA-Platform-Version第三步灰度切换。先把低风险场景比如前端展示判断切到 CH观察一段时间的错误率再切服务端逻辑。我在项目中先切的是“移动端投放页跳转”因为Sec-CH-UA-Mobile是最稳定、最不容易出错的信号最后切的才是依赖具体系统版本的精细判断。5.2 几个我现在依然坚持的实践习惯踩过几次坑之后我在所有项目里都固化了几个习惯分享给你。绝不在业务代码里直接拼 UA 子串判断。无论你用的是 UA 还是 CH都抽出一层独立的解析模块统一返回结构化的{ browser, os, device, isMobile }业务层只能消费这个结构不接触原始字段。这样后续从 UA 切到 CH只需要改这一层。缓存配置里“默认加 Vary”。我现在的规则是只要页面或接口的返回内容与设备相关就一定在响应头里保留Vary并在上线前用 curl 加不同的 CH 请求头各跑一遍确认返回的缓存键不同。高熵请求必须带超时和降级。前端getHighEntropyValues虽然是个 Promise但在某些弱网或 WebView 环境下可能长时间 pending。我会用Promise.race给它套一层 2 秒超时失败就返回低熵信息绝不阻塞页面渲染。根据我个人经验CH 最大的价值不是你立刻就能拿到多精确的设备数据而是它第一次把“浏览器的自我陈述”变成了“可审计、可控制、可折叠的按需应答”。比起继续在一个充满谎言和历史包袱的 UA 字符串里翻找答案我更愿意把精力和规则库都迁到 Client Hints 这个新机制上。以后遇到设备识别需求我也建议你先想清楚“我要的到底是什么粒度”再决定用低熵还是高熵而不是一股脑把浏览器能说的全问一遍。