首页
/ Storybook a11y 插件深度解析:基于 axe-core 的无障碍测试与面板实践

Storybook a11y 插件深度解析:基于 axe-core 的无障碍测试与面板实践

2026-09-06 19:25:59作者:咎岭娴Homer

@storybook/addon-a11y 是 Storybook 官方提供的无障碍(Accessibility,简称 a11y)测试插件,它基于 axe-core 引擎在 Story 渲染完成后自动执行 WCAG 规则检查,并在 Manager 面板中展示违规详情与视觉模拟结果。本文以 a11y 插件 README 为核心骨架,结合插件源码(code/addons/a11y/src)逐一拆解其安装机制、a11y 参数体系、检查执行链路与结果呈现,帮助你既会用这个插件,又理解它"为什么这样跑"。

一、插件定位与安装方式

README 对插件的定位非常简洁:为 Storybook stories 提供无障碍测试,底层使用 axe-core 执行检查。从 package.json 可以确认其关键事实:

  • 运行时依赖仅为 axe-core(^4.2.0)与 @storybook/global,插件本体非常轻;
  • 包名描述明确为 "Test UI component compliance with WCAG web accessibility standards";
  • storybook.unsupportedFrameworks 声明了 react-native 不受支持——无障碍检查依赖真实 DOM,React Native 没有浏览器 DOM,因此被显式排除;
  • exports 暴露了四个入口:.(preview 侧装饰器与类型)、./manager./preset./preview,对应 Storybook addon 的 Manager/Preview 双端模型。

在已有 Storybook 中启用

官方推荐的启用方式是一条命令:

npx storybook add @storybook/addon-a11y

这条命令背后做了两件事,源码可以直接印证:

  1. 注册 presetpreset.ts 只导出一个布尔值 isAddonA11yEnabled = true,其注释写明用途:让其他 addon 或 preset 通过 await presets.apply('isAddonA11yEnabled', false) 探测 a11y 是否启用,从而调整自身行为——这是一种轻量级的"addon 间能力协商"机制。
  2. 执行 postinstall 迁移脚本postinstall.ts 会在安装时调用子命令:
const args = [
  useRemotePkg ? `storybook@${versions.storybook}` : `storybook`,
  'automigrate',
  'addon-a11y-addon-test',
  '--loglevel', 'silent',
  '--skip-doctor',
];

即自动运行 storybook automigrate addon-a11y-addon-test 迁移器。它支持透传 --yes--package-manager--config-dir 等选项,并用 stdio: ['ignore', 'pipe', 'pipe'] 处理 stdin——源码注释特别解释了必须把 stdin 设为 ignore,否则嵌套的 CLI 收不到 EOF 会永久阻塞外层 add 命令。这说明插件安装不只是"加个包",还会对既有配置做自动化改造。

二、架构总览:Preview 端跑检查,Manager 端看结果

从源码结构看,a11y 插件遵循 Storybook addon 的标准双端模型:

code/addons/a11y/
├── src/
│   ├── preview.tsx          # Preview 端:afterEach 钩子,自动触发检查
│   ├── manager.tsx          # Manager 端:注册面板与视觉模拟工具
│   ├── a11yRunner.ts        # 核心:封装 axe-core 的执行器
│   ├── a11yRunnerUtils.ts   # 结果增强:生成可定位的 linkPath
│   ├── params.ts            # a11y 参数类型定义
│   ├── types.ts             # 全局参数、结果类型、状态机
│   ├── constants.ts         # ADDON_ID / 事件名 / 面板 ID
│   ├── AccessibilityRuleMaps.ts  # axe 规则 → 友好文案映射
│   ├── postinstall.ts       # 安装期迁移
│   ├── withVisionSimulator.ts    # 色觉障碍视觉模拟装饰器
│   └── components/          # A11YPanel / Report / VisionSimulator 等 UI
└── template/stories/        # 官方示例 stories(参数用法演示)

两端通过 addons channel 通信,constants.ts 定义了事件总线:

事件/标识 作用
ADDON_ID storybook/a11y addon 注册 ID
PANEL_ID storybook/a11y/panel 结果面板 ID
PARAM_KEY a11y 面板关联的 parameters 键
EVENTS.RESULT / ERROR / MANUAL storybook/a11y/... preview → manager 的结果回传

Manager 端 manager.tsx 注册了两个 addon:一个是 types.PANEL 类型的结果面板(标题动态显示 "Accessibility" 与违规计数 Badge),一个是 types.TOOL 类型的视觉模拟工具(VisionSimulator)。面板仅在 viewMode === 'story' 时匹配——Docs 视图下不出现,这与检查只在 story 渲染后触发相呼应。

