Web 可访问性测试工具链
a11y 不靠人肉测——有一套成熟的自动化工具。本文讲清四层 a11y 测试:lint(eslint-plugin-jsx-a11y)、自动扫描(axe-core/Lighthouse)、E2E 集成(@axe-core/playwright)、屏幕阅读器实测(VoiceOver/NVDA),以及 CI 门禁配置。
本文要点
- 主题聚焦:React、TypeScript
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
大多数团队对 a11y(accessibility,可访问性)的测试方式是这样的:上线前找个 Mac,打开 VoiceOver,闭着眼睛戳两下,"嗯,能读出来,应该没问题";或者更常见——根本不测,等用户投诉、等律所发函、等欧盟合规审查被打回来,才开始慌。
这是把 a11y 测试理解成了"要么不测,要么全靠人耳听"。两种极端都错。a11y 早就有一套成熟的、分层的自动化工具链——从你敲代码那一刻的即时提示,到 CI 里卡合并的门禁,都有现成方案。但工具也不是万能的:自动化最多只能抓到 30%–40% 的 a11y 问题,剩下那一多半语义、顺序、真实交互体验的问题,必须靠屏幕阅读器人耳实测。这两件事不矛盾,是同一套体系的两条腿。
这篇讲清楚四层 a11y 测试每一层用什么工具、抓什么问题、怎么配,以及最重要的——哪些问题自动化根本摸不到,必须留给人。
一、四层测试:为什么不能只靠一种工具
先把这个框架立住,否则你会陷入"装了个 axe 扩展就以为 a11y 搞定了"的幻觉。
a11y 问题分布极广:从"img 没 alt"这种一眼能看出的低级错误,到"焦点环被 CSS 干掉了""动态渲染的弹窗没锁焦点""aria-live 区域读不出更新"这种需要真实交互才暴露的问题,跨度极大。没有任何单一工具能全覆盖。成熟的实践分成四层,每一层回答不同问题:
- Lint 层(编码时):
eslint-plugin-jsx-a11y。在你敲 JSX 的瞬间就提示——alt 缺失、tabindex 是正数、aria-* 拼错了。抓的是"写错了",零成本,应该是第一道防线。 - 自动扫描层(开发时):
axe-core及其浏览器扩展、Lighthouse 的 a11y 审计。对渲染后的 DOM 跑几十条规则,抓 DOM 层面的违规——对比度不够、id 重复、表单没 label。抓的是"渲染出来的 DOM 不合规"。 - E2E 集成层(CI 时):
@axe-core/playwright。把 axe 嵌进你的 E2E 测试,在真实浏览器里走完流程后自动断言"这一页没有违规"。抓的是"真实页面(含动态渲染、登录态、异步数据)不违规"。 - 人耳实测层(发布前):VoiceOver(Mac)/ NVDA(Windows)。真的用屏幕阅读器走一遍。抓的是自动化永远摸不到的语义和体验问题——阅读顺序、标题层级是否合理、动态更新有没有被播报。
为什么必须四层叠加?因为每一层都有盲区。Lint 只看源码,跑不出渲染逻辑;axe 扫描只看当前 DOM 快照,抓不到交互后的状态变化;E2E 里的 axe 虽然能跑交互后状态,但它只能检查"规则可判定"的问题——而"这个按钮的文案对盲人用户是否清晰""焦点跳转顺序是否合理"这类需要人类判断的语义问题,axe 的规则引擎根本无能为力。
Deque(axe-core 的母公司)自己的研究数据是:自动化测试大约只能覆盖 30%–40% 的 a11y 问题。剩下 60% 以上——语义、顺序、文案、真实交互体验——必须靠人工。所以记住这条铁律:自动化是兜底,不是免责声明;人耳测是不可替代的那一层,不是可选项。
下面四节,一层一层讲。
二、第一层:eslint-plugin-jsx-a11y —— 编码时即时拦截
成本最低、收益最快的一层。它的逻辑是:很多 a11y 错误在 JSX 源码层面就能判定,根本不需要等渲染。比如 <img> 必须有 alt、tabIndex 不能是正数、aria-* 属性的值要合法——这些都是"写错的瞬间就该报红"的事,而不是"等浏览器跑起来再发现"。
安装与配置
ESLint 9 的 flat config 接法(eslint.config.js):
// eslint.config.js
import jsxA11y from 'eslint-plugin-jsx-a11y';
export default [
...其它配置,
jsxA11y.flatConfigs.recommended, // 推荐规则集,开箱即用
];
老式 .eslintrc 写法:
{
"extends": ["plugin:jsx-a11y/recommended"],
"plugins": ["jsx-a11y"]
}
装完之后,你在编辑器里敲 <img src="/hero.png" />(漏了 alt)会立刻飘红,不用等构建。这就是这一层的价值——把一类 bug 在它出生的那一秒就摁死。
它实际帮你抓什么
推荐规则集里最常救命的几条,按我见过的踩坑频率排:
alt-text:<img>必须有alt(哪怕是空字符串alt=""也行,表示"装饰性图片,屏幕阅读器跳过")。最常漏的一个。aria-role:role属性的值必须是合法的 ARIA role(role="buton"拼错直接报错)。还有aria-*的值校验(比如aria-expanded只能是true/false)。tabindex-no-positive:禁止tabIndex={1}及以上的正数。正数 tabIndex 会破坏文档流的自然焦点顺序,导致键盘用户跳跃式乱跳——这是 a11y 里的经典反模式,一律用0(可聚焦、按文档流)或-1(可编程聚焦、不在 Tab 链里)。no-autofocus:禁止autoFocus。强行抢焦点会让屏幕阅读器用户"迷路"——页面一加载焦点就飞到某个输入框,他们还没读到页面标题呢。anchor-is-valid:<a>要么有合法href,要么改成<button>。<a onClick>这种伪链接是双重灾难:屏幕阅读器读成"链接"但点不动、键盘不能聚焦。label-has-associated-control:<label>必须通过htmlFor或包裹关联到一个表单控件,否则标签是孤儿,输入框对屏幕阅读器就是"无名的编辑框"。no-static-element-interactions:给<div onClick>但没加role/tabIndex/onKeyDown——光能鼠标点、键盘和屏幕阅读器全废。这条逼你用语义标签或补齐全套交互属性。
它抓不到什么
Lint 只看静态源码,抓不到运行时的 DOM。比如你用 JS 动态渲染了一个没 alt 的 <img>,或者一个组件库内部生成的 DOM 结构有问题,lint 完全看不见。也抓不到对比度、焦点管理、动态内容的 aria-live——这些得交给下面几层。
一句话:lint 是第一道闸,但只是闸,不是堤坝。 它能让你的代码"一开始就少犯错",但不能保证渲染出来一定合规。
三、第二层:axe-core + Lighthouse —— 浏览器扩展与自动扫描
Lint 之后,下一层是对渲染后的真实 DOM 跑规则。这一层的核心引擎是 axe-core,它是 Deque 开源的 a11y 规则引擎,业界事实标准——Lighthouse 的 a11y 审计、Chrome DevTools 的 Accessibility 面板、各种浏览器扩展,底层跑的都是 axe-core。学一套,到处通用。
axe-core 在测什么
axe-core 内置了 50+ 条规则,按 tag 分组:wcag2a、wcag2aa、wcag21a、wcag21aa(对应 WCAG 2.x 的 A/AA 级),还有 best-practice。每条规则都是为了回答"这一类问题合不合规",比如:
color-contrast:文字和背景的对比度是否达到 WCAG AA(正常文字 4.5:1,大号文字 3:1)。duplicate-id:id重复——aria-labelledby/htmlFor会指向错误的元素。region:页面内容是否被语义化的 landmark(<main>/<nav>/<header>)包裹,没有就是"孤儿内容",屏幕阅读器用户难以跳转。heading-order:标题层级是否跳级(h1直接跳到h4)。button-name/link-name:按钮/链接必须有可访问的名字(文字、aria-label、aria-labelledby),否则屏幕阅读器只会读"按钮",用户完全不知道这个按钮干嘛。
axe 设计上有一个重要原则值得记住:它只报"确定是问题"的违规(violations),刻意降低假阳性。规则没把握的会归到 incomplete(需要人工确认)而不是直接判违规。这是它能在 CI 里当门禁的前提——一个动不动误报的工具没人敢拿它卡合并。
怎么用:浏览器扩展 + Lighthouse
开发时最快的方式是装浏览器扩展(axe DevTools,Chrome/Firefox/Edge 都有)。打开任意页面点一下扫描,几秒钟出一份报告:哪些元素违规、违反了哪条规则、严重等级(critical/serious/moderate/minor)、怎么修。这是排查现网页面 a11y 问题最趁手的工具,没有之一。
Lighthouse 的 a11y 审计跑的就是 axe 的一个子集。你可以通过 Chrome DevTools 的 Lighthouse 面板跑,也可以用 Lighthouse CI 在构建产物上跑(这个项目本身就有配置):
// .lighthouserc.json
{
"ci": {
"collect": {
"staticDistDir": "./dist",
"numberOfRuns": 3,
"settings": { "preset": "desktop" }
},
"assert": {
"assertions": {
"categories:accessibility": ["error", { "minScore": 0.95 }]
}
}
}
}
这里把 a11y 分数的及格线设到 0.95(满分 1.0)。一个值得注意的细节:Lighthouse 的 a11y 分数只有 0 或 1——每条规则要么全过要么 0 分,所以"分数 0.95"意味着有少量规则没过。别被高分麻痹,要看具体哪几条没过。
这层的局限
axe 扫描是对某个时刻的 DOM 快照做静态判定。这意味着:
- 只测扫描那一瞬间的状态。你的页面有 5 个 Tab、3 个弹窗、2 个下拉菜单,扫描器只看到默认展开的那一个的状态。其他交互后才会出现的 DOM,它根本不知道存在。
- 测不到"交互过程"。打开弹窗后焦点有没有跳进去、Tab 键能不能在弹窗里循环、关掉弹窗焦点有没有回到触发按钮——这些是过程问题,快照扫描抓不到。
- 判不了"合不合理"。axe 能告诉你"这个按钮有 aria-label",但这个 label 文案写的是"点击这里"(对盲人毫无意义)它管不着——这要靠人判断。
所以 axe 扩展是开发期极好的"扫雷器",但它扫完没问题不等于真没问题。要覆盖交互和动态状态,进下一层。
四、第三层:@axe-core/playwright —— E2E 里自动断言违规
这一层把 axe 嵌进 E2E 测试,解决上一层的两个盲区:测得到交互后的 DOM、测得到登录态/异步数据下的真实页面。原理很简单——在 Playwright 跑完某个页面或交互后,对当前 DOM 跑一次 axe 分析,断言没有违规。等于让 axe 看到所有交互过的状态。
安装与基础用法
pnpm add -D @axe-core/playwright
最小用例:
// e2e/a11y.spec.ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test("首页无 a11y 违规", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
这就完成了"这一页渲染出来后,axe 规则全过"的断言。零额外成本,和你的 E2E 用同一套浏览器、同一套断言。
进阶:测交互后的状态 + 按规则裁剪
真实价值在于测交互后的 DOM。比如弹窗打开后是否合规、表单校验失败后错误提示是否能被屏幕阅读器读到:
test("登录失败后错误提示可访问", async ({ page }) => {
await page.goto("/login");
// 故意触发校验失败
await page.getByRole("button", { name: "登录" }).click();
// 对失败态的 DOM 跑 axe
const results = await new AxeBuilder({ page })
.include("#login-form") // 只扫登录表单区域
.withTags(["wcag2a", "wcag2aa"]) // 只跑 WCAG A/AA 级规则
.analyze();
expect(results.violations).toEqual([]);
});
两个关键技巧:
.include(selector):只扫描指定区域。整页面扫描容易把第三方组件、广告 iframe 的噪音混进来,聚焦到自己的组件更干净。.withTags([...]):只跑指定级别的规则。默认会跑wcag2a/aa + wcag21a/aa + best-practice,但很多团队只想卡 WCAG A/AA 这个合规底线,best-practice当参考不卡——这样 CI 更稳。
处理"已知噪音":禁用规则要克制
扫一个老项目,往往会冒出几十条违规,CI 一片红,团队第一反应是"全 disable 掉让它绿"。这是最坏的做法。 正确姿势是分类处理:
test("历史页面(含已知问题,逐步修复)", async ({ page }) => {
await page.goto("/legacy");
const results = await new AxeBuilder({ page })
.disableRules(["color-contrast", "heading-order"]) // 只禁有跟踪 issue 的
.analyze();
expect(results.violations).toEqual([]);
});
纪律是:每一条 disableRules 都要在 issue tracker 里有对应工单,修一条删一条 disable。无脑 disable 等于把这一层废掉,还给你"已经测了"的虚假安全感。我给团队的硬指标是:被 disable 的规则数量只降不升,新代码零新增违规。 这样新代码被门禁卡住,老代码逐步还债,a11y 是在变好而不是在烂掉。
五、第四层:VoiceOver / NVDA —— 屏幕阅读器人耳实测
前三层都是自动化,加起来能抓 30%–40%。剩下 60% 的问题,没有捷径,只能真的用屏幕阅读器听一遍。这一层是自动化永远替代不了的,也是大多数团队彻底跳过的一层——结果就是"axe 全绿,但盲人用户根本用不了"。
自动化到底测不到什么
举几个 axe 全绿、但屏幕阅读器用户会骂娘的典型场景:
- 阅读顺序错乱。你用 CSS
flex-direction: row-reverse或order把视觉顺序调了,但 DOM 顺序没动——盲人听到的顺序和明眼人看到的顺序完全不一致。axe 只看 DOM,看不到 CSS 重排。 - 标题层级语义化缺失。
<h1>到<h4>层级连续不跳级、h1只有一个、h2是主要分区——这些"合不合理"axe 判不了。屏幕阅读器用户靠标题跳转导航,标题乱写=无法导航。 - aria-live 没配或配错。动态加载的列表、"加入购物车"后的数量变化、表单校验提示——这些异步更新必须配
aria-live才会被播报。配漏了,盲人用户根本不知道发生了什么;配成assertive又会打断用户当前操作。这个度只有人听了才知道。 - 焦点管理崩溃。打开弹窗焦点没跳进去(还在背后页面晃)、Tab 键能跑出弹窗外、关弹窗焦点没回到触发按钮——交互流完全断裂。E2E axe 扫的是"弹窗 DOM 合规",扫不到"焦点是不是按预期跳转"。
- 文案对盲人无效。按钮叫"点击这里",明眼人看着上下文能猜,盲人听到"点击这里"链接完全不知道去哪;图标按钮的
aria-label写的是"按钮"。全是 axe 认为合规(有 label)、但语义为零的问题。
这些问题有一个共同特征:它们是"体验"问题,不是"结构"问题。规则引擎能判结构,判不了体验。所以这一层必须人来做。
怎么测:VoiceOver(Mac)和 NVDA(Windows)
VoiceOver 是 macOS/iOS 内置的,免费、无需安装,Mac 开发者没有借口不做这一层。快捷键速查(VO = Control + Option):
VO + F5:开启/关闭 VoiceOver。VO + →/←:按 DOM 顺序逐个元素读。VO + Command + H:按标题跳转(测标题层级用这个,听听能不能快速跳到目标区块)。VO + U:进入"转子"(Rotor),按标题/链接/表单/landmark 分类浏览——这是盲人用户的高级导航,你的页面支不支持得好,一听便知。
NVDA 是 Windows 上最主流的免费屏幕阅读器(开源)。装完之后默认快捷键:Insert 键作为修饰键(NVDA 键),Numpad 加方向键导航。Mac 开发者想测 NVDA 但没有 Windows 机器,可以用 Playwright 驱动的云端 Windows,或在虚拟机里装。
实测该测什么
别想着把每个页面都走一遍——那不现实。按 ROI 抓核心流程:
- 关键转化路径:注册/登录、搜索、结账、表单提交。盲人用户能不能独立走完?
- 动态交互:弹窗、下拉、Tab 切换、Toast 提示。焦点跳转对不对、更新有没有被播报?
- 键盘可达性:拔掉鼠标,只用 Tab/Shift+Tab/Esc/Enter 能不能完成所有操作。这一步往往能抓到大量"鼠标 hover 才出菜单、键盘出不来"的问题。
- 标题和 landmark 导航:用转子跳一下,能不能 3 秒内跳到主内容区?
一个务实的目标:每个迭代挑 1–2 个核心流程做一次屏幕阅读器走查,而不是追求全覆盖。重点是建立"真的有人用过"这个事实,而不是把它当成上线前的仪式。
六、CI 门禁:让 a11y 违规阻断合并
前四层工具都用上之后,最后一步是把它们卡进 CI——否则就是"装了但没人跑",三个月后规则全红也没人管。门禁的核心是把"新代码不引入新违规"这件事自动化。
GitHub Actions 配置
最小可用的 a11y CI job:
# .github/workflows/a11y.yml
name: Accessibility
on:
pull_request:
paths: ['src/**', 'e2e/**']
jobs:
a11y:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
# 1. Lint 层:jsx-a11y 规则跟着主 lint job 跑,这里不重复
# 2. E2E + axe 断言层
- name: a11y E2E 断言
run: pnpm exec playwright test --grep @a11y
# 3. Lighthouse a11y 审计(可选,和构建产物一起跑)
- name: Lighthouse a11y
run: pnpm build && pnpm seo:lighthouse
两个实践细节值得强调:
- 用
@a11ytag 隔离 a11y 用例。a11y 用例和功能 E2E 用例混在一起会很乱——给 a11y 测试加@a11y标签,单独跑、单独看报告。功能测试失败和 a11y 失败的处置流程不一样。 - 分支保护里把这个 job 设为必需检查。否则 CI 红了照样能合,门禁形同虚设。这是流程上最关键的一步——技术上配好了,组织上不放权,等于没配。
渐进式收紧:别一上来全卡死
老项目接 a11y 门禁最大的坑是"一开 CI 全红、所有 PR 都被堵死"。正确策略是新老分开:
- 新代码零容忍:
@axe-core/playwright的断言只对"本次 PR 改动的区域"生效(用.include()限定到改动组件),新代码不达标直接 fail。 - 老代码基线管理:对全站扫描,把当前违规数作为 baseline,CI 断言"违规数不得超过 baseline"。每修一条,手动把 baseline 减一。这样老债务只降不增,新代码零新增。
这种"基线冻结 + 增量卡死"的策略,是把 a11y 从零引入到老项目唯一可行的方式。一刀切全卡死,团队只会逼着你在 disableRules 里做手脚。
小结
- 四层测试缺一不可:lint(编码时拦源码错误)→ axe 自动扫描(开发时扫渲染 DOM)→ @axe-core/playwright(CI 里断言交互后状态)→ 屏幕阅读器人耳测(发布前验语义体验)。每层都有盲区,叠加才接近完整。
- eslint-plugin-jsx-a11y 是零成本的第一道闸:alt/aria/tabindex/label 这些源码就能判定的错误,在敲代码瞬间摁死,推荐规则集开箱即用。
- axe-core 是自动化的引擎,到处通用:浏览器扩展、Lighthouse、Playwright 底层都是它,50+ 规则只报"确定违规"、刻意降低假阳性,这是它能当 CI 门禁的前提。
- CI 门禁要渐进式:新代码零容忍、老代码基线冻结只降不增,每条
disableRules都要有对应工单。一刀切全卡死只会逼出更多的自欺欺人。 - 自动化只覆盖 30%–40%,人耳测不可替代:阅读顺序、标题语义、aria-live 播报、焦点管理、文案有效性——这些"体验"问题规则引擎判不了,必须用 VoiceOver/NVDA 真听一遍。
一句话收尾:a11y 测试的成熟度,不在于你装了多少自动化工具,在于你是否承认自动化有天花板——并且真的留出时间,闭上眼睛听一遍自己的产品。
Related