客户端检测方法论:分层抽象与责任分离设计
1. 项目概述:为什么客户端检测这件事,值得我们花时间“雕琢”代码?
“从丑陋到优雅,让代码越变越美”——这个标题乍看像在聊设计美学,但放在客户端检测这个具体场景里,它其实直指一个被长期忽视的工程现实:绝大多数人写的客户端检测逻辑,不是功能不对,而是“难看”得让人头皮发麻。这里的“丑陋”,不是指缩进不规范或变量名不够语义化,而是指结构混乱、边界模糊、责任错位、扩展乏力、测试困难,甚至在上线后连自己都不敢轻易动它。我做过不下二十个中大型前端项目,几乎每个都经历过这样的阶段:初期用navigator.userAgent.indexOf('Chrome') > -1快速搞定;中期加了 iOS 版本判断,开始套 if-else 嵌套三层;后期要兼容鸿蒙、折叠屏、车机系统,代码里塞满了&& !/HarmonyOS/.test(ua) && /Mobile/.test(ua)这类“祖传条件链”,改一行,测三天,上线前全员祈祷。
核心关键词“客户端检测方法”背后,藏着三个真实痛点:准确性、可维护性、前瞻性。准确性是底线——不能把 iPad 当成 iPhone,也不能把 Chrome on Android 误判为 Safari;可维护性是生命线——新设备一出,你得花 20 分钟定位哪段正则该改,而不是花 2 小时翻文档、查 UA 字符串、再手动验证;前瞻性是护城河——当 WebKit 内核的国产浏览器开始占据 15% 市场份额,你的检测模块是否能不改主干逻辑就接入?这篇文章不讲“如何用正则匹配 UA”,那只是工具;我要带你拆解的是一套可落地、可演进、可测试的客户端检测方法论,它适用于所有需要做设备/浏览器/内核/OS 识别的场景,比如:H5 活动页适配不同安卓厂商的 WebView 行为、管理后台根据终端类型加载不同交互组件、小程序转 H5 时的兜底降级策略。无论你是刚写完第一个if (isIOS)的新人,还是正在重构遗留系统的资深工程师,这套思路都能让你少踩至少三类典型坑:一是“UA 字符串幻觉”(以为 UA 是铁板一块,实则千变万化),二是“检测即业务”(把判断逻辑和业务逻辑搅在一起,改个判断就得动整个支付流程),三是“静态硬编码”(版本号写死在 if 里,等 iOS 18 发布那天,你的兼容列表还在 16.4)。
我试过七种主流方案:纯 UA 正则、第三方库(UAParser、bowser)、服务端 UA 解析 + 客户端缓存、基于特性检测的渐进式方案、Web API 组合(navigator.platform+navigator.vendor+matchMedia)、自研轻量解析器、以及混合式分层架构。最终沉淀下来的不是某一行代码,而是一套分层抽象 + 责任分离 + 渐进增强的设计骨架。它不追求“一次写完永不动”,而是让每次新增一个设备型号、升级一个浏览器版本、支持一个新特性,都变成一次可预测、可验证、可回滚的微小变更。下面,我们就从这个骨架的底层逻辑开始,一层层剥开“优雅”的真实构成。
2. 核心设计思路:为什么放弃“一把梭哈”,选择分层抽象与责任分离?
2.1 传统方案的三大结构性缺陷
几乎所有初学者写的客户端检测,都默认采用“单点决策”模式:一个函数,输入 UA 字符串,输出一个{ os: 'iOS', browser: 'Safari', version: '17.4' }对象。这看似简洁,实则埋下三颗定时炸弹:
第一颗是数据污染。UA 字符串本身就是一个“不可信信源”。它可被用户修改(开发者工具覆盖)、可被中间代理篡改(某些企业网络)、可被浏览器主动弱化(Chrome 110+ 默认裁剪 UA 中的 Chrome 版本号,只保留Chrome/110.0.0.0)。更麻烦的是,同一台设备上,不同场景 UA 差异巨大:微信内置浏览器的 UA 里会塞入MicroMessenger,但它的内核可能是 WKWebView 或 X5;QQ 浏览器在安卓上用 Blink,在 iOS 上却用 WKWebView;而国内某超级 App 的 WebView,UA 里甚至不带Mobile关键字。如果你的判断逻辑全押在 UA 上,等于把业务稳定性的命门交给了一个随时可能撒谎的“证人”。
第二颗是逻辑耦合。检测结果往往直接驱动业务分支:“如果是 iOS 且版本 < 15,则隐藏某个动画”、“如果是华为手机,则走特殊上报通道”。一旦检测逻辑和业务逻辑写在同一层,修改检测规则(比如新增对鸿蒙 NEXT 的识别)就必须同步修改所有业务调用点。我见过一个电商项目,因为要支持折叠屏展开状态检测,不得不在 17 个文件里搜索isFoldable,逐个确认是否影响下单按钮样式——这不是开发,这是考古。
第三颗是演进僵化。当团队决定引入“基于特性检测”作为 UA 的补充时(比如用CSS.supports('display: grid')判断 CSS Grid 支持度),你会发现旧架构根本无法平滑接入。因为原有模块只认“字符串解析结果”,不接受“运行时能力返回值”。强行塞进去,要么重写整个检测引擎,要么搞出两套并行的判断体系,最终代码里出现if (detectByUA().os === 'Android' || detectByFeature().hasGrid)这种“双轨制”怪胎。
提示:不要试图用更复杂的正则去修复 UA 的不可靠性。正则越复杂,越容易漏掉边缘 case,也越难维护。真正的解法是承认 UA 的局限性,并为它找到合适的“搭档”。
2.2 分层抽象:把检测拆成“谁在说”“说了什么”“信多少”三层
我们重构的核心,是把“客户端检测”这个笼统概念,拆解为三个职责清晰、可独立演进的层级:
采集层(Who Said It):只负责“获取原始信号”,不作任何判断。它聚合所有可用信源:
navigator.userAgent、navigator.platform、navigator.vendor、window.screen.width/height、matchMedia('(hover: hover)')、CSS.supports()、甚至window.chrome全局对象是否存在。这一层的目标是“尽可能多拿”,哪怕拿到的是矛盾信息(比如 UA 说 iOS,但navigator.platform返回Win32——这在某些模拟器里真实存在)。它输出一个原始信号包(RawSignal),形如:{ "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1", "platform": "iPhone", "vendor": "Apple Computer, Inc.", "screen": { "width": 390, "height": 844 }, "features": { "cssGrid": true, "webp": true, "touch": true } }解析层(What Was Said):接收 RawSignal,进行结构化解析。它不关心信号真假,只做“翻译”:把 UA 字符串拆解为
osName、osVersion、browserName、browserVersion、engineName、engineVersion等标准字段;把screen.width和screen.height结合matchMedia推断设备类型(手机/平板/桌面);把features映射为能力标签(hasCssGrid、supportsWebP)。关键在于,这一层输出的是标准化中间表示(IR),而非最终业务结论。例如,它可能输出:{ "os": { "name": "iOS", "version": "17.4", "family": "Unix" }, "browser": { "name": "Safari", "version": "17.4", "engine": "WebKit" }, "device": { "type": "mobile", "model": "iPhone 14 Pro" }, "capabilities": ["cssGrid", "webp", "touch"] }决策层(How Much to Trust):这才是真正面向业务的接口。它接收 IR,结合预设的置信度策略(Confidence Policy),输出带权重的判断结果。例如:
isIOS():当os.name === 'iOS'且os.version可解析时,置信度 0.95;若仅凭navigator.platform === 'iPhone'推断,则置信度 0.7。isWebView():当ua包含MicroMessenger或MQQBrowser且browser.engine !== 'WebKit'时,置信度 0.9;若仅凭!window.chrome && !window.opr推断,则置信度 0.6。isFoldable():当screen.width < 400 && screen.height > 800 && matchMedia('(min-width: 800px)').matches时,置信度 0.85。
这种分层不是为了炫技,而是为了解耦。采集层可以随时增加新信源(比如未来接入navigator.userAgentDataAPI),只要输出格式不变,解析层和决策层完全不受影响;解析层可以升级 UA 解析算法(比如从正则改为语法树解析),只要 IR 字段定义一致,业务代码零修改;决策层可以动态调整置信度阈值(比如灰度发布时,把isFoldable()的触发阈值从 0.85 降到 0.7),无需动到底层。
2.3 责任分离:检测逻辑与业务逻辑的物理隔离
分层之后,必须强制“划清界限”。我们约定:所有业务代码,只能调用决策层提供的、带明确语义的布尔函数(如isIOS(),isWebView(),hasTouch()),绝不能直接访问解析层的 IR 对象,更不能碰采集层的原始信号。
这带来两个硬性约束:
决策层函数必须无副作用、纯函数化。
isIOS()只能读取 IR,不能发起网络请求、不能修改全局状态、不能依赖时间戳。这样它才能被 Jest 单元测试轻松覆盖,也能在服务端渲染(SSR)环境中安全执行(因为 SSR 没有navigator对象,但我们可以通过预注入 IR 来模拟)。业务代码必须封装检测判断。禁止出现
if (detect.isIOS && detect.osVersion < '15.0')这样的裸判断。正确姿势是:// ✅ 好:业务逻辑清晰,检测细节被封装 if (client.isIOSBelow15()) { disableSmoothScroll(); } // ❌ 坏:业务逻辑和检测细节混杂,无法复用,难以测试 const detect = client.parse(); if (detect.os.name === 'iOS' && compareVersion(detect.os.version, '15.0') < 0) { disableSmoothScroll(); }isIOSBelow15()这个函数,就是决策层对外暴露的“业务语义接口”。它的内部实现可以是:function isIOSBelow15(): boolean { const ir = this.parse(); // 获取解析层 IR if (!ir.os || ir.os.name !== 'iOS') return false; // 使用语义化版本比较,而非字符串比对 return semver.lt(ir.os.version, '15.0'); }这样,当未来需要支持 iOS 18 时,你只需修改
isIOSBelow15()的内部逻辑(比如改成< '18.0'),所有调用点自动生效,且测试用例只需验证这个函数的行为,无需关心底层 UA 如何解析。
3. 核心细节解析:采集、解析、决策三层的实操要点与避坑指南
3.1 采集层:如何安全、全面、兼容地获取原始信号?
采集层的目标是“宁可多拿,不可漏拿”,但必须规避常见陷阱。以下是我在多个项目中验证过的实操要点:
UA 字符串:永远做防御性读取navigator.userAgent在部分环境(如某些 WebView、禁用 JS 的浏览器)可能为undefined或空字符串。错误写法:
// ❌ 危险:未检查 UA 是否存在,直接调用 indexOf if (navigator.userAgent.indexOf('iPhone') > -1) { ... }正确写法是封装一个安全读取器:
function getUA(): string { // 优先尝试现代 API(Chrome 101+ 支持) if ('userAgentData' in navigator && navigator.userAgentData) { try { // 注意:userAgentData.getHighEntropyValues() 是异步且需权限的,此处仅作示意 return navigator.userAgentData.uaList?.[0]?.ua || ''; } catch (e) { // 权限不足或异常,降级 } } // 降级到传统 UA return navigator.userAgent || ''; }更重要的是,永远不要假设 UA 是“干净”的。实际采集到的 UA 可能包含\n、\t、乱码字符,甚至恶意注入的脚本片段(虽然概率低,但安全起见)。因此,解析前必须清洗:
const rawUA = getUA().trim().replace(/[\r\n\t]/g, ' '); // 避免正则被换行符破坏,也防止后续处理出错Platform 与 Vendor:它们比 UA 更可靠,但有历史包袱navigator.platform在桌面端通常返回Win32、MacIntel、Linux x86_64,在移动端返回iPhone、iPad、Android。它的优势是不可伪造(浏览器厂商不会允许 JS 修改它),劣势是粒度粗(Android不区分手机/平板,iPhone不区分型号)。navigator.vendor则更有趣:Safari 返回Apple Computer, Inc.,Chrome 返回Google Inc.,Firefox 返回Mozilla Foundation。但它在部分国产浏览器中可能为空或返回null。
实操中,我们发现platform是判断设备大类(iOS/Android/Desktop)的黄金信源。例如:
platform === 'iPhone' || platform === 'iPad'→ 几乎 100% 是 iOS 设备(极少数模拟器除外,但业务场景可忽略)platform.includes('Win') || platform.includes('Mac') || platform.includes('Linux')→ 桌面端platform === 'Android'→ 安卓设备(注意:部分安卓平板会返回Linux armv8l,需结合其他信号)
注意:
platform在 iOS 13+ 的 Safari 中曾短暂返回MacIntel(因桌面版 Safari 的 UA 伪装),但苹果很快修复。当前(iOS 17)已回归iPhone/iPad。不过,为防万一,我们的采集层会同时记录platform和ua,供解析层交叉验证。
特性检测:不是“替代”,而是“补全”
特性检测(Feature Detection)是 UA 检测的天然互补。UA 告诉你“它声称是什么”,特性检测告诉你“它实际能做什么”。但新手常犯两个错误:一是过度依赖单一特性(如只用window.Promise判断 ES6 支持,却忽略Promise.allSettled的兼容性差异),二是忽略特性检测的性能成本(如频繁调用CSS.supports())。
我们的采集层对特性检测做了三点优化:
- 批量缓存:将常用特性检测封装为一个惰性求值对象,在首次调用时统一执行,后续直接返回缓存值。
const features = { get cssGrid() { if (this._cssGrid === undefined) { this._cssGrid = CSS.supports('display', 'grid'); } return this._cssGrid; }, _cssGrid: undefined as boolean | undefined, // 其他特性... }; - 分层采样:对高成本特性(如
matchMedia查询),只在必要时采集。例如,isFoldable()决策函数触发时,才去查询matchMedia('(min-width: 800px)'),而非在采集层就全部执行。 - 语义化包装:不直接暴露原生 API,而是提供业务友好接口。例如,
hasTouch()不是简单返回'ontouchstart' in window,而是综合('ontouchstart' in window || navigator.maxTouchPoints > 0),并排除某些误报场景(如 Windows 触控笔记本在桌面模式下maxTouchPoints > 0但实际无触控)。
3.2 解析层:从 UA 字符串到结构化 IR 的精准翻译
解析层是整个架构的“翻译官”,其质量直接决定上层决策的可靠性。我们摒弃了所有第三方 UA 解析库(UAParser.js 体积大、更新慢、对国产浏览器支持弱),选择自研轻量解析器,核心原则是:以白名单为主,黑名单为辅;以确定性规则优先,启发式推断兜底。
白名单驱动的 UA 解析引擎
我们维护一个 JSON 格式的 UA 白名单数据库(ua-patterns.json),按优先级排序。每条规则包含:
pattern: 正则表达式(使用i标志,不区分大小写)os: 操作系统信息(name, versionPattern, family)browser: 浏览器信息(name, versionPattern, engine)device: 设备信息(type, modelPattern)confidence: 该规则的初始置信度(0.7~0.95)
例如,针对 iOS Safari 的规则:
{ "pattern": "iPhone.*OS (\\d+)_(\\d+)", "os": { "name": "iOS", "versionPattern": "$1.$2", "family": "Unix" }, "browser": { "name": "Safari", "engine": "WebKit" }, "device": { "type": "mobile", "modelPattern": "iPhone $1,$2" }, "confidence": 0.95 }解析流程是:遍历白名单,对 UA 字符串执行pattern.exec(),第一个匹配成功的规则即为“主规则”,提取其versionPattern中的捕获组填充版本号。若无匹配,则启用“兜底规则”(如.*AppleWebKit.*Mobile.*→iOS,置信度 0.7)。
为什么不用 UAParser?
UAParser 的解析逻辑是“贪婪匹配”,它会尝试匹配所有可能的浏览器组合,导致在复杂 UA(如微信内置浏览器)中产生歧义。例如,一个微信 UA:Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.46(...)。UAParser 可能同时匹配到Safari和WeChat规则,最终返回哪个取决于内部优先级,而这个优先级并不透明。我们的白名单规则明确声明:MicroMessenger规则优先级高于Safari,因此微信 UA 必定被识别为WeChat浏览器,Safari仅作为内核信息保留。这种可控性,是业务稳定性的基石。
版本号解析:语义化比较是刚需
字符串比较17.4 < 17.10会返回true(因为'10' < '4'字典序),这是灾难性的。我们必须使用语义化版本比较(SemVer)。但并非所有版本号都符合 SemVer 规范(如Chrome/110.0.5481.177中的110.0.5481.177是四段式)。我们的解析层内置一个灵活的parseVersion(str)函数:
function parseVersion(versionStr: string): number[] { // 移除非数字非点字符,如 '17.4.1 (18D52)' → '17.4.1' const clean = versionStr.replace(/[^0-9.]/g, ''); // 拆分为数字数组,自动补零至 4 位(便于比较) const parts = clean.split('.').map(p => parseInt(p, 10)); while (parts.length < 4) parts.push(0); return parts.slice(0, 4); } // 比较函数:[17,4,0,0] vs [17,10,0,0] → -1(小于) function compareVersion(v1: string, v2: string): number { const a = parseVersion(v1); const b = parseVersion(v2); for (let i = 0; i < 4; i++) { if (a[i] < b[i]) return -1; if (a[i] > b[i]) return 1; } return 0; }这个函数确保compareVersion('17.4', '17.10')返回-1(正确),而非1(字符串比较错误结果)。
3.3 决策层:置信度策略与业务语义接口的设计艺术
决策层是用户(业务开发者)唯一接触的接口,它的设计直接决定了代码的“优雅度”。我们坚持三个设计信条:语义清晰、置信可调、组合自由。
语义清晰:函数名即契约
每一个导出的函数,其名称必须精确描述其业务意图,而非技术实现。例如:
isIOS():表示“当前环境是 iOS 系统”,不关心是 Safari 还是微信 WebView。isSafari():表示“当前浏览器是 Safari”,不关心它运行在 iOS 还是 macOS。isWebView():表示“当前运行在 WebView 容器中”,不关心是 X5 还是 WKWebView。isFoldable():表示“当前设备具备可折叠形态”,不关心是硬件传感器还是 CSS 媒体查询推断。
这种命名杜绝了歧义。当业务同学说“需要在折叠屏上隐藏导航栏”,你直接给他isFoldable(),他不需要理解 UA 解析原理,只需要知道“调用这个函数,返回 true 就隐藏”。
置信可调:让判断结果带上“可信度”标签
我们不返回简单的true/false,而是返回一个DecisionResult对象:
interface DecisionResult { value: boolean; // 最终布尔结果 confidence: number; // 置信度(0.0 ~ 1.0) source: string; // 决策依据(如 'ua-ios-pattern', 'platform-iPhone') reason?: string; // 简短说明(如 'Matched iOS UA pattern with high confidence') }业务代码可以按需使用:
// ✅ 默认行为:只关心布尔值 if (client.isFoldable().value) { ... } // ✅ 高级用法:根据置信度做灰度 const foldable = client.isFoldable(); if (foldable.value && foldable.confidence >= 0.85) { // 高置信度,启用完整折叠屏体验 enableFoldableUI(); } else if (foldable.value) { // 中置信度,启用简化版 enableBasicFoldableUI(); }置信度的计算不是拍脑袋。我们为每种信源设定基准值,并根据交叉验证动态调整:
UA pattern match:0.95(白名单精确匹配)platform === 'iPhone':0.9(平台不可伪造)navigator.vendor === 'Apple Computer, Inc.':0.85(厂商标识较可靠)matchMedia('(min-width: 800px)').matches:0.7(媒体查询易受缩放、横竖屏影响)
当多个信源指向同一结论时,置信度会叠加(非简单相加,而是按对数衰减公式计算),例如UA says iOS(0.95) +platform says iPhone(0.9) → 最终置信度 0.97。
组合自由:用逻辑运算符构建复杂业务条件
决策层提供and()、or()、not()等高阶函数,让业务方能自由组合原子判断:
// 业务需求:在 iOS 15+ 的 Safari 中启用新动画 const useNewAnimation = client.and( client.isIOS(), client.isSafari(), client.isIOSAtLeast('15.0') ); // 业务需求:在非微信、非 QQ 的 WebView 中禁用某功能 const disableInWebView = client.or( client.isWebView(), client.not(client.isWeChat()), client.not(client.isQQBrowser()) );这些组合函数内部会智能合并信源,避免重复解析。例如and(isIOS(), isSafari())会复用同一个 UA 解析结果,而不是分别解析两次。
4. 实操过程:从零搭建一个可运行的客户端检测模块
4.1 项目初始化与目录结构
我们以一个标准的 TypeScript 项目为例(兼容 Vue/React/Angular),创建一个独立的@myorg/client-detect包。目录结构如下:
src/ ├── index.ts # 入口,导出 ClientDetector 类 ├── collector/ # 采集层 │ ├── index.ts # 采集器主逻辑 │ └── features.ts # 特性检测封装 ├── parser/ # 解析层 │ ├── index.ts # 解析器主逻辑 │ └── patterns/ # UA 白名单规则 │ ├── ios.ts │ ├── android.ts │ └── wechat.ts ├── decision/ # 决策层 │ ├── index.ts # 决策函数集合 │ └── policies.ts # 置信度策略配置 └── types/ # 类型定义 └── index.ts这种结构确保各层物理隔离,便于团队分工(前端 A 负责 collector,B 负责 parser,C 负责 decision),也方便单元测试单独 mocking 某一层。
4.2 采集层实现:安全、健壮的原始信号获取
src/collector/index.ts的核心是Collector类:
export class Collector { private cache: RawSignal | null = null; // 主采集方法,返回缓存或重新采集 collect(): RawSignal { if (this.cache) return this.cache; const ua = this.getSafeUA(); const platform = this.getSafePlatform(); const vendor = this.getSafeVendor(); const screen = this.getScreenInfo(); const features = this.collectFeatures(); this.cache = { ua, platform, vendor, screen, features }; return this.cache; } private getSafeUA(): string { // 如前文所述的安全 UA 获取逻辑 let ua = ''; if (typeof navigator !== 'undefined') { ua = navigator.userAgent || ''; } return ua.trim().replace(/[\r\n\t]/g, ' '); } private getSafePlatform(): string { if (typeof navigator !== 'undefined' && navigator.platform) { return navigator.platform; } return ''; } private getSafeVendor(): string { if (typeof navigator !== 'undefined' && navigator.vendor) { return navigator.vendor; } return ''; } private getScreenInfo(): ScreenInfo { if (typeof window === 'undefined') { return { width: 0, height: 0 }; } return { width: window.screen.width, height: window.screen.height, }; } private collectFeatures(): Features { // 特性检测,使用前文所述的惰性缓存模式 return { cssGrid: CSS.supports('display', 'grid'), webp: this.hasWebPSupport(), touch: this.hasTouchSupport(), // ...其他特性 }; } // 其他辅助方法... }关键点在于collect()方法的缓存机制。它保证在整个页面生命周期内,原始信号只采集一次,避免重复读取navigator对象(某些 WebView 下多次读取可能返回不同值)。
4.3 解析层实现:白名单驱动的 UA 翻译器
src/parser/index.ts的核心是Parser类,它依赖patterns目录下的规则:
import { IOS_PATTERN } from './patterns/ios'; import { ANDROID_PATTERN } from './patterns/android'; // ...导入其他规则 export class Parser { private patterns: PatternRule[] = [ IOS_PATTERN, ANDROID_PATTERN, WECHAT_PATTERN, // ...按优先级排序 ]; parse(rawSignal: RawSignal): IntermediateRepresentation { const { ua, platform, vendor, screen, features } = rawSignal; let ir: Partial<IntermediateRepresentation> = {}; // 第一步:UA 解析(主路径) const uaMatch = this.matchUA(ua); if (uaMatch) { ir = { ...ir, ...uaMatch.ir }; ir.confidence = uaMatch.confidence; } // 第二步:平台/厂商交叉验证(提升置信度) if (platform && platform.toLowerCase().includes('iphone')) { ir.os = { ...ir.os, name: 'iOS', family: 'Unix' }; ir.device = { ...ir.device, type: 'mobile' }; ir.confidence = Math.max(ir.confidence || 0.5, 0.9); } // 第三步:特性补全(如根据 touch 特性推断 device.type) if (features.touch && screen.width < 500) { ir.device = { ...ir.device, type: 'mobile' }; } // 确保 IR 结构完整 return this.ensureIRStructure(ir); } private matchUA(ua: string): { ir: Partial<IntermediateRepresentation>, confidence: number } | null { for (const rule of this.patterns) { const match = rule.pattern.exec(ua); if (match) { const ir: Partial<IntermediateRepresentation> = {}; // 根据 rule.os, rule.browser 等填充 ir 字段 // 使用 rule.versionPattern 提取版本号 return { ir, confidence: rule.confidence }; } } return null; } private ensureIRStructure(ir: Partial<IntermediateRepresentation>): IntermediateRepresentation { // 填充默认值,确保所有字段存在 return { os: { name: 'Unknown', version: '0.0.0', family: 'Unknown' }, browser: { name: 'Unknown', version: '0.0.0', engine: 'Unknown' }, device: { type: 'unknown', model: 'Unknown' }, capabilities: [], ...ir, }; } }patterns/ios.ts示例:
export const IOS_PATTERN: PatternRule = { pattern: /iPhone.*OS (\d+)_(\d+)/i, os: { name: 'iOS', versionPattern: '$1.$2', family: 'Unix', }, browser: { name: 'Safari', engine: 'WebKit', }, device: { type: 'mobile', modelPattern: 'iPhone $1,$2', }, confidence: 0.95, };这个设计让新增一个设备(如鸿蒙)变得极其简单:只需在patterns/harmony.ts中添加一条新规则,然后把它插入patterns数组的合适位置(通常在 Android 之后,iOS 之前),无需修改任何解析逻辑。
4.4 决策层实现:语义化接口与置信度引擎
src/decision/index.ts导出ClientDetector类,它是业务方的唯一入口:
import { Collector } from '../collector'; import { Parser } from '../parser'; import { DecisionResult, ConfidencePolicy } from './policies'; export class ClientDetector { private collector: Collector; private parser: Parser; private policy: ConfidencePolicy; constructor(policy: ConfidencePolicy = defaultPolicy) { this.collector = new Collector(); this.parser = new Parser(); this.policy = policy; } // 原子决策函数 isIOS(): DecisionResult { const ir = this.getIR(); const isIOS = ir.os.name === 'iOS'; const confidence = this.policy.getConfidence('os', 'iOS', isIOS, ir); return { value: isIOS, confidence, source: 'os-name', reason: 'OS name matched iOS' }; } isSafari(): DecisionResult { const ir = this.getIR(); const isSafari = ir.browser.name === 'Safari'; const confidence = this.policy.getConfidence('browser', 'Safari', isSafari, ir); return { value: isSafari, confidence, source: 'browser-name', reason: 'Browser name matched Safari' }; } isFoldable(): DecisionResult { const ir = this.getIR(); // 综合 UA、platform、screen、media query 多个信号 const isFoldable = this.isFoldableByScreen(ir) || this.isFoldableByUA(ir); const confidence = this.policy.getConfidence('foldable', 'true', isFoldable, ir); return { value: isFoldable, confidence, source: 'foldable-combined', reason: 'Combined signals indicate foldable' }; } // 组合函数 and(...decisions: (() => DecisionResult)[]): DecisionResult { const results = decisions.map(fn => fn()); const value = results.every(r => r.value); const confidence = this.policy.combineConfidence(results, 'and'); return { value, confidence, source: 'combined-and', reason: `And of ${results.length} decisions` }; } // 工具方法 private getIR(): IntermediateRepresentation { const raw = this.collector.collect(); return this.parser.parse(raw); } private isFoldableByScreen(ir: IntermediateRepresentation): boolean { // 实现基于屏幕尺寸和媒体查询的折叠屏判断 if (typeof window === 'undefined') return false; const