React 基础体系 · 第 53/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React Storybook:Story、交互测试、文档、主题和评审

Storybook 是一个在应用外部独立开发、验证和展示 UI 组件的工作台。它把组件渲染到一个隔离的预览环境中,并允许开发者为同一个组件声明多组可复现状态,再利用这些状态进行交互测试、文档生成、主题切换和代码评审。

这里的核心对象不是“一个组件页面”,而是一个 Story:组件在特定输入、上下文和初始状态下的可复现渲染场景。

例如,按钮至少可能有以下状态:

  • 默认状态;
  • 禁用状态;
  • 加载状态;
  • 不同视觉主题;
  • 点击后显示反馈;
  • 网络请求失败后的错误状态。

如果这些状态只存在于人工操作步骤中,评审者需要自己猜测如何复现;如果它们被写成 Story,Storybook 就可以把同一组状态同时用于开发、文档、交互测试和视觉评审。


一、Story 到底是什么

1.1 Story 是组件状态的声明

一个 Story 通常由以下信息组成:

S=(C,P,G,A,R)S = (C, P, G, A, R)

其中:

  • CC:要渲染的组件;
  • PP:传给组件的输入,例如 args
  • GG:全局或局部上下文,例如主题、路由、国际化环境;
  • AA:交互动作,例如点击、输入、提交;
  • RR:对交互结果的断言。

Story 的目标是让同一个场景满足:

相同输入+相同上下文可重复的初始 UI\text{相同输入} + \text{相同上下文} \Rightarrow \text{可重复的初始 UI}

如果一个 Story 依赖开发者之前在页面上做过某些操作,或者依赖真实后端返回某个随机结果,它就不再是可靠的可复现场景。

Story 不是测试函数,也不是组件本身:

  • 组件定义行为和渲染;
  • Story 提供一个具体使用场景;
  • 交互测试验证用户操作后的结果;
  • 文档解释组件如何被使用;
  • 评审过程判断这些场景是否符合产品和设计要求。

1.2 一个完整的 React 示例

下面定义一个使用受控状态的按钮。它有 idleloadingsuccess 三种状态:

// src/components/SaveButton.tsx
import { useState } from 'react';

export type SaveButtonProps = {
  onSave?: () => Promise<void>;
  disabled?: boolean;
};

export function SaveButton({
  onSave = async () => {},
  disabled = false,
}: SaveButtonProps) {
  const [status, setStatus] = useState<'idle' | 'loading' | 'success'>('idle');

  async function handleClick() {
    if (disabled || status === 'loading') {
      return;
    }

    setStatus('loading');

    try {
      await onSave();
      setStatus('success');
    } catch {
      setStatus('idle');
    }
  }

  const label =
    status === 'loading'
      ? '保存中…'
      : status === 'success'
        ? '已保存'
        : '保存';

  return (
    <button
      type="button"
      disabled={disabled || status === 'loading'}
      aria-busy={status === 'loading'}
      onClick={handleClick}
    >
      {label}
    </button>
  );
}

这个组件有一个明确的状态转换:

idle
  └─ 点击且未禁用
       ↓
    loading
      ├─ onSave 成功 → success
      └─ onSave 失败 → idle

Story 需要描述初始输入,而不是把所有状态都塞进组件属性。例如,loading 并不是这个实现暴露的 prop,因此不能直接写成 args: { status: 'loading' }。对于内部状态,应通过 play 先执行用户动作,或者为组件增加适合业务的受控 API。

1.3 CSF 文件

Storybook 常用的文件格式是 CSF(Component Story Format)。它本质上是包含元数据和导出对象的 TypeScript/JavaScript 模块。

// src/components/SaveButton.stories.tsx
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within } from '@storybook/test';
import { SaveButton } from './SaveButton';

const meta = {
  title: 'Components/SaveButton',
  component: SaveButton,
  parameters: {
    layout: 'centered',
  },
  args: {
    onSave: fn(),
  },
} satisfies Meta<typeof SaveButton>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const Disabled: Story = {
  args: {
    disabled: true,
  },
};

