首页
/ Storybook 无障碍测试渐进式工作流的收尾:移除 `a11y: { test: 'todo' }` 参数

Storybook 无障碍测试渐进式工作流的收尾:移除 `a11y: { test: 'todo' }` 参数

2026-09-06 18:46:22作者:秋泉律Samson

本文讲解 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.tsxafterEach 钩子承担:每次渲染故事后,若满足运行条件(非 ghost 故事、disable !== truetest !== '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 步,整套流程如下:

  1. 项目级开启硬性校验:在 .storybook/preview.ts|tsx 中设置 a11y: { test: 'error' },保证所有新故事默认都必须通过无障碍测试;
  2. 运行后你大概率会发现不少组件存在违规(这是正常的);
  3. 记录有问题的组件,并在这些组件的 meta 中临时加上 a11y: { test: 'todo' },把它们的失败暂时降级为警告,同时提交一个基线(见 addon-a11y-parameter-todo-in-meta.md);
  4. 挑一个起点组件(如 Button——它是被广泛复用的基础组件)按 addon 面板中的建议修复违规,确认通过后移除 'todo' 参数(即本篇文章所依据的 addon-a11y-parameter-remove.md);
  5. 重复第 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' 参数的组件在移除后会重新受到严格校验,可以沿着调用链核对:

  1. 运行条件判定preview.tsx):afterEach 中,只要 a11yParameter?.test !== 'off' 且没有 disable、没有 globals.a11y.manual = true,就会对当前 story 执行 axe 扫描。'todo''error' 都会运行测试,区别只在结果呈现;
  2. 状态映射getMode()'todo' 映射为 'warning''error'(及默认分支)映射为 'failed'。移除 'todo' 后,若项目级 preview 已设 'error'(工作流第 1 步),组件即回归 'failed' 语义;
  3. 独立运行时的强制失败(同一文件的 afterEach 后半段):当 Vitest 脱离 Storybook UI 独立运行时,若 getMode() === 'failed' 且有违规,会通过 vitest-axeexpect(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' 做一次「清扫确认」的好时机。

注意事项与常见误区

  1. 'todo' 不是 'off':它仍会运行测试并产生警告输出,不应长期留在代码里;它是「已记录、待修复」的显式标记,修复后应删除(见上面的 Callout 解释)。
  2. 删对层级:先确认当初是在项目级、meta 还是 story 级设置的。本文展示的是 meta(组件级);若在 story 级设置,删除范围更小。
  3. 不要把「移除 todo」误写成「设为 off」'off' 会直接跳过对该组件的自动测试,与工作流目标(全量覆盖、持续改进)相悖。
  4. 异步渲染组件可能产生假阴性:文档 FAQ 提到 React 的 Suspense/RSC 等异步组件可能在未完成渲染时就被扫描,导致面板不显示本应存在的违规。这是渲染时机问题,与是否移除参数无关;相关环境下可通过 feature flag(developmentModeForBuild)配合处理,详见 accessibility-testing.mdx 的 FAQ 小节

相关资源

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