先说几个核心判断:直接在 HTML 页面中调用 axe.run(),大概率会失败。这并非代码本身写错了,真正的障碍在于——执行时机与运行环境未能同步到位。常见的现象是接连报错,要么是 ReferenceError: axe is not defined,要么是 TypeError: axe.run is not a function,或者干脆毫无反馈,静默无输出。

axe.run() 在纯 HTML 中调用失败的根源
核心原因可归结为以下三点:
加载完成后,并不等于axe对象已全局可用。它本质上是异步注入的,稳妥的做法是监听load事件,或用setTimeout延迟调用确保库完全就绪。- DOM 自身可能尚未渲染完整。如果页面上包含
或依赖data-*驱动的模板(如 Alpine.js),axe.run()扫描到的将是一个空壳结构。 - 静态 DOM 往往无法捕捉交互层面的无障碍问题。比如折叠菜单未展开、表单未聚焦等场景下,许多违规仅在用户交互后才暴露出来。
DevTools 控制台手动验证:最小可行路径
若不想改动 HTML、不安装构建工具、也不编写测试文件,最直接的方式是:利用浏览器控制台快速验证当前页面是否存在明显的无障碍违规。这一方法在调试阶段极为实用。
具体操作步骤如下:
- 打开目标页面 → 按下 F12 → 切换到 Console 标签页。
- 首先检查:
typeof axe,若返回"object"方可继续;否则需要手动注入:const s = document.createElement('script'); s.src = 'https://cdn.jsdelivr.net/npm/axe-core@4.7.0/dist/axe.min.js'; document.head.appendChild(s); - 等待 2 到 3 秒后,再执行:
await axe.run({ shadowDom: true }).then(r => console.table(r.violations.map(v => ({ rule: v.id, impact: v.impact, nodes: v.nodes.length }))));
需要注意:{ shadowDom: true } 必须显式传入。否则,像 这类自定义元素内部的 DOM 结构将被完全漏检。如果页面上还包含 iframe,还需额外添加 iframes: true。
自动化测试必须绑定生命周期控制权
所谓“自动化”,核心在于让 axe.run() 在正确的时间、正确的状态下运行。依靠 setTimeout 猜测时间并不可行,必须依赖测试框架提供的生命周期钩子。
主流方案的实际约束如下:
- Playwright:推荐使用
@axe-core/playwright提供的AxeBuilder。它能自动等待networkidle和 Turbo/ReactPy 渲染完成,但务必确保在page.goto()之后显式触发交互(例如page.click('#menu-toggle'))再进行扫描。 - Cypress:必须先调用
cy.injectAxe(),再使用cy.checkA11y()。若直接使用cy.window().then(w => w.axe.run()),会因上下文隔离而失败。 - Jest + JSDOM:
global.document必须来自新创建的 JSDOM 实例。如果复用了旧实例,axe.run()返回的结果将是过期的、缓存的 DOM。
runOnly 配置不当会使检测失去意义
默认情况下,axe-core 会运行全部 57+ 条规则,既慢又容易分散注意力。但随意配置 runOnly 更加危险。例如写成 { runOnly: { type: 'tag', values: ['serious'] } },实际上 axe-core 原生并不识别 serious 这个 tag,结果就是“扫了等于没扫”。
安全且常用的配置方案:
- WCAG 2.1 AA 合规:
{ runOnly: { type: 'tag', values: ['wcag2aa'] } } - 仅查高影响项(critical / serious):
{ runOnly: ['color-contrast', 'heading-order', 'label', 'link-in-text-block'] }—— 这里显式列出规则 ID,不依赖 tag。 - 排除已知误报(例如某些富文本编辑器的
aria-hidden误判):{ rules: [{ id: 'aria-hidden-focus', enabled: false }] }
最容易忽略的一点是:所有配置必须在 axe.run() 调用前通过 axe.configure() 设置,并且每次测试前需要执行 axe.reset()。否则,上一次的配置会污染后续扫描结果。