export const SavesSuccessfully: Story = {
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole('button', { name: '保存' });

    await userEvent.click(button);

    expect(args.onSave).toHaveBeenCalledTimes(1);
    await expect(
      canvas.findByRole('button', { name: '已保存' }),
    ).resolves.toBeVisible();
  },
};

export const SavesAndFails: Story = {
  args: {
    onSave: fn(async () => {
      throw new Error('模拟保存失败');
    }),
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole('button', { name: '保存' });

    await userEvent.click(button);

    await expect(
      canvas.findByRole('button', { name: '保存' }),
    ).resolves.toBeVisible();
  },
};

这里的关键部分有以下含义:

  • Meta<typeof SaveButton> 让元数据与组件类型关联;
  • satisfies 检查对象结构,同时尽量保留 TypeScript 的具体类型信息;
  • StoryObj<typeof meta> 让每个 Story 的 args 与元数据保持一致;
  • args 是 Story 的可配置输入,通常用于 props;
  • fn() 创建可观察的模拟函数,可以检查它是否被调用;
  • play 在 Story 渲染完成后执行交互;
  • within(canvasElement) 把查询范围限制在当前 Story 的画布内;
  • getByRole 优先按照用户可感知的语义查询元素;
  • findByRole 用于等待异步状态变化。

@storybook/react-vite 是 React + Vite 项目的常见框架包;如果项目使用 Next.js 等框架,应使用对应的 Storybook framework 包。具体包名和生成配置应以当前 Storybook 初始化结果为准,不应把不同框架的配置混用。


二、从零运行一个 Storybook

对于已有 React 项目,可以在项目根目录执行:

npx storybook@latest init

初始化工具会根据项目识别框架,并生成:

  • Storybook 配置目录;
  • 示例 Story;
  • 运行脚本;
  • 与当前框架匹配的依赖。

典型启动命令是:

npm run storybook

常见结果是启动一个本地开发服务器,例如:

storybook dev -p 6006

然后访问:

http://localhost:6006

生产构建通常使用:

npm run build-storybook

它会生成静态 Storybook。这个静态站点可以部署到静态托管服务中,用于设计评审、组件库预览或版本归档。

需要区分两个运行环境:

  1. Storybook 管理界面:显示导航、Controls、Actions 等工具;
  2. Preview iframe:真正渲染组件并执行 Story 的环境。

组件通常运行在 preview iframe 中。某个 Story 的 CSS、全局 Provider 或运行时异常,可能只影响该 iframe,而不一定导致管理界面整体崩溃。


三、Args、参数和装饰器:Story 的输入边界

3.1 args 适合表达组件输入

假设组件有以下 API:

type AlertProps = {
  title: string;
  description?: string;
  severity: 'info' | 'warning' | 'error';
  onClose?: () => void;
};

可以通过 args 描述输入:

export const Warning: Story = {
  args: {
    title: '即将过期',
    description: '当前会话将在 5 分钟后过期。',
    severity: 'warning',
    onClose: fn(),
  },
};

Controls 面板可以根据这些 args 生成可编辑控件。这样,文档读者不需要修改源码就能观察不同输入对 UI 的影响。

args 不应被用来模拟任意内部实现细节。例如,不能仅仅为了显示“保存成功”就添加一个组件没有定义的 success: true 参数。这样做会让 Story 与真实组件 API 脱节,文档也会误导使用者。

3.2 parameters 是 Storybook 行为配置

parameters 通常不是传给组件的 props,而是控制 Storybook 如何处理该 Story:

export const FullWidth: Story = {
  parameters: {
    layout: 'fullscreen',
  },
};

常见用途包括:

  • 调整画布布局;
  • 配置文档展示;
  • 覆盖 Mock Service Worker 的请求处理器;
  • 配置可访问性检查;
  • 提供视觉测试的视口或背景设置。

因此需要区分:

args: {
  disabled: true, // 传给 SaveButton
},
parameters: {
  layout: 'centered', // 给 Storybook
},