三、a11y 参数体系:types 与 defaults 一览

插件对外的配置面集中在 params.tstypes.ts。完整参数类型如下:

// code/addons/a11y/src/params.ts
type A11yTest = 'off' | 'todo' | 'error';

export interface A11yParameters {
  /** axe-core run 的 Context 参数(不支持直接传 Node/NodeList) */
  context?: ContextSpecWithoutNode;
  /** axe-core 运行选项(对应 RunOptions) */
  options?: RunOptions;
  /** axe-core 配置对象(对应 Spec) */
  config?: Spec;
  /** 是否禁用无障碍测试 */
  disable?: boolean;
  /** 违规处理方式:'off' | 'todo' | 'error' */
  test?: A11yTest;
}

全局参数(Globals)则只有一个开关:

// code/addons/a11y/src/types.ts
export interface A11yGlobals {
  a11y?: {
    /** 访问 story 时不自动执行检查(仍可手动在面板触发) */
    manual?: boolean;
  };
}

默认值由 preview.tsx 中的 initialGlobalsparameters 声明:

export const initialGlobals = {
  a11y: { manual: false },
  vision: undefined,
};

export const parameters = {
  a11y: { test: 'todo' }, // 默认将违规报告为 warning
};

各参数语义归纳:

参数 位置 作用 默认值
test: 'off' parameters / meta / story 完全关闭该 story 的自动化检查 无(插件级默认 'todo')
test: 'todo' 同上 有违规时按 warning 报告,不视为失败 'todo'
test: 'error' 同上 有违规时按 failed 报告
disable: true 同上 禁用检查(与 test: 'off' 等效的另一种写法) false
context 同上 限定检查范围,支持选择器、选择器数组或 { include, exclude } 对象 document.body 减去 Storybook 内部元素
options 同上 透传 axe-core RunOptions(如 runOnlychecks) {}
config 同上 透传 axe-core Spec(自定义/禁用规则等) {}
a11y.manual globals(URL/预览配置) 关闭"访问 story 即自动检查",改为面板手动触发 false

值得注意:源码中 context 类型是 ContextSpecWithoutNode——即刻意剥离了 Node / NodeList 支持。运行器会显式拒绝 element 参数并抛出 ElementA11yParameterError(见 a11yRunner.tsif (input.element) throw ...),从结构上强制使用可序列化选择器,保证参数可被 URL 编码与持久化。

四、执行链路:afterEach → 串行队列 → channel 回传

4.1 何时跑检查

preview.tsx 导出的是 preview 侧的 afterEach 钩子,判定逻辑完整还原如下:

const shouldRunEnvironmentIndependent =
  !isGhostStories &&              // ghost stories 场景跳过
  a11yParameter?.disable !== true &&  // parameters.a11y.disable
  a11yParameter?.test !== 'off' &&    // parameters.a11y.test
  a11yGlobals?.manual !== true;        // globals.a11y.manual

if (shouldRunEnvironmentIndependent && viewMode === 'story') {
  const result = await run(a11yParameter, storyId);
  const hasViolations = (result?.violations.length ?? 0) > 0;
  reporting.addReport({
    type: 'a11y',
    version: 1,
    result,
    status: hasViolations ? getMode() : 'passed',
  });
}

要点:

  • 检查只在 viewMode === 'story' 时触发,Docs 视图不跑;
  • 参数可按 parameters 继承链在 preview(全局)、meta、story 三级配置,story 级 disable/off 可覆盖全局;
  • 结果统一写入 Storybook 的 reporting 体系(type: 'a11y'),这正是它能在 Manager 面板与组件测试报告中被统一消费的原因;
  • 违规时的 status 由 test 参数决定:'todo'warning,'error'failed

4.2 a11yRunner:为什么用串行队列

a11yRunner.ts 是整个插件的核心。它的 run() 函数有几个关键设计:

1. 串行队列。 源码注释写明 "axe-core is not designed to run in parallel",因此所有 axe.run 调用被推入一个 promise 队列,由 runNext() 逐个消费。这意味着快速切换多个 story 时检查会排队执行,而不是并发竞争 axe 内部状态。

2. 默认检查范围排除 Storybook 内部元素:

const context: ContextSpec = {
  include: document?.body,
  exclude: ['.sb-wrapper', '#storybook-docs', '#storybook-highlights-root'],
};

.sb-wrapper、Docs 容器与高亮根节点都属于 Storybook 框架自身 DOM,不检查它们可以避免框架层面的误报。用户传入的 context.exclude追加到这个默认列表,而 context.include 会直接替换默认 include——模板示例 stories 里的 SingleNodeContext / MultipleNodeContext / IncludeAndExcludeContext 正好演示了这三种写法:

// 单选择器
a11y: { context: '#custom-target' }

// 多选择器
a11y: { context: ['#first-custom-target', '#second-custom-target'] }

// include + exclude
a11y: {
  context: {
    include: ['#parent-node'],
    exclude: ['#second-custom-target'],
  },
}

IncludeAndExcludeFromShadowDOMContext 还演示了 Shadow DOM 场景下 fromShadowDom 的 include/exclude 用法,并在 play 函数中动态 attachShadow 注入测试内容。

3. 默认禁用 region 规则。

const DISABLED_RULES = [
  // In component testing, landmarks are not always present
  // and the rule check can cause false positives
  'region',
] as const;

组件级测试中通常没有完整的页面 landmark 结构,region 规则(要求所有内容被 landmark 包裹)会产生大量误报,所以插件在 axe.configure 前统一将其禁用,且用户可通过 config.rules 显式重新启用(规则按顺序应用,后者覆盖前者)。

4. runOnlyrules 的合并修正。 mergeDisabledRulesIntoRunOptions 处理一个 axe-core 的边界情况:axe.run({ runOnly }) 可能重新启用被 config.rules 禁用的带 tag 规则,因此当存在 runOnly 时,插件会把 config.rules 中的禁用项镜像进 options.rules(且用户自己的 options.rules 优先级最高),并且不修改用户传入的原始对象。

5. 结果回传与 CSP 兼容。 MANUAL 事件处理先 await waitForAnimations(),再对结果做一次 JSON.parse(JSON.stringify(result))——源码注释解释:axe 结果中包含类实例,直接经 telejson 反序列化会违反 Manager 侧 CSP(script-src),所以用深拷贝"抹平"类实例。

4.3 linkPath:每个违规节点都能一键定位

a11yRunnerUtils.tswithLinkPaths 会为 passes / incomplete / violations 中的每个 node 生成:

const id = `${key}.${result.id}.${index + 1}`;
const linkPath = `${pathname}?path=/story/${storyId}&addonPanel=${PANEL_ID}&a11ySelection=${id}`;

即形如 ?path=/story/button--primary&addonPanel=storybook/a11y/panel&a11ySelection=violations.color-contrast.1 的深链——这正是"测试报告中的某条违规"能直接跳转到面板并高亮对应 DOM 节点的机制,也让 a11y 报告可以被外部工具(如 CI 汇总页)以 URL 形式引用。

五、测试语义:'todo' 与 'error' 在两种运行环境下的行为

test 参数同时影响 UI 报告状态与测试运行结果:

  • Storybook UI 环境:afterEach 通过 reporting.addReport 上报 status('todo'warning,'error'failed),面板与状态徽标据此显示;
  • Vitest 独立运行环境(portable stories 场景):preview.tsx 会检测 getIsVitestStandaloneRun(),当存在违规且 mode 为 failed 时,动态加载 vitest-axe/matcherstoHaveNoViolations 并执行 expect(result).toHaveNoViolations() 使测试真正失败;运行出错时则直接 throw e 让 vitest 捕获。源码注释将其标注为过渡方案(todo:未来统一由 portable stories 层级处理异常)。

这套双环境语义意味着:把 story 的 a11y 检查接入 test('error') 后,同一条 story 文件既能被 Storybook UI 消费,也能在纯 vitest 下作为失败门禁运行。

六、规则映射:从 axe 规则 ID 到"人话"

面板不只展示 axe-core 原始输出。AccessibilityRuleMaps.ts 维护了一份大型映射表 combinedRulesMap,将每条 axe 规则 ID 映射为三元组:

export type AxeRuleMap = {
  [axeId: string]: {
    title: string;        // 规则简名
    axeSummary: string;   // axe-core 官方摘要
    friendlySummary: string; // 面向使用者的修复建议
  };
};

映射按合规来源分层组织:

分表 覆盖范围 代表规则
axeRuleMapping_wcag_2_0_a_aa WCAG 2.0 A/AA color-contrastimage-altlink-namehtml-has-langbutton-name
axeRuleMapping_wcag_2_1_a_aa WCAG 2.1 A/AA autocomplete-validavoid-inline-spacing
axeRuleMapping_wcag_2_2_a_aa WCAG 2.2 A/AA target-size(触控目标尺寸)
axeRuleMapping_wcag_2_x_aaa WCAG AAA(默认不跑) color-contrast-enhancedidentical-links-same-purpose
axeRuleMapping_best_practices 行业最佳实践 heading-orderskip-linktabindexregionempty-heading
axeRuleMapping_experimental 实验性(默认禁用) p-as-headinghidden-content
axeRuleMapping_deprecated 已弃用 aria-roledescription

