兼容性测试矩阵设计¶
兼容性测试真正的问题不是“测多少浏览器”,而是你为什么选这几个。说不清选择依据的矩阵,最后都会变成“大家都装 Chrome,那就只测 Chrome”。
下面所有版本结论以 2026-10 的 caniuse 数据为准,命令与配置在 Node 20 / Playwright 1.41 下验证过。
一、矩阵的六个维度¶
| 维度 | 取值 | 选择依据 |
|---|---|---|
| 浏览器 | Chrome、Edge、Firefox、Safari | 看埋点 UV 占比,不看“主流”这个词 |
| 版本 | 最新版、上一大版、企业锁定版 | 版本分布里累计覆盖 ≥95% UV |
| 操作系统 | Windows 10/11、macOS 13/14、iOS 16/17/18、Android 12~15 | 系统决定字体、滚动、输入法 |
| 分辨率 | 1366×768、1920×1080、2560×1440、390×844、360×800 | 1366 仍是企业内网最大头 |
| 网络 | Wi-Fi、4G、3G 限速、离线 | 弱网影响超时与重试,不影响渲染 |
| 设备 | 主流直板机、低端机、平板、折叠屏 | 折叠屏展开态与安全区差异 |
| 语言/时区 | zh-CN、en-US、ja-JP、UTC+8 / UTC-5 | 影响日期、数字、复数、排序 |
只测“最新版”是最常见的错误决策:企业客户常年停留在 Chromium 109(Windows 7 最后支持版本),而你的构建工具默认 target 已经是 ES2022。
二、用真实流量裁剪,而不是拍脑袋¶
先取一个完整月的 PV/UV,按浏览器主版本聚合:
SELECT browser_family, browser_major, os_family,
COUNT(*) AS pv, COUNT(DISTINCT user_id) AS uv
FROM dwd_page_view
WHERE dt BETWEEN '2026-09-01' AND '2026-09-30' AND app_id = 'web-main'
GROUP BY 1, 2, 3
ORDER BY uv DESC;
把结果按 UV 降序累加,找到覆盖 95% UV 需要几个组合。经验值:中等规模 B 端产品通常 8~12 个组合就够,剩下 5% 集中在低版本 Safari 和国产 Android WebView,用云真机按季度抽检即可。
裁剪规则按优先级排:
- UV 累计前 95% 的组合,每次回归必跑。
- 支付、登录、上传等关键路径,额外强制覆盖 Safari 与 iOS。
- 近三个月出过缺陷的组合,无论占比多低都保留。
- 新发布的大版本(如 Chrome 130、iOS 18.2)先跑冒烟。
- 明确写出“不覆盖”的组合及原因,比假装全覆盖更有价值。
三、必须知道的渲染差异¶
flex 的 gap¶
| 特性 | Chrome | Firefox | Safari |
|---|---|---|---|
flex 容器 gap |
84+ | 63+ | 14.1+ |
CSS.supports('gap','8px') 能否识别 |
能,但会误报 | 能 | 能 |
@supports (gap: 8px) |
同上,误报 | 同上 | 同上 |
坑在于 CSS.supports('gap','8px') 在只支持 grid gap 的旧 Safari 上同样返回 true,用它做特性检测会得到错误结论。可靠做法是量元素的实际位置:
const box = document.createElement('div');
box.style.cssText = 'display:flex;gap:100px;position:absolute;visibility:hidden';
box.innerHTML = '<i></i><i></i>';
document.body.appendChild(box);
const [a, b] = box.children;
const ok = b.getBoundingClientRect().left - a.getBoundingClientRect().right >= 99;
box.remove();
案例 1:Safari 14 上导航项挤成一坨。 桌面端用 display:flex; gap:24px,Chrome/Firefox 正常,iOS 14.4 的 Safari 里菜单文字首尾相接。构建产物没有做 postcss 降级,也没写 margin 兜底。修复是 .nav > * + * { margin-left: 24px } 配合 @supports not (gap: 1px),或直接升级最低支持版本到 iOS 15。
input[type=date]¶
| 行为 | Chrome 120 | Firefox 121 | Safari 17 |
|---|---|---|---|
| 是否弹出日期选择器 | 是 | 是 | 是(桌面版 14.1 起) |
| 展示格式 | 跟随系统 locale | 固定 yyyy-mm-dd 占位 |
跟随 locale,mm/dd/yyyy |
value |
始终 yyyy-mm-dd |
同左 | 同左 |
| 清空按钮 | 有 | 无 | 有 |
min/max 校验文案 |
中文 | 英文 | 跟随系统语言 |
input.value 永远返回 yyyy-mm-dd,但 input.valueAsDate 返回的是 UTC 午夜。业务在东八区用它做“今天”的判断,午夜到早上 8 点之间会算成前一天。
// 不要这样取本地日期
const d = document.querySelector('#date').valueAsDate; // 2026-10-22T00:00:00Z
d.getDate(); // UTC-5 下是 21 号
// 这样取
const [y, m, day] = document.querySelector('#date').value.split('-').map(Number);
const local = new Date(y, m - 1, day);
100vh 与移动端地址栏¶
iOS Safari 15 及以下,100vh 等于地址栏收起时的高度。底部固定按钮会被遮住约 60~100px,滚动后地址栏收起布局又跳一次。100dvh 覆盖 Chrome 108+ / Safari 15.4+ / Firefox 101+,写两遍即可渐进增强:
position: sticky 失效¶
sticky 不生效,九成是三个原因:任意祖先有 overflow: hidden|auto|scroll,滚动容器被换成了该祖先;父元素高度等于 sticky 元素高度,没有可滚动空间;元素是 flex/grid 子项且 align-self: stretch,高度被拉满。
案例 2:表格表头吸顶失效。 表格外层为了横向滚动加了 overflow-x: auto,thead th { position: sticky; top: 0 } 完全不动。把 overflow-x 移到更外层并给内层 overflow: visible,或者在 overflow 容器内部让表头 sticky 相对该容器生效(此时 top: 0 相对容器,不是视口)。另外 Chrome 在 border-collapse: collapse 下 sticky 表头边框会消失,改成 separate + border-spacing: 0 才稳定。
字体与字重¶
macOS/iOS 的 PingFang SC 有 Medium(500),Windows 的微软雅黑只有 400/700。设计稿写 font-weight: 500 时,Windows 上会回落到 400,“加粗没生效”;600 则可能被合成加粗(faux bold),笔画糊成一团。部分中文版 Chrome 的“最小字号”默认为 12px,font-size: 10px 的角标会被强制放大,撑破固定高度。
body { font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; }
.amount { font-variant-numeric: tabular-nums; } /* 金额列在 Windows 上不抖动 */
视频自动播放¶
| 环境 | 静音自动播放 | 带声音自动播放 |
|---|---|---|
| Chrome 66+ 桌面 | 允许 | 需用户交互或高 MEI |
| Chrome Android | 允许 | 禁止 |
| iOS Safari | 允许(需 playsinline) |
禁止 |
| iOS 低电量模式 | 禁止,静音也不行 | 禁止 |
失败时的真实报错:
const v = document.querySelector('video');
v.muted = true;
v.setAttribute('playsinline', ''); // 缺这个 iOS 会全屏播放
v.play().catch((e) => {
if (e.name === 'NotAllowedError') showPlayButton(); // 降级为手动点击
});
Playwright 里给 Chromium 加 --autoplay-policy=no-user-gesture-required 可以绕过策略,但这会让测试结果与真实用户不一致,别把它当成“兼容性通过”。
其他高频差异¶
:has() 需要 Chrome 105+/Safari 15.4+,旧版整条选择器失效导致样式全丢;backdrop-filter 在 Safari 18 之前必须带 -webkit- 前缀,否则毛玻璃无效;inert 需要 Chrome 102+/Safari 15.5+,缺失时弹窗遮罩下的内容仍可被 Tab 聚焦;structuredClone 在 Safari 15.4 以下直接报 structuredClone is not defined。
四、响应式断点怎么测¶
DevTools 的设备模拟只能验证布局,不能验证真机字体、滚动与键盘。断点测试要做两件事:逐像素找溢出、在真实断点上验交互。
// tests/responsive.spec.ts
import { test, expect } from '@playwright/test';
test('320~1920 无横向溢出', async ({ page }) => {
await page.goto('/');
const bad: string[] = [];
for (let w = 320; w <= 1920; w += 10) {
await page.setViewportSize({ width: w, height: 900 });
const overflow = await page.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth
);
if (overflow > 0) bad.push(`width=${w} overflow=${overflow}px`);
}
expect(bad, bad.join('\n')).toHaveLength(0);
});
test('定位溢出元素', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto('/');
const offenders = await page.evaluate(() =>
[...document.querySelectorAll('body *')]
.map((el) => ({ el, r: el.getBoundingClientRect() }))
.filter(({ r }) => r.width > 0 && (r.right > innerWidth + 1 || r.left < -1))
.slice(0, 10)
.map(({ el, r }) => `${el.tagName}.${el.className} right=${r.right.toFixed(0)}`)
);
console.log(offenders);
});
断点本身要按内容断,不是按设备断。固定的 768/1024 是历史习惯,正确做法是从 320 开始加宽,直到布局“看起来不对”的那一刻才是断点。测试时至少覆盖:320(iPhone SE)、375、390、414、768、1024、1280、1366、1440、1920。
五、Playwright 多浏览器执行¶
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
retries: 1,
reporter: [['html', { open: 'never' }], ['list']],
use: {
baseURL: 'https://staging.example.com',
trace: 'on-first-retry',
video: 'retain-on-failure',
},
projects: [
{ name: 'chrome', use: { ...devices['Desktop Chrome'], channel: 'chrome' } },
{ name: 'edge', use: { ...devices['Desktop Edge'], channel: 'msedge' } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'safari', use: { ...devices['Desktop Safari'] } }, // 实为 WebKit
{ name: 'iphone13', use: { ...devices['iPhone 13'] } },
{ name: 'pixel7', use: { ...devices['Pixel 7'] } },
],
});
必须清楚一点:Playwright 的 webkit 是 WebKit 内核构建,不等于 Safari。它没有 Safari 的地址栏收缩、智能防跟踪、系统字体与视频解码栈。webkit 通过只说明“WebKit 渲染没大问题”,Safari 真机仍需单独抽检。
npx playwright test --project=safari --grep @smoke
npx playwright show-trace test-results/xxx/trace.zip
六、云真机能力配置¶
BrowserStack 的 browserstack.yml:
userName: ${BROWSERSTACK_USERNAME}
accessKey: ${BROWSERSTACK_ACCESS_KEY}
projectName: qalab-web
buildName: web-20261022
browserstackLocal: true
platforms:
- browserName: chrome
browserVersion: '120.0'
os: Windows
osVersion: '11'
- browserName: safari
browserVersion: '17.0'
os: OS X
osVersion: Sonoma
- browserName: firefox
browserVersion: '121.0'
os: Windows
osVersion: '10'
- deviceName: iPhone 15
browserName: safari
osVersion: '17'
- deviceName: Samsung Galaxy S23
browserName: chrome
osVersion: '13.0'
parallelsPerPlatform: 4
Sauce Labs 的 capabilities(Web 场景):
{
"platformName": "iOS",
"browserName": "Safari",
"appium:deviceName": "iPhone 15 Simulator",
"appium:platformVersion": "17.2",
"sauce:options": {
"build": "web-20261022",
"name": "checkout-smoke",
"screenResolution": "390x844"
}
}
注意模拟器与真机的差别:iOS 模拟器没有真实的内存压力、发热降频和蜂窝网络,支付、相机、推送这类能力必须真机验证。云真机用于“多机型抽检”,不用于替代全部回归。
七、静态检查辅助¶
browserslist 是矩阵的单一事实来源,构建、stylelint、eslint 都读它:
npx browserslist "> 0.5% in CN, last 2 versions, not dead"
npx browserslist --coverage # 该查询覆盖的全球 UA 份额
CSS 侧用 stylelint-no-unsupported-browser-features,它按 browserslist 判定用了哪些“目标浏览器不支持”的特性:
{
"plugins": ["stylelint-no-unsupported-browser-features"],
"rules": {
"plugin/no-unsupported-browser-features": [true, {
"severity": "warning",
"ignore": ["flexbox-gap"]
}]
}
}
JS 侧用 eslint-plugin-compat,能直接标记 Array.prototype.at、structuredClone 这类 API 在目标浏览器上缺失。这两个工具只能拦“语法与 API”,拦不住布局差异,别把它们当成兼容性测试的替代品。
八、移动端 UA 与触屏事件¶
UA 已经不可靠:Chrome 110 起 UA 字符串被冻结在 Chrome/110.0.0.0,真实的次版本要走 Client Hints。iPadOS 13 起默认发送桌面 UA(Macintosh; Intel Mac OS X),服务端按 UA 判断会把它当 Mac。
// 判断是否触屏设备,而不是判断 UA
const isTouch = navigator.maxTouchPoints > 1 && matchMedia('(pointer: coarse)').matches;
const isIPad = /Macintosh/.test(navigator.userAgent) && navigator.maxTouchPoints > 1;
// 需要型号时用 Client Hints(Chrome/Edge)
const hints = await navigator.userAgentData?.getHighEntropyValues(['model', 'platformVersion']);
触屏相关检查:
| 检查项 | 怎么测 | 预期 |
|---|---|---|
| 300ms 点击延迟 | 点按钮计时 | 有 <meta name="viewport" content="width=device-width"> 时无延迟 |
| 双击缩放误触 | 连续快速点两次按钮 | 不缩放页面,touch-action: manipulation 生效 |
| 滚动穿透 | 弹窗内滚动到底继续滑 | 背景不滚动,overscroll-behavior: contain |
| hover 依赖 | 触屏上点卡片 | 不出现“第一次只触发 hover”的双击问题 |
| 手势冲突 | 横滑删除 vs 页面横滑 | 方向锁生效,不互相抢 |
九、矩阵文档模板¶
| 组合 ID | 浏览器/版本 | OS | 分辨率 | 语言 | 网络 | 覆盖用例 | 结果 | 缺陷 | 负责人 |
|---|---|---|---|---|---|---|---|---|---|
| W-01 | Chrome 120 | Win 11 | 1920×1080 | zh-CN | Wi-Fi | 全量 | 通过 | - | 张三 |
| W-02 | Safari 17.0 | macOS 14 | 1440×900 | en-US | Wi-Fi | 全量 | 失败 | #1234 | 李四 |
| W-03 | Chrome 109 | Win 10 | 1366×768 | zh-CN | 4G | 冒烟 | 通过 | - | 张三 |
| M-01 | iOS Safari 17 | iPhone 15 | 390×844 | zh-CN | 4G | 支付链路 | 通过 | - | 王五 |
| M-02 | Android WebView | Galaxy S23 | 360×780 | en-US | 3G | 列表/详情 | 阻塞 | #1235 | 王五 |
结论区必须写清三件事:覆盖了哪些组合、没覆盖哪些及原因、失败项的处理结论(修复/降级/接受)。
十、坑位表¶
| 坑位 | 表现 | 判定标准 |
|---|---|---|
用 CSS.supports 判断 flex gap |
旧 Safari 误报支持 | 必须用实际布局测量 |
| 用 WebKit 代替 Safari | 测试全绿,真机白屏 | Safari 真机每版本至少抽检一次 |
| 只测最新版 | 企业客户旧版炸 | 覆盖累计 95% UV 的版本 |
| 只看分辨率不看 DPR | 2x/3x 下图片模糊、1px 边框消失 | 至少覆盖 DPR 1/2/3 |
| 忽略字体回退 | Windows 上字重、行高全变 | 在 Win 与 macOS 各截一张图对比 |
| 云真机当全量回归 | 用例多、成本高、结果慢 | 云真机只做发布前抽检 |
| 不记录未覆盖范围 | 出问题无法界定责任 | 每版输出矩阵结论表 |