3.3 Decorator 是包裹 Story 的运行时上下文

Decorator 可以理解为:

RenderedStory=Dn(D2(D1(Story)))\text{RenderedStory} = D_n(\dots D_2(D_1(\text{Story})) \dots)

它适合提供:

  • React Context;
  • 路由;
  • 主题;
  • 国际化;
  • 测试用的状态容器;
  • 布局容器。

例如,组件依赖一个应用主题上下文:

// src/theme/ThemeProvider.tsx
import { createContext, useContext, type ReactNode } from 'react';

export type Theme = 'light' | 'dark';

const ThemeContext = createContext<Theme>('light');

export function ThemeProvider({
  theme,
  children,
}: {
  theme: Theme;
  children: ReactNode;
}) {
  return (
    <ThemeContext.Provider value={theme}>
      <div data-theme={theme}>{children}</div>
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  return useContext(ThemeContext);
}

.storybook/preview.tsx 中提供全局 Decorator:

// .storybook/preview.tsx
import type { Preview } from '@storybook/react';
import { ThemeProvider } from '../src/theme/ThemeProvider';

const preview: Preview = {
  decorators: [
    (Story, context) => {
      const theme =
        (context.globals.theme as 'light' | 'dark' | undefined) ?? 'light';

      return (
        <ThemeProvider theme={theme}>
          <Story />
        </ThemeProvider>
      );
    },
  ],
  globalTypes: {
    theme: {
      description: '全局主题',
      toolbar: {
        icon: 'paintbrush',
        items: ['light', 'dark'],
      },
    },
  },
  initialGlobals: {
    theme: 'light',
  },
};

export default preview;

这里的数据流是:

工具栏选择 theme
      ↓
context.globals.theme
      ↓
全局 decorator
      ↓
ThemeProvider
      ↓
组件通过 Context 或 CSS 属性读取主题

globalsargs 不同:

  • args 通常描述某个 Story 的组件输入;
  • globals 描述多个 Story 共享的环境;
  • decorator 将这些环境转换为 React 运行时上下文。

如果只需要为单个 Story 提供上下文,也可以使用局部 Decorator:

export const InDialog: Story = {
  decorators: [
    (Story) => (
      <div role="dialog" aria-label="保存设置">
        <Story />
      </div>
    ),
  ],
};

四、交互测试:从静态状态到用户行为

4.1 交互测试验证状态转换

静态 Story 只能验证初始状态。交互测试还要验证:

初始状态用户操作结果状态\text{初始状态} \xrightarrow{\text{用户操作}} \text{结果状态}

例如保存按钮的交互路径是:

  1. 初始显示“保存”;
  2. 用户点击按钮;
  3. 调用 onSave
  4. onSave 返回 Promise;
  5. 成功时显示“已保存”;
  6. 失败时恢复为“保存”。

play 函数运行在 Story 渲染后,因此可以对这条路径进行验证。它不是模拟浏览器点击事件的低级工具,而是通过 Testing Library 查询和 userEvent 尽量模拟用户行为。

export const SavesSuccessfully: Story = {
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);

    await userEvent.click(
      canvas.getByRole('button', { name: '保存' }),
    );

    expect(args.onSave).toHaveBeenCalledTimes(1);

    await expect(
      canvas.findByRole('button', { name: '已保存' }),
    ).resolves.toBeVisible();
  },
};

每一步都有不同目的:

  • getByRole 查找初始同步元素;找不到时立即失败;
  • userEvent.click 执行用户点击;
  • toHaveBeenCalledTimes(1) 验证副作用;
  • findByRole 等待 React 状态更新完成;
  • toBeVisible 验证用户可见结果。

不要用固定的 setTimeout 等待 UI:

await new Promise((resolve) => setTimeout(resolve, 500));

这会把测试绑定到时间猜测上。机器负载变化、网络 Mock 或 React 调度变化都可能让测试产生偶发失败。应优先使用 Testing Library 的等待查询或显式的 Promise 条件。

4.2 交互测试的失败路径

