加入直播:周四,美国东部时间上午 11 点,Storybook 9 发布 & AMA(问答)
文档Story
Storybook Docs

如何编写 Story

Story 捕获 UI 组件的渲染状态。它是一个对象,带有描述组件在给定一组参数(arguments)下的行为和外观的注解。

Storybook 在讨论 React 的 props、Vue 的 props、Angular 的 @Input 等类似概念时,使用了通用术语 arguments(简写为 args)。

Story 的存放位置

组件的 Story 定义在与组件文件一同存放的 Story 文件中。Story 文件仅用于开发,不会包含在生产构建中。在文件系统中,它看起来像这样

components/
├─ Button/
│  ├─ Button.js | ts | jsx | tsx | vue | svelte
│  ├─ Button.stories.js | ts | jsx | tsx | svelte

组件 Story 格式

我们根据组件 Story 格式 (CSF) 来定义 Story,它是一个基于 ES6 模块的标准,易于编写并可在不同工具之间移植。

关键要素是描述组件的默认导出(default export)和描述 Story 的命名导出(named exports)。

默认导出

默认导出元数据控制 Storybook 如何列出你的 Story,并提供插件使用的信息。例如,这是一个 Story 文件 Button.stories.js|ts 的默认导出

Button.stories.ts|tsx
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import type { Meta } from '@storybook/your-framework';
 
import { Button } from './Button';
 
const meta = {
  component: Button,
} satisfies Meta<typeof Button>;
 
export default meta;

从 Storybook 7.0 版本开始,Story 标题在构建过程中进行静态分析。默认导出必须包含可静态读取的 title 属性,或者可从中计算出自动标题的 component 属性。使用 id 属性自定义 Story URL 也必须是静态可读的。

定义 Story

使用 CSF 文件的命名导出(named exports)来定义组件的 Story。我们建议 Story 导出使用 UpperCamelCase 命名。以下是如何在“主要”(primary)状态下渲染 Button 并导出一个名为 Primary 的 Story。

Button.stories.ts|tsx
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { Button } from './Button';
 
const meta = {
  component: Button,
} satisfies Meta<typeof Button>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
export const Primary: Story = {
  args: {
    primary: true,
    label: 'Button',
  },
};

使用 React Hooks

React Hooks 是方便的辅助方法,可以使用更简洁的方法创建组件。如果需要,可以在创建组件 Story 时使用它们,但应将其视为高级用例。我们建议在编写自己的 Story 时尽可能多地使用args。例如,这是一个使用 React Hooks 改变按钮状态的 Story

Button.stories.ts|tsx
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { Button } from './Button';
 
const meta = {
  component: Button,
} satisfies Meta<typeof Button>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
/*
 *👇 Render functions are a framework specific feature to allow you control on how the component renders.
 * See https://storybook.org.cn/docs/api/csf
 * to learn how to use render functions.
 */
export const Primary: Story = {
  render: () => <Button primary label="Button" />,
};

重命名 Story

你可以重命名任何你需要的特定 Story。例如,给它一个更准确的名称。以下是如何更改 Primary Story 的名称

Button.stories.ts|tsx
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { Button } from './Button';
 
const meta = {
  component: Button,
} satisfies Meta<typeof Button>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
export const Primary: Story = {
  // 👇 Rename this story
  name: 'I am the primary',
  args: {
    label: 'Button',
    primary: true,
  },
};

你的 Story 现在将以给定的文本显示在侧边栏中。

如何编写 Story

Story 是一个描述如何渲染组件的对象。每个组件可以有多个 Story,这些 Story 可以相互构建。例如,我们可以基于上面的 Primary Story 添加 SecondaryTertiary Story。

Button.stories.ts|tsx
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { Button } from './Button';
 
const meta = {
  component: Button,
} satisfies Meta<typeof Button>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
export const Primary: Story = {
  args: {
    backgroundColor: '#ff0',
    label: 'Button',
  },
};
 
export const Secondary: Story = {
  args: {
    ...Primary.args,
    label: '😄👍😍💯',
  },
};
 
export const Tertiary: Story = {
  args: {
    ...Primary.args,
    label: '📚📕📈🤓',
  },
};

此外,你可以在编写其他组件的 Story 时导入 args 进行复用,这在构建复合组件时非常有用。例如,如果我们创建 ButtonGroup Story,我们可能会将其子组件 Button 的两个 Story 进行组合。

ButtonGroup.stories.ts|tsx
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { ButtonGroup } from '../ButtonGroup';
 
//👇 Imports the Button stories
import * as ButtonStories from './Button.stories';
 
const meta = {
  component: ButtonGroup,
} satisfies Meta<typeof ButtonGroup>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
export const Pair: Story = {
  args: {
    buttons: [{ ...ButtonStories.Primary.args }, { ...ButtonStories.Secondary.args }],
    orientation: 'horizontal',
  },
};

Button 的签名发生变化时,你只需要修改 Button 的 Story 来反映新的结构,ButtonGroup 的 Story 将自动更新。这种模式允许你在组件层级结构中复用数据定义,使你的 Story 更易于维护。

不仅如此!来自 Story 函数的每个 args 都可以使用 Storybook 的Controls 面板进行实时编辑。这意味着你的团队可以在 Storybook 中动态改变组件来进行压力测试和发现边缘情况。