这种分层解释了面板中"每条违规同时显示技术描述与修复建议"的体验:axeSummary 回答"违反了什么规范",friendlySummary 回答"我应该怎么改"。

七、可选进阶配置示例

以下示例全部来自仓库内的官方模板与文档片段,可直接参考。

7.1 调整运行选项:关闭单项 check 或启用 AAA 规则集

模板示例 中的 Options story 展示如何在 story 级禁用颜色对比检查:

export const Options = {
  args: {
    content: '<button style="color: rgb(255, 255, 255); background-color: rgb(76, 175, 80);">Click me!</button>',
  },
  parameters: {
    a11y: {
      config: {},
      options: {
        checks: {
          'color-contrast': { enabled: false },
        },
      },
    },
  },
};

Config story 则展示用 config.rules + disableOtherRules 只跑单条规则(如 avoid-inline-spacing)。

在 preview 全局启用 WCAG AAA 规则,参考 文档片段(注意必须显式重列默认 tag,再追加 wcag2aaa):

const preview: Preview = {
  parameters: {
    a11y: {
      options: {
        runOnly: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice', 'wcag2aaa'],
      },
    },
  },
};

7.2 关闭检查的两种方式

// 方式一:story/meta 级
export const Disabled = {
  parameters: { a11y: { disable: true } },
};

// 方式二:story 级把违规降级/关闭
parameters: { a11y: { test: 'off' } }   // 或 'todo' / 'error'

7.3 手动模式

globals 中设置 a11y: { manual: true } 后,访问 story 不再自动执行检查,但仍可在 Accessibility 面板中手动触发(EVENTS.MANUAL 通道即为此设计,触发前会 waitForAnimations() 等待动画稳定)。

八、视觉模拟:色觉障碍模拟面板

除了"查违规",该插件还内置一个辅助理解问题的工具:色觉模拟。withVisionSimulator.ts 作为 preview 端装饰器工作:

  • 读取 globals.vision(初始值 undefined,即不应用任何滤镜);
  • visionSimulatorFilters.ts 中定义的 CSS filter 应用到 document.body,并与既有 filter 字符串做正则替换、拼接/还原,避免覆盖用户自定义 filter;
  • MutationObserver 监听 body class 变化(Storybook 切换渲染区时会改 class),保证滤镜状态在 story 切换后仍正确;
  • 滤镜的 SVG 定义以 filterDefs 形式注入 body,卸载时清理。

Manager 侧的 VisionSimulator 工具组件(见 components/VisionSimulator.tsx)提供切换入口。典型用途:当 color-contrast 违规报告出现时,用模拟视图直观感受"对比度不足对用户意味着什么"。

九、如何读面板与结果

结合 manager.tsxtypes.ts 可以确认面板的信息组织:

  • 标题 Badge 计数 = violations.length + incomplete.length(违规 + 不确定项);
  • 结果分为三类 tab,对应 RuleType 常量:violations / incomplete / passes,默认停留在 violations;
  • 面板状态机 Status 覆盖 initial | manual | running | error | component-test-error | ran | ready,因此"检查中/失败/完成"都有明确 UI 状态,不会静默;
  • 每条违规节点携带第四节所述的 linkPath,可一键深链;
  • 跨环境结果不一致时的处理思路,插件在 constants.ts 中内置了对官方文档锚点 why-are-my-tests-failing-in-different-environments 的跳转链接(DOCUMENTATION_DISCREPANCY_LINK),对应仓库内 写作测试文档

十、关键文件索引

关注点 文件
插件说明与安装命令 README.md
参数类型定义 params.ts / types.ts
自动检查钩子(afterEach) preview.tsx
axe-core 执行器与队列 a11yRunner.ts
结果深链增强 a11yRunnerUtils.ts
面板与工具注册 manager.tsx
规则友好文案映射 AccessibilityRuleMaps.ts
安装期迁移脚本 postinstall.ts
官方参数示例 stories template/stories/parameters.stories.ts / tests.stories.ts
单元测试(执行器行为验证) a11yRunner.test.ts / preview.test.tsx
官方 a11y 参数文档片段 docs/_snippets/ 下的 addon-a11y-* 系列

适用前提说明:本文结论基于当前仓库(a11y 包版本 10.6.0-beta.1)的源码状态;axe-core 依赖声明为 ^4.2.0,具体规则集行为以所安装 axe-core 版本为准。React Native 框架下该 addon 不可用,这是 package.json 中显式声明的限制而非偶然缺失。

登录后查看全文
热门项目推荐
相关项目推荐