失败路径同样需要被建模。上面的 SavesAndFails Story 中,onSave 抛出异常,组件捕获异常并恢复为可再次保存的状态。

如果组件没有捕获异常,Promise rejection 可能表现为:

  • 浏览器控制台出现未处理 rejection;
  • Story 的 play 测试失败;
  • 用户仍然看到“保存中…”;
  • 按钮长期处于禁用状态。

这些现象说明组件的状态机缺少错误边界,而不是 Storybook 本身的问题。

一个更真实的请求测试可以使用 Mock Service Worker。Story 为请求提供处理器:

import { http, HttpResponse } from 'msw';

export const RequestFails: Story = {
  parameters: {
    msw: {
      handlers: [
        http.post('/api/save', async () => {
          return HttpResponse.json(
            { message: '保存失败' },
            { status: 500 },
          );
        }),
      ],
    },
  },
};

前置条件是项目已经安装并配置了 msw 以及 Storybook 对应的 MSW 集成。Storybook 中的请求应该默认指向稳定的 Mock,而不是依赖开发环境后端。真实后端可能带来登录状态、数据漂移、网络延迟和副作用,使 Story 无法稳定复现。

4.3 查询方式的语义边界

常见查询的失败条件不同:

查询 适用情况 元素不存在时
getByRole 元素应立即存在 立即抛错
queryByRole 验证元素不存在 返回 null
findByRole 元素异步出现 在等待超时后抛错

例如验证加载状态消失:

expect(
  canvas.queryByRole('button', { name: '保存中…' }),
).toBeNull();

而验证异步成功状态:

await expect(
  canvas.findByRole('button', { name: '已保存' }),
).resolves.toBeVisible();

优先使用 role、可访问名称、label 等用户可感知的查询。依赖 .button-primary 或组件内部 data-testid 的测试更容易在重构 CSS 或 DOM 结构时失效。

4.4 运行交互测试

Storybook 可以在浏览器中执行 play,也可以将 Story 作为测试用例接入项目的测试运行器。现代 Storybook 项目通常可结合 Vitest 集成;较早项目也可能使用 Storybook Test Runner。两者的配置和命令取决于 Storybook 主版本及项目构建器,因此应以项目生成的配置和当前版本官方文档为准。

无论使用哪种运行器,测试的逻辑仍然是:

加载 Story
  ↓
渲染组件和 decorator
  ↓
执行 play
  ↓
执行断言
  ↓
报告 Story 级别的成功或失败

如果只在浏览器中手动点击,测试没有进入持续集成;如果只把 play 当作演示脚本而没有断言,交互路径也不能在失败时提供可靠信号。


五、Story 文档:从示例代码到可读契约

5.1 Story 本身就是可执行示例

组件文档至少应回答四个问题:

  1. 组件解决什么问题;
  2. 它有哪些输入;
  3. 常见状态如何表现;
  4. 用户行为和异常状态如何处理。

Story 的价值在于示例不是静态截图,而是可以运行的代码。一个 Disabled Story 同时表达了:

  • disabled 是有效输入;
  • 禁用按钮不可点击;
  • 视觉和交互状态需要一起评审。

5.2 自动文档

可以为 Story 文件添加 autodocs 标签:

const meta = {
  title: 'Components/SaveButton',
  component: SaveButton,
  tags: ['autodocs'],
} satisfies Meta<typeof SaveButton>;

Storybook 会根据组件类型、Story、参数和标签生成文档页面。对于 TypeScript 组件,文档工具通常可以从 props 类型推导控件和 API 信息,但推导结果受到组件写法、构建器和类型声明方式影响。

例如下面的类型有利于生成清晰的 API 信息:

export type SaveButtonProps = {
  /** 保存操作。返回 rejected Promise 时组件恢复为可重试状态。 */
  onSave?: () => Promise<void>;

  /** 是否禁止用户触发保存。 */
  disabled?: boolean;
};

类型注释不会改变运行时行为,但可以改善文档中的 API 解释。它不能替代 Story:类型能说明 disabled 是布尔值,却不能说明禁用时按钮是否保留焦点、是否显示提示或是否阻止请求。