你还可以使用 Controls 面板在调整控件值后编辑或保存新的 Story。

插件可以增强 args。例如,Actions 插件会自动检测哪些 args 是回调函数,并为其附加日志记录功能。这样,交互(如点击)就会记录在 Actions 面板中。

使用 play 函数

Storybook 的 play 函数是方便的辅助方法,用于测试需要用户交互的组件场景。它们是 Story 渲染后立即执行的小段代码片段。例如,假设你想验证一个表单组件,你可以使用 play 函数编写以下 Story,以检查组件在输入信息时的响应方式

LoginForm.stories.ts|tsx
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { expect } from 'storybook/test';
 
import { LoginForm } from './LoginForm';
 
const meta = {
  component: LoginForm,
} satisfies Meta<typeof LoginForm>;
export default meta;
 
type Story = StoryObj<typeof meta>;
 
export const EmptyForm: Story = {};
 
export const FilledForm: Story = {
  play: async ({ canvas, userEvent }) => {
    // 👇 Simulate interactions with the component
    await userEvent.type(canvas.getByTestId('email'), 'email@provider.com');
 
    await userEvent.type(canvas.getByTestId('password'), 'a-random-password');
 
    // See https://storybook.org.cn/docs/essentials/actions#automatically-matching-args to learn how to setup logging in the Actions panel
    await userEvent.click(canvas.getByRole('button'));
 
    // 👇 Assert DOM structure
    await expect(
      canvas.getByText(
        'Everything is perfect. Your account is ready and we should probably get you started!'
      )
    ).toBeInTheDocument();
  },
};

你可以在交互面板中与 Story 的 play 函数进行交互和调试。

使用 Parameters

Parameters 是 Storybook 定义 Story 静态元数据的方法。Story 的 Parameters 可用于为 Story 或 Story 组级别的各种插件提供配置。

例如,假设你想让 Button 组件使用与应用程序中其他组件不同的背景集进行测试。你可以在组件级别添加一个 backgrounds parameter

Button.stories.ts|tsx
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc.
import type { Meta } from '@storybook/your-framework';
 
import { Button } from './Button';
 
const meta = {
  component: Button,
  //👇 Creates specific parameters at the component level
  parameters: {
    backgrounds: {
      options: {},
    },
  },
} satisfies Meta<typeof Button>;
 
export default meta;

Parameters background color

这个 parameter 会指示 backgrounds 插件在选择 Button Story 时重新配置自身。大多数插件通过基于 parameter 的 API 进行配置,并可在全局组件Story 级别进行影响。

使用 Decorators

Decorators 是一种在渲染 Story 时将组件包装在任意标记中的机制。组件在创建时通常会假定其渲染的“位置”。你的样式可能需要主题或布局包装器,或者你的 UI 可能需要特定的上下文或数据提供者。

一个简单的例子是为组件的 Story 添加内边距。可以使用一个将 Story 包装在带内边距的 div 中的 decorator 来实现,如下所示

Button.stories.ts|tsx
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { Button } from './Button';
 
const meta = {
  component: Button,
  decorators: [
    (Story) => (
      <div style={{ margin: '3em' }}>
        {/* 👇 Decorators in Storybook also accept a function. Replace <Story/> with Story() to enable it  */}
        <Story />
      </div>
    ),
  ],
} satisfies Meta<typeof Button>;
 
export default meta;

Decorators 可以更复杂,并且通常由插件提供。你还可以在Story组件全局级别配置 decorators。

两个或多个组件的 Story

有时你可能有两个或多个组件需要协同工作。例如,如果你有一个父组件 List,它可能需要子组件 ListItem

List.stories.ts|tsx
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { List } from './List';
 
const meta = {
  component: List,
} satisfies Meta<typeof List>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
// Always an empty list, not super interesting
export const Empty: Story = {};

在这种情况下,为每个 Story 渲染一个不同的函数是合理的

List.stories.ts|tsx
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { List } from './List';
import { ListItem } from './ListItem';
 
const meta = {
  component: List,
} satisfies Meta<typeof List>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
export const Empty: Story = {};
 
/*
 *👇 Render functions are a framework specific feature to allow you control on how the component renders.
 * See https://storybook.org.cn/docs/api/csf
 * to learn how to use render functions.
 */
export const OneItem: Story = {
  render: (args) => (
    <List {...args}>
      <ListItem />
    </List>
  ),
};
 
export const ManyItems: Story = {
  render: (args) => (
    <List {...args}>
      <ListItem />
      <ListItem />
      <ListItem />
    </List>
  ),
};

你还可以将子组件 ListItemStory 数据List 组件中复用。这样更容易维护,因为你无需在多个地方更新它。

List.stories.ts|tsx
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { List } from './List';
import { ListItem } from './ListItem';
 
//👇 We're importing the necessary stories from ListItem
import { Selected, Unselected } from './ListItem.stories';
 
const meta = {
  component: List,
} satisfies Meta<typeof List>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
export const ManyItems: Story = {
  render: (args) => (
    <List {...args}>
      <ListItem {...Selected.args} />
      <ListItem {...Unselected.args} />
      <ListItem {...Unselected.args} />
    </List>
  ),
};

请注意,这样编写 Story 存在一些缺点,因为你无法充分利用 args 机制,也无法在构建更复杂的复合组件时组合 args。有关更多讨论,请参阅多组件 Story 工作流程文档。