Storybook 无障碍测试渐进式工作流的收尾:移除 `a11y: { test: 'todo' }` 参数
本文讲解 Storybook Accessibility(a11y)测试中的一处关键收尾动作:当某个组件已经通过无障碍检查后,如何将其 meta 中临时用于降级为警告的 a11y: { test: 'todo' } 参数移除(或注释掉)。该动作对应 accessibility-testing.mdx 中「Recommended workflow」的第 4 步,是「先用 'error' 卡住违规 → 用 'todo' 标记待修组件 → 修复后移除标记」这一渐进式落地方案的必要闭环。读完本文将掌握该参数的语义、它在各框架/各 CSF 写法中的删除方式,以及移除后底层源码是如何重新把无障碍违规变为失败信号的。
前置知识:parameters.a11y.test 的三种取值
Accessibility addon 构建于 Deque 的 axe-core 之上,会基于 WCAG 规则对渲染后的 DOM 进行自动审计。而「审计结果如何影响测试」则由 parameters.a11y.test 这一参数控制,它只接受三个取值,见 accessibility-testing.mdx 的 Test behavior 小节:
| 取值 | 含义 | 后果 |
|---|---|---|
'off' |
不运行无障碍测试(仍可在 addon 面板手动检查) | 既不警告也不失败 |
'todo' |
运行无障碍测试;发现违规时在 Storybook UI 中返回 warning | 不阻塞开发,但保持可见 |
'error' |
运行无障碍测试;发现违规时返回 failing test(Storybook UI 与 CLI/CI 均失败) | 阻塞合并/发布 |
三个取值在源码中以联合类型定义:
- params.ts 中:
type A11yTest = 'off' | 'todo' | 'error';,而A11yParameters接口里的test?: A11yTest即对应文档表格中的这一字段。
文档特别强调:为什么叫 'todo' 而不是 'warn'?因为它被设计为代码库中一个字面意义的 TODO——标记「我已知道有 a11y 问题、但暂不修复」的故事;而 'off' 只应留给那些根本不需要测试无障碍的场景(例如故意演示反模式的故事)。如果你不想测试某个组件,更推荐的是禁用特定规则,而不是关闭整个测试。
在真实的 addon 实现里,该行为由 preview.tsx 的 afterEach 钩子承担:每次渲染故事后,若满足运行条件(非 ghost 故事、disable !== true、test !== 'off'、globals.a11y.manual !== true)就调用 run() 执行 axe 扫描,再依据违规与否映射报告状态。映射逻辑就是下面这段 getMode():
// code/addons/a11y/src/preview.tsx
const getMode = (): (typeof reporting)['reports'][0]['status'] => {
switch (a11yParameter?.test) {
case 'todo':
return 'warning';
case 'error':
default:
return 'failed';
}
};
也就是说:有违规时,'todo' → warning,'error' → failed;没有违规时一律为 passed。这一映射在 preview.test.tsx 中也有对应断言(test: 'todo' 时上报 status: 'warning')。
本片段在整个渐进式工作流中的位置
「移除 'todo' 参数」是文档推荐的渐进式工作流中的第 4 步,整套流程如下:
- 项目级开启硬性校验:在 .storybook/preview.ts|tsx 中设置
a11y: { test: 'error' },保证所有新故事默认都必须通过无障碍测试; - 运行后你大概率会发现不少组件存在违规(这是正常的);
- 记录有问题的组件,并在这些组件的 meta 中临时加上
a11y: { test: 'todo' },把它们的失败暂时降级为警告,同时提交一个基线(见 addon-a11y-parameter-todo-in-meta.md); - 挑一个起点组件(如 Button——它是被广泛复用的基础组件)按 addon 面板中的建议修复违规,确认通过后移除
'todo'参数(即本篇文章所依据的 addon-a11y-parameter-remove.md); - 重复第 4 步,直到所有组件覆盖完毕。
为什么第 4 步的「移除」如此重要?因为 parameters 存在作用域合并机制:它可以在 .storybook/preview.*(项目级)、story 文件的 meta / default export(组件级)或单个 story(故事级)定义,且越具体的级别优先级越高。只要组件 meta 里还残留 test: 'todo',它就会覆盖你在第 1 步设置的项目级 'error',让该组件的违规始终停留在「警告」而非「失败」;只有移除它,组件策略才会回落,重新被 'error' 或更高层策略约束,从而真正保证「零无障碍违规」不被悄悄回归。
实操:从 meta 中移除(或注释掉)a11y: { test: 'todo' }
被移除的代码块位于 story 文件的 meta 定义中,它长这样(以 CSF 3 + TypeScript 为例):
// Button.stories.ts (CSF 3)
import type { Meta } from '@storybook/react-vite';
import { Button } from './Button';
const meta = {
component: Button,
parameters: {
// 👇 Remove this once all stories pass accessibility tests
// a11y: { test: 'todo' },
},
} satisfies Meta<typeof Button>;
export default meta;
addon-a11y-parameter-remove.md 本身正是以「注释掉」的方式呈现这段配置——注意注释文案 Remove this once all stories pass accessibility tests 是一句给开发者自己看的 TODO 提示。实际操作时可以有两种程度:
- 注释而非删除:当文件里还有多个 story 未完全修复、你希望稍后重新启用标记时,先注释保留;
- 彻底删除:当该文件所有 story 均已通过无障碍测试后,连注释一起删掉最干净,也避免后续读者困惑。
另外注意,注释掉整行后,parameters: { } 空对象可以一并简化,只保留 component 字段即可——空 parameters 没有语义,纯粹是移除时的残留。
CSF 3(经典写法)在各框架中的形态
绝大多数渲染器使用完全相同的结构,只有 import 来源不同。以下按文件类型逐一给出(与片段一一对应)。
CSF 3 · TypeScript(React 等 @storybook/your-framework 家族)
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc.
import { Meta } from '@storybook/your-framework';
import { Button } from './Button';
const meta = {
component: Button,
parameters: {
// 👇 Remove this once all stories pass accessibility tests
// a11y: { test: 'todo' },
},
} satisfies Meta<typeof Button>;
export default meta;
CSF 3 · JavaScript(通用)
// Button.stories.js
import { Button } from './Button';
export default {
component: Button,
parameters: {
// 👇 Remove this once all stories pass accessibility tests
// a11y: { test: 'todo' },
},
};
Angular · CSF 3
// Button.stories.ts
import { Meta } from '@storybook/angular';
import { Button } from './button.component';
const meta: Meta<Button> = {
component: Button,
parameters: {
// 👇 Remove this once all stories pass accessibility tests
// a11y: { test: 'todo' },
},
};
export default meta;
Svelte · CSF 3(TypeScript / JavaScript)
// Replace your-framework with the framework you are using, e.g. sveltekit or svelte-vite
import type { Meta } from '@storybook/your-framework';
import Button from './Button.svelte';
const meta = {
component: Button,
parameters: {
// 👇 Remove this once all stories pass accessibility tests
// a11y: { test: 'todo' },
},
} satisfies Meta<typeof Button>;
export default meta;
// Button.stories.js
import Button from './Button.svelte';
export default {
component: Button,
parameters: {
// 👇 Remove this once all stories pass accessibility tests
// a11y: { test: 'todo' },
},
};
Web Components · CSF 3(TypeScript / JavaScript):注意这里 component 是自定义元素标签名(字符串),其余完全一致。
// Button.stories.ts
import { Meta } from '@storybook/web-components-vite';
const meta: Meta = {
component: 'demo-button',
parameters: {
// 👇 Remove this once all stories pass accessibility tests
// a11y: { test: 'todo' },
},
};
export default meta;
Svelte CSF(defineMeta 写法)
使用 @storybook/addon-svelte-csf 时,参数写在 defineMeta 里,移除动作一致:
<!-- Button.stories.svelte -->
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import Button from './Button.svelte';
const { Story } = defineMeta({
component: Button,
parameters: {
// 👇 Remove this once all stories pass accessibility tests
// a11y: { test: 'todo' },
},
});
</script>
CSF Next(preview.meta 实验性写法)
如果你使用实验性的 CSF Next,meta 由 preview.meta(...) 构造(preview 从 ../.storybook/preview 导入),parameters 的移除位置不变:
// Button.stories.ts (CSF Next)
import preview from '../.storybook/preview';
import { Button } from './Button';
const meta = preview.meta({
component: Button,
parameters: {
// 👇 Remove this once all stories pass accessibility tests
// a11y: { test: 'todo' },
},
});
该写法适用于 React、Vue 3、Angular、Web Components 等(区别仅在于 component 的值是组件对象还是标签字符串,如 Vue 中为 Button,Web Components 中为 'demo-button'),因此移除逻辑完全相同。注意:无论哪种框架,这段代码都写在 组件级 meta;如果当初你是在某个具体 story 上(而不是整个 meta)加的 'todo',则应在对应 story 对象中移除,切勿删错层级。
移除后发生了什么:源码视角的验证
为了确信托管 'todo' 参数的组件在移除后会重新受到严格校验,可以沿着调用链核对:
- 运行条件判定(preview.tsx):
afterEach中,只要a11yParameter?.test !== 'off'且没有disable、没有globals.a11y.manual = true,就会对当前 story 执行 axe 扫描。'todo'与'error'都会运行测试,区别只在结果呈现; - 状态映射:
getMode()把'todo'映射为'warning'、'error'(及默认分支)映射为'failed'。移除'todo'后,若项目级 preview 已设'error'(工作流第 1 步),组件即回归'failed'语义; - 独立运行时的强制失败(同一文件的
afterEach后半段):当 Vitest 脱离 Storybook UI 独立运行时,若getMode() === 'failed'且有违规,会通过vitest-axe的expect(result).toHaveNoViolations()直接抛错,使 CI 构建失败。这就是「移除'todo'后 CI 会拦住违规」的机制来源。
补充一个文档表格与源码并存的细节:官方参数表格中 parameters.a11y.test 的默认值写作 undefined,而在 addon 的预览注解中实际导出了 parameters = { a11y: { test: 'todo' } }(同样见 preview.tsx 文件末尾)。这正是「开箱即用默认以警告呈现、不阻塞开发」的设计初衷;也正因如此,仅当你在项目级显式升级为 'error'、并按工作流逐步清空 'todo' 后,才会形成真正的硬性门禁。
另外,axe 的实际执行在 a11yRunner.ts 中完成:它会排除 Storybook 自身的内嵌 UI(.sb-wrapper、#storybook-docs、#storybook-highlights-root 等),默认禁用不适合组件级检测的 region 规则以减少误报,并通过队列串行执行 axe(axe-core 不支持并行)。这些与你移除的参数无关,不会影响「移除后重新严格化」的结论。
如何验证移除是否生效
- Storybook UI(Vitest addon):展开侧边栏的测试组件,勾选 Accessibility 后运行组件测试。若项目级为
'error',修复完成的组件应显示「通过」;一旦人为引入违规,故事旁的测试状态指示器会变为失败,点击可进入 Accessibility 面板查看详情; - CI:按 accessibility-testing.mdx 的说明,CI 中只有
test: 'error'的故事会产生失败输出;若残留'todo',CI 不会报错(仅在本地 UI 中以警告显示)。因此确认全仓库不存在残留的test: 'todo',是 CI 门禁真正生效的前提——这也是用search全局搜索test: 'todo'做一次「清扫确认」的好时机。
注意事项与常见误区
'todo'不是'off':它仍会运行测试并产生警告输出,不应长期留在代码里;它是「已记录、待修复」的显式标记,修复后应删除(见上面的 Callout 解释)。- 删对层级:先确认当初是在项目级、meta 还是 story 级设置的。本文展示的是 meta(组件级);若在 story 级设置,删除范围更小。
- 不要把「移除 todo」误写成「设为 off」:
'off'会直接跳过对该组件的自动测试,与工作流目标(全量覆盖、持续改进)相悖。 - 异步渲染组件可能产生假阴性:文档 FAQ 提到 React 的 Suspense/RSC 等异步组件可能在未完成渲染时就被扫描,导致面板不显示本应存在的违规。这是渲染时机问题,与是否移除参数无关;相关环境下可通过 feature flag(
developmentModeForBuild)配合处理,详见 accessibility-testing.mdx 的 FAQ 小节。
相关资源
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00