5.3 自定义 MDX 文档

对于需要解释设计背景、组合方式或边界条件的组件,可以使用 MDX:

<!-- src/components/SaveButton.mdx -->
import { Meta, Canvas, Controls, Stories } from '@storybook/blocks';
import * as SaveButtonStories from './SaveButton.stories';

<Meta of={SaveButtonStories} />

# SaveButton

用于提交一个可重复执行的保存操作。

## 基本用法

<Canvas of={SaveButtonStories.Default} />

## 可配置输入

<Controls of={SaveButtonStories.Default} />

## 交互状态

<Stories include={[SaveButtonStories.SavesSuccessfully, SaveButtonStories.SavesAndFails]} />

这里的 Meta of={SaveButtonStories} 将 MDX 文档关联到该 Story 文件。Canvas 展示可运行的组件示例,Controls 展示可调参数,Stories 展示多个场景。

文档中的代码示例应与实际 Story 保持一致。如果手写一段与组件实现不同的示例,文档就会成为第二套 API,最终必然与代码分叉。

5.4 文档的边界

自动文档不能自动推导所有业务语义。例如:

  • “失败后是否允许重试”通常需要人工解释;
  • “按钮在表单中是否触发提交”取决于 type 和宿主表单;
  • “主题颜色是否满足对比度要求”需要可访问性验证;
  • “点击后是否应跳转页面”需要路由上下文和业务场景。

因此文档应把类型推导、可运行 Story 和人工说明组合起来,而不是只依赖 Controls 面板。


六、主题:不是换颜色,而是改变运行时上下文

6.1 主题的两层含义

在组件系统中,“主题”通常包含两部分:

  1. 设计令牌:颜色、间距、字体、圆角、阴影等;
  2. 运行时选择:当前使用 light、dark 或品牌主题。

例如可以通过 CSS 自定义属性实现令牌:

/* src/theme/theme.css */
[data-theme='light'] {
  --color-button-bg: #2563eb;
  --color-button-text: #ffffff;
}

[data-theme='dark'] {
  --color-button-bg: #93c5fd;
  --color-button-text: #111827;
}

组件使用令牌,而不是直接绑定某个主题的颜色:

/* src/components/save-button.css */
button {
  background: var(--color-button-bg);
  color: var(--color-button-text);
}

主题切换的数据流是:

globals.theme
   ↓
ThemeProvider 或 data-theme
   ↓
CSS 变量 / React Context
   ↓
组件样式和行为

如果组件只在 Storybook 中通过全局 CSS 变色,却没有在真实应用中使用同样的主题 Provider 或属性,Storybook 展示的就不是应用真实环境。

6.2 使用主题插件与自定义 Decorator

Storybook 生态中存在 @storybook/addon-themes,它可以帮助在工具栏中切换主题类名、数据属性或主题 Provider。这个插件适合把主题选择和 Storybook 工具栏连接起来,但它不能替代应用自己的主题实现。

两种方式的区别是:

  • 如果主题只需要设置 classdata-theme,插件可以直接处理 DOM 属性;
  • 如果主题需要 React Context、运行时配置或多个 Provider,应使用自定义 Decorator;
  • 如果应用使用第三方主题库,应在 Storybook 中复用该库的 Provider,而不是另写一套近似实现。

主题 Story 应覆盖至少两个方向:

export const DarkTheme: Story = {
  globals: {
    theme: 'dark',
  },
};

如果主题是全局选择,Story 的 globals 可以表达默认主题;如果某个 Story 必须固定在特定主题中,则可以在 Story 上覆盖全局值。

主题问题的典型故障包括:

  • 组件使用了 CSS 变量,但 Storybook 没有加载主题 CSS;
  • data-theme 设置在错误的 DOM 层级;
  • Portal 渲染到 document.body,脱离了主题容器;
  • iframe 中加载了应用样式,但构建配置没有处理字体或静态资源;
  • 深色主题只修改背景,没有修改文本、边框和焦点样式。

