12k
All articles

现代 iOS 特性检测(不再痛苦)

实用的 iOS 功能检测:使用 CSS.supports、行为测试,以及仅在 iOS 26 和旧款 iPad 上少量 UA 判断。

OpenReplay Team
OpenReplay Team
现代 iOS 特性检测(不再痛苦)

默认使用特性检测:用 'IntersectionObserver' in windowCSS.supports('property', 'value') 直接测试你真正需要的能力,而将 user-agent 检测保留给少数几个确实无法通过特性检测解决的 iOS 场景。

任何在代码库里维护过 iOS 版本号表的人都熟悉这个套路:Apple 发布一次更新,表就过期了,然后线上出问题——而且往往在有人提工单之前就已经坏了。能力检测(capability check)则完全跳过了这个循环。

特性检测能解决绝大多数 Safari 和 iOS 的痛点:你不再维护脆弱的版本表,而是直接询问浏览器它能做什么。真正棘手的是残留部分:一小撮 iOS 边缘情况(报告为桌面版 macOS 的老款 iPad、iOS 26 的 user-agent 冻结),这些场景不存在对应的能力检测,你只能小心地做 sniffing。本文会给出针对这两类情况的现代实战手册。

核心要点

  • 使用 'x' in windowCSS.supports() 和可选链直接检测能力。MDN 称这是”一种可靠得多的策略”,优于解析 user-agent 字符串。
  • 声明存在不等于真正可用:Safari 可能对某个它实际并未应用的特性把 @supports 报告为 true,因此对于已知的”说谎者”,应在屏幕外渲染元素并用 getBoundingClientRect() 实测。
  • 由于 iOS 上的每个浏览器都运行在 WebKit 之上,且 Safari 版本与操作系统主版本号同步,因此一个 isMobileWebKit() 检测加上一个 CSS.supports() 门控就能推断出 iOS 版本,完全无需解析 UA。
  • 在 iOS 26+ 上,Safari 会把 UA 中的操作系统 token 冻结为一个 26 之前的值,而这个值本身也在漂移(18_618_6_218_7)。绝不要硬编码它;改为解析 Version/ token。
  • 该冻结是 Safari 自身的行为,Apple 已在 iOS 和 iPadOS 26 的文档中说明;iOS 上的 Chrome 和 Firefox 仍会报告真实的 iOS 版本。

特性检测是默认选择

检测能力,而不是浏览器。特性检测会在 Apple 发布更新时自动适配,不需要维护版本表,并且在各引擎上表现一致——这正是 Apple 和 MDN 推荐它优先于 user-agent sniffing 的原因。你有三种工具可用。

对于 JavaScript API,探测全局对象或使用可选链:

if ("IntersectionObserver" in window) {
  // wire up lazy-loading
}
navigator.share?.({ title: "Modern feature detection" });

对于 CSS,在 JS 中使用 CSS.supports(),或在样式表中使用 @supports at-rule:

@supports (text-wrap-style: stable) {
  h1 { text-wrap-style: balance; }
}

注意,navigator.userAgentData 在这里并不是一个可行的退路:它仅限 Chromium,且被标记为实验性,Safari 和 Firefox 都未实现。当你的目标是 iOS Safari 时,它绝不能替代特性检测。

当特性检测说谎时:声明存在不等于真正可用

特性检测有两种值得点名的失效模式,因为它们的修复方式不同。第一种是反模式:检测一个不相关的特性 B 来推断特性 A。正如 A Beautiful Site 所记录的,一旦浏览器先发布了其中一个特性,你的检测就会悄无声息地失效。不要把检测耦合到代理指标上。

第二种更为隐蔽:声明存在不等于真正可用。Safari 可能对某个它实际并未应用的取值把 @supports 报告为 trueEvil Martians 在 CSS safe 对齐关键字上就发现了这个问题。对于这些已知的”说谎者”,不要相信支持标志。应在屏幕外渲染元素,并用 getBoundingClientRect() 测量真实结果:

const supportsSafeAlign = () => {
  const box = document.createElement("div");
  const child = document.createElement("span");
  child.textContent = "measure me";
  Object.assign(box.style, {
    display: "flex",
    justifyContent: "safe center",
    width: "5%",
    position: "absolute",
    top: "-9999px",
    left: "-9999px",
  });
  box.appendChild(child);
  document.body.appendChild(box);
  const applied = child.getBoundingClientRect().left >= box.getBoundingClientRect().left;
  document.body.removeChild(box);
  return applied;
};

行为测试无需依赖任何版本声明即可得出正确结论:它观察的是实际渲染出来的结果。这正是 session replay 擅长暴露的一类 bug:某个受门控的特性在你的测试环境中通过了支持检查,却在真实用户的 iOS 构建版本上悄然行为异常。

用特性检测推断 iOS 版本

由于 iOS 上的每个浏览器都运行在 WebKit 之上,且 Safari 版本与操作系统主版本号绑定,因此一个 isMobileWebKit() 检测,加上针对某个已知 Safari 版本引入的属性所做的 CSS.supports() 门控,就能让你在完全不解析 user-agent 字符串的情况下推断出 iOS 版本。移动端 WebKit 的启发式判断依赖于 WebKit 暴露的一个手势事件:

const isMobileWebKit = () => "ongesturechange" in window;

至于版本门控,可以在 Apple 的 Safari 发布说明或 MDN 的兼容性数据中查找某个属性首次获得支持的版本,然后据此测试。text-wrap-style 这个 longhand 属性在 Safari 17.5 落地,该版本同时提供了 balancestableauto 三个取值,因此其中任意一个取值都能干净利落地门控 iOS 17.5+:

const isAtLeastIOS175 = () =>
  window.CSS?.supports("text-wrap-style", "stable") ?? false;

上线前请自行验证「属性 ↔ 发布版本」的映射关系。发布说明偶尔会遗漏变更,而”已支持”标志也可能说谎(见上一节)。把 isMobileWebKit() 当作一个强启发式判断,而非规范层面的保证:Apple 在 iOS 17.4+ 上允许欧盟地区使用替代浏览器引擎,所以”iOS 即 WebKit”是绝大多数情况下成立,而非绝对成立。

什么时候 user-agent sniffing 是限定范围内的最后手段

只有在两个条件同时成立时才动用 user-agent:该能力确实没有对应的特性检测方式,并且猜错的代价不会超过一点外观上的瑕疵。有两个 iOS 场景达到了这个门槛。

老款 iPad。 特性检测无法区分 iPad 和 Mac,因为自 iPadOS 13 起,iPad 的默认 user-agent 与 Mac 发送的字符串完全相同。改为组合多个信号:一个读起来像桌面 macOS Safari 的 user-agent,配合移动端 WebKit 检测为真,以及非零的触摸点数量,就意味着这是一台披着 Mac 外衣的 iPad。触摸点检测很关键:Mac 上 navigator.maxTouchPoints 报告为 0,因此即便桌面 Safari 也存在手势事件信号,它也能阻止真正的 Mac 被误判。

const looksLikeMacSafari = /Macintosh/.test(navigator.userAgent);
const isIPad = () =>
  looksLikeMacSafari && isMobileWebKit() && navigator.maxTouchPoints > 0;

iOS 26 的 UA 冻结。 在 iOS 26 和 iPadOS 26 上,Safari 不再在 user-agent 中放入实际运行的操作系统版本,而是把该 token 钉在了一个更早的版本上。这个被钉住的值本身也在各个小版本间发生了漂移(发布时是 18_6,随后 Safari 26.1 中变为 18_6_2,再到 iOS 26.2 起为 18_7),这正是你绝不能硬编码它的原因。改为解析 Version/ token,它仍然反映真实的 Safari(因而也是 iOS)主版本号:

const iosMajor = () => {
  const m = navigator.userAgent.match(/Version\/(\d+)/);
  return m ? Number(m[1]) : null; // 26 on iOS 26.x Safari
};

