无障碍审计最大的敌人不是“不知道标准”,而是一次性:上线前请人点一遍、修一轮、三个月后新功能把老问题全带回来。解药和所有工程问题一样——把要求翻译成能进 CI 的断言。
分层:哪些能自动查,哪些必须人看
| 层 | 工具 | 能覆盖 | 不能覆盖 |
|---|---|---|---|
| 静态规则 | axe-core / eslint-plugin-jsx-a118 / Lighthouse a11y | 缺 label、role 误用、对比度、landmark | 交互流是否合理 |
| 键盘行为 | Playwright 驱动 | Tab 顺序、焦点陷阱、Esc、方向键 | “好不好用” |
| 视觉偏好 | 媒体特征模拟 | reduced-motion 全静态、强制色彩 | 主观可读性 |
| 读屏实测 | 人工(NVDA/VoiceOver) | 语义是否“听起来对” | 无法自动化 |
自动化吃下前三层,人只负责第四层——这是个人站点也养得起的分工。
五条最值得固化的断言
1. 键盘可达核心流(每页模板化):
await page.keyboard.press('Tab'); // skip-link 第一站
// 断言:纯键盘完成 搜索→选结果→进入详情
const focusables = await page.evaluate(
() =>
[
...document.querySelectorAll(
'a,button,input,select,textarea,[tabindex="0"]',
),
].filter((el) => el.offsetParent !== null).length,
);
2. 焦点永远可见:全站禁止“只有颜色变化”的 focus 态——断言自定义控件存在 :focus-visible 规则且伴随非颜色信号(outline/border/背景差):
const outline = await page.evaluate(() => {
const el = document.activeElement;
return getComputedStyle(el).outlineStyle; // 期望非 'none',或 box-shadow/背景有可辨变化
});
3. 模态必带焦点管理:每个弹层三条断言——打开后焦点在弹层内、Tab×N 不逃逸、关闭后回到触发元素。这三行是回归事故(焦点丢失到 body、Tab 掉进已卸载按钮)的捕获器。
4. 状态播报:一切“结果会异步变化”的区域必须有 live region:
const live = await page.evaluate(() =>
[...document.querySelectorAll('[aria-live]')].map((n) => n.id),
);
// 搜索计数、提交结果、成就解锁 → 断言注册在列
5. reduced-motion 全静态且信息不减:启动 reducedMotion: 'reduce' 的 context,逐页断言:无 running 动画、所有 [data-scroll-reveal] 内容可见、动画承载的状态仍有文本/颜色替身。本项目用 13 项检查把六页跑成了一条命令。
把颜色对比度变成数值而不是感觉
深色主题的对比度问题肉眼不可靠(显示器亮度、环境光都在骗人),交给计算——Lighthouse a11y 满分时它内置的 color-contrast 规则已经逐元素核过一遍。把 --lhci 纳入发布流程,等于免费雇了一个对比度审计员;一次真实修复(全局 a{color:inherit} 压过按钮文字色导致 1.73:1)就是它抓出来的。
反模式清单(规则能查的)
<div onclick>:没有键盘语义——要求交互节点是button/a或显式补role + tabIndex + keydown;- 图标按钮无文本:必须有
aria-label或视觉隐藏的文本子节点; - 只用颜色传达状态:错误=红、成功=绿之外必须有图标/文本;
aria-hidden的容器里残留可聚焦元素:等于给键盘用户造黑洞——隐藏需连inert/tabindex=-1一起。
流程建议
- 每个新交互组件的 PR 附“上表五条断言”中适用的行(写进组件模板,让复制测试比跳过测试容易);
- CI 跑静态规则 + 关键页 reduced-motion;
- 发布前全量跑一遍带 Lighthouse 的门禁;
- 每季度人工读屏一次核心流——这一项接受无法自动化的事实。
小结
无障碍的质量不取决于你懂多少 ARIA 规范,而取决于三个月后新功能进来时,老规矩还在不在。把标准翻译成断言,是把个人意志变成系统能力——键盘用户、读屏用户、对动效敏感的用户,从此不再依赖任何人“记得”测试。