其中 Portal 是常见边界:如果弹窗通过 createPortal 渲染到 body,而主题属性只挂在 Story 的局部容器上,弹窗可能读取不到主题变量。解决方式是把主题属性放到更高层级,或确保 Portal 根节点也继承主题上下文。


七、React 19 与客户端/服务端边界

7.1 Storybook 预览通常是客户端运行环境

Storybook 的 preview iframe 需要在浏览器中渲染组件并执行用户交互。因此以下代码可以直接用于客户端组件 Story:

'use client';

import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button type="button" onClick={() => setCount((value) => value + 1)}>
      次数:{count}
    </button>
  );
}

React 19 的 use、Actions、表单相关能力等具体行为仍然取决于 React 版本、框架集成和 Storybook 构建器。Storybook 不是 React 服务端运行时的替代品;它主要提供一个可交互的组件预览环境。

7.2 React Server Components 的限制

在 Next.js App Router 等环境中,服务端组件与客户端组件有明确边界:

  • 服务端组件可以访问服务端资源,但不能直接使用 useStateuseEffect 或浏览器事件;
  • 客户端组件可以处理交互,但需要通过 'use client' 建立客户端边界;
  • play 测试需要浏览器 DOM,因此测试对象必须是可在 preview 中渲染并交互的客户端部分。

例如,以下服务端组件不适合直接作为需要点击测试的 Story:

// ServerData.tsx
export async function ServerData() {
  const response = await fetch('https://example.com/data');
  const data = await response.json();

  return <p>{data.title}</p>;
}

问题不在于 Storybook 不能显示静态 JSX,而在于:

  • Storybook preview 需要处理异步服务端组件;
  • 请求可能依赖服务端凭据;
  • 组件可能依赖框架专有运行时;
  • play 无法像测试普通客户端组件那样控制服务端生命周期。

更稳妥的边界是把服务端取数与客户端展示分开:

// ServerDataContainer.tsx
import { DataView } from './DataView';

export async function ServerDataContainer() {
  const data = await loadDataOnServer();
  return <DataView title={data.title} />;
}
// DataView.tsx
'use client';

export function DataView({ title }: { title: string }) {
  return <p>{title}</p>;
}

Storybook 直接展示 DataView,通过 args 注入数据:

import type { Meta, StoryObj } from '@storybook/react-vite';
import { DataView } from './DataView';

const meta = {
  title: 'Components/DataView',
  component: DataView,
} satisfies Meta<typeof DataView>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Loaded: Story = {
  args: {
    title: '示例数据',
  },
};

这不是伪造服务端行为,而是把组件边界测试清楚:服务端容器负责加载数据,客户端组件负责呈现和交互。容器本身仍应通过集成测试或框架级测试验证。


八、组件评审:评审的对象是可复现证据

8.1 Story 如何进入评审流程

组件评审不是“打开 Storybook 看一下颜色”。一个可评审的变更通常包含:

  1. 组件代码;
  2. 代表主要状态的 Story;
  3. 交互测试;
  4. 可访问性检查;
  5. 必要的视觉快照;
  6. 文档和 API 说明。

评审者通过 Story 的导航直接进入状态,而不是手动构造状态。对于 SaveButton,至少应能看到:

Default
Disabled
SavesSuccessfully
SavesAndFails
LightTheme
DarkTheme

如果某个状态需要在真实后端、特定账号或特定时间窗口下才能出现,它就不适合只靠手工评审,应通过 Mock 或显式输入将状态稳定地呈现出来。

8.2 视觉评审和交互评审验证不同问题

视觉测试通常比较两个渲染结果:

Δ(Icurrent,Ibaseline)ϵ\Delta(I_{\text{current}}, I_{\text{baseline}}) \leq \epsilon

其中:

  • IcurrentI_{\text{current}} 是当前提交生成的图像;
  • IbaselineI_{\text{baseline}} 是已批准的基线图像;
  • ϵ\epsilon 是允许的渲染差异。