有两点需要进一步明确。第一,该冻结仅限 Safari:来自 AppleInsiderLapcat Software 的独立服务器日志分析都证实,iOS 上的 Chrome 和 Firefox 仍在报告真实的操作系统版本,所以”无法检测 iOS 26”是一个 Safari 特有的问题,而非整个 iOS 平台的问题。第二,如果你不想自己维护解析逻辑,ua-parser-js 的最新 npm 发布版本为 2.0.10,因此请锁定 2.0.10+。该包也有有据可查的供应链安全事件历史,安装前请先核实。

实战手册

完整流程很短,并且按顺序执行:

  1. 优先特性检测。 'x' in windowCSS.supports()@supports、可选链。这覆盖了绝大多数门控决策。
  2. 对”说谎者”做行为测试。 当支持标志不可信时,在屏幕外渲染并用 getBoundingClientRect() 实测。
  3. 不用 UA 推断 iOS 版本。 isMobileWebKit() 加上一个针对发布说明已确认属性的 CSS.supports() 门控。
  4. 只对无法检测的边缘情况做 sniffing。 老款 iPad 的组合判断,以及针对 iOS 26 冻结的 Version/ token。仅在 sniffing 失败也不会损失任何功能的地方使用它们。
  5. 在真机和模拟器上测试。 发布说明和支持标志都存在盲区,只有真实硬件才能给出定论。

特性检测之所以是默认选择,是因为它能扛住你没有预料到的更新;user-agent sniffing 则是针对平台确实让人无法检测的那两三个 iOS 场景所做的限定范围例外。按这个顺序串联你的检测,锁定 Version/ token 解析而不是任何被冻结的操作系统字符串,并在上线前在一台真实的老款 iPad 上确认结果。

常见问题

尽管存在 user-agent 冻结,还能在 JavaScript 中检测 iOS 26 吗?

可以。从 iOS 26 起,Safari 会把 user-agent 中的操作系统 token 冻结为一个 26 之前的值,而这个值在各小版本间发生了漂移(18_6,随后是 18_6_2,再到 iOS 26.2 起的 18_7),因此硬编码必然失败。应改为解析 Version/ token,它仍然报告真实的 Safari 主版本号,也就等同于 iOS 主版本号。在 iOS 26.x 的 Safari 上,匹配 Version/(\\d+) 会返回 26。

iOS 26 的 user-agent 冻结会影响 iOS 上的 Chrome 和 Firefox 吗?

不会。Apple 的文档将被冻结的操作系统 token 说明为 Safari 在 iOS 和 iPadOS 26 上的自身行为。AppleInsider 和 Lapcat Software 的独立服务器日志分析都证实,即便所有 iOS 浏览器都运行在 WebKit 之上,iOS 上的 Chrome 和 Firefox 仍在其 user-agent 字符串中报告真实的操作系统版本。所以「无法检测 iOS 26」是一个 Safari 特有的问题,而非整个 iOS 平台的问题。

如何在浏览器中区分 iPad 和 Mac?

需要组合两个信号,因为仅靠特性检测无法区分二者:自 iPadOS 13 起,iPad 的默认 user-agent 与 Mac 发送的字符串完全相同。如果 user-agent 看起来像桌面 macOS Safari,但移动端 WebKit 检测表明你处于触摸设备上,那就是一台伪装成 Mac 的 iPad。由于手势事件在桌面 Safari 上同样存在,还应以 navigator.maxTouchPoints 大于 0 作为门控条件,因为 Mac 报告的值是 0。

navigator.userAgentData 在 Safari 中可用吗?

不可用。navigator.userAgentData 仅限 Chromium,且在 MDN 上被标记为实验性,Safari 和 Firefox 都未实现。当你的目标是 iOS Safari 时,它绝不是特性检测的可行替代方案。应改为使用 'x' in window 或 CSS.supports() 直接检测能力,MDN 认为这比读取 user-agent 可靠得多。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.