它能发现:

  • 间距改变;
  • 字体或颜色改变;
  • 元素溢出;
  • 响应式布局破坏;
  • 深色主题中的对比度或边框错误。

但视觉相同不代表行为正确。例如按钮可能看起来一样,却没有调用 onSave。所以:

  • 交互测试验证状态和副作用;
  • 视觉测试验证渲染外观;
  • 可访问性测试验证语义、名称、键盘和部分 WCAG 规则;
  • 代码评审验证实现是否合理。

任何一种都不能替代其他类型。

8.3 可访问性评审

可以在 Storybook 中加入可访问性插件,并在特定 Story 上配置规则。示例:

export const ErrorState: Story = {
  parameters: {
    a11y: {
      element: 'button',
    },
  },
};

具体支持的规则和配置取决于使用的 addon 版本。可访问性自动检查能够发现部分问题,例如缺少名称、颜色对比度不足或结构不合法,但无法判断所有键盘流程、屏幕阅读器语义和业务可用性。

因此交互 Story 还应验证键盘行为。例如:

export const KeyboardActivation: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole('button', { name: '保存' });

    button.focus();
    await userEvent.keyboard('{Enter}');

    await expect(
      canvas.findByRole('button', { name: '已保存' }),
    ).resolves.toBeVisible();
  },
};

如果按钮只是视觉上像按钮、实际使用 div,这个测试会暴露语义问题。使用原生 <button> 通常能获得更可靠的键盘和禁用行为,但仍需结合具体设计验证焦点样式和交互顺序。


九、常见失败表现与诊断路径

9.1 Story 找不到组件或类型错误

表现可能是:

Cannot find module ...

Meta<typeof Component> 类型检查失败。

排查顺序是:

  1. 确认 Story 文件位于 Storybook 的 stories glob 匹配范围;
  2. 确认组件导出方式与导入方式一致;
  3. 确认使用了正确的 framework 包;
  4. 删除错误的自动生成示例后重新检查;
  5. 检查 TypeScript、Vite 或框架插件的别名配置。

Storybook 的 stories glob 只决定发现哪些 Story 文件,不会自动修复 TypeScript 路径别名。应用能编译,不代表 Storybook 一定能解析相同别名。

9.2 play 测试找不到元素

例如:

Unable to find an accessible element with the role "button"

通常有四类原因:

  • Story 的初始 args 与测试假设不一致;
  • 组件还未完成异步渲染;
  • 查询范围错误,没有使用正确的 canvasElement
  • 元素没有正确的语义角色或可访问名称。

先检查 Story 在浏览器中是否能正常显示,再检查 Testing Library 的查询条件。不要第一时间把查询改成宽泛的 CSS 选择器,否则可能掩盖组件的可访问性问题。

9.3 测试在本地通过、CI 失败

常见原因包括:

  • 字体未加载,导致视觉快照变化;
  • 使用当前时间、随机数或真实网络;
  • 动画没有关闭;
  • 不同浏览器或操作系统产生像素差异;
  • Story 依赖本地环境变量;
  • Mock 请求处理器没有覆盖所有路径。

修复的方向不是盲目增大等待时间,而是让 Story 的输入、网络、时间和环境显式化。对于视觉测试,还应固定视口、字体和动画策略,并审查每次基线更新的差异。

9.4 Story 过度依赖全局状态

如果所有 Story 都依赖一个巨大 Decorator,单个 Story 的行为可能很难理解。一个修改全局 Provider 的提交,可能让几十个看似无关的 Story 同时失败。

可以按依赖层次拆分:

  • 全局只放所有组件都必须有的环境;
  • 局部 Decorator 放某个组件族专用的 Provider;
  • Story 自己通过 argsparameters 表达特殊输入;
  • 与后端相关的行为使用局部请求 Mock。

这样能够缩小故障范围,也让评审者更容易判断一个 Story 的真实前置条件。


十、Story 设计中的反例

10.1 反例:一个 Story 通过大量点击进入目标状态

export const FinalState: Story = {
  play: async () => {
    // 连续点击十几个按钮,最后才进入目标状态
  },
};

这种 Story 的问题是目标状态不可直接理解。任何中间按钮文案、动画时间或路由变化都可能使它失败。

更好的做法是:

  • 如果状态是公开组件输入,直接使用 args
  • 如果状态是内部状态,通过最短且有业务意义的用户操作进入;
  • 如果需要复杂工作流,拆分成多个 Story 或提升为集成场景。

10.2 反例:Story 只截取“漂亮状态”

只定义默认状态会导致以下问题:

  • 禁用时文本是否可读没人验证;
  • 加载时是否重复提交没人验证;
  • 失败后是否可恢复没人验证;
  • 深色主题和窄屏布局没人验证。

Story 的数量不应追求越多越好,而应覆盖会改变用户决策或组件行为的边界。一个错误状态 Story 的价值通常高于多个仅改变文案的重复 Story。

10.3 反例:用 Storybook 环境掩盖应用缺陷

如果组件在 Storybook 中通过 Decorator 获得了一个特殊 Context,但真实应用没有同样的 Provider,Storybook 中的成功不能证明应用正确。

验证方法是检查依赖闭包:

DStoryDComponentD_{\text{Story}} \supseteq D_{\text{Component}}

其中 DComponentD_{\text{Component}} 是组件运行所需的上下文依赖,DStoryD_{\text{Story}} 是 Story 实际提供的依赖。理想情况下,Story 应显式提供组件需要的最小环境;不应额外注入一个只在 Storybook 存在的假行为。

例如,组件依赖路由跳转时,Story 可以提供测试路由,但应确认真实应用也使用兼容的路由 API,而不是在 Story 中替换成完全不同的实现。


十一、从组件到评审的完整闭环

一个可执行的 Storybook 工作流可以按下面的因果链组织:

flowchart TD
    A[定义组件 API 和状态] --> B[为主要状态编写 CSF Story]
    B --> C[在 Preview 中提供真实所需的 Provider]
    C --> D[使用 args 和 Controls 调整输入]
    B --> E[为用户流程编写 play 交互测试]
    E --> F[运行断言和可访问性检查]
    B --> G[生成文档和示例]
    B --> H[生成视觉基线或快照]
    F --> I[持续集成]
    G --> J[设计与开发评审]
    H --> J
    I --> J
    J --> K[批准、修正或回滚]

关键路径是:

  1. 先明确组件有哪些状态和状态转换;
  2. args 表达公开输入;
  3. 用 Decorator 表达主题、路由和上下文;
  4. play 验证用户行为导致的状态变化;
  5. 用文档说明 API 和使用语义;
  6. 用视觉和可访问性检查验证呈现质量;
  7. 将 Story 在持续集成和 Pull Request 评审中运行。

如果缺少第 1 步,Story 会变成随意的截图集合;如果缺少第 4 步,组件行为没有被验证;如果缺少第 6 步,代码通过测试仍可能出现明显的视觉或语义回归。


十二、如何判断一个 Story 是否合格

一个合格的 Story 至少应满足以下条件:

  • 可以从文件中看出它代表的组件状态;
  • 初始输入使用真实存在的组件 API;
  • 必要的 Provider、主题和请求 Mock 是显式的;
  • 交互步骤不依赖随机数据或真实后端;
  • 断言验证用户可见结果,而不是只验证内部实现;
  • 失败时能指出是哪个状态、哪个操作或哪个条件不满足;
  • 能被文档、测试和评审共同复用;
  • 在真实应用的客户端/服务端边界内没有伪造错误运行模型。

Storybook 的工程价值不在于“多一个组件预览页面”,而在于把组件状态转化为可执行、可观察、可评审的契约:

Story=可复现状态+运行时上下文+用户操作+可验证结果\text{Story} = \text{可复现状态} + \text{运行时上下文} + \text{用户操作} + \text{可验证结果}

当 Story 同时承担示例、测试和评审入口时,组件库中的状态不再只存在于开发者记忆中,而会成为团队可以运行和讨论的工程资产。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。