文档故事
Storybook 文档

如何编写故事

观看视频教程

故事捕获 UI 组件的渲染状态。它是一个带有注释的对象,描述了组件在给定一组参数的情况下,其行为和外观。

Storybook 在谈论 React 的 props、Vue 的 props、Angular 的 @Input 和其他类似概念时,使用通用术语参数 (简称 args)。

故事放置位置

组件的故事定义在一个故事文件中,该文件与组件文件并存。故事文件仅用于开发,不会包含在生产包中。在您的文件系统中,它看起来像这样

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

组件故事格式

我们根据 组件故事格式 (CSF) 定义故事,这是一个基于 ES6 模块的标准,易于编写且可在工具之间移植。

关键要素是 默认导出,它描述了组件,以及 命名导出,它描述了故事。

默认导出

默认 导出元数据控制 Storybook 如何列出您的故事,并提供插件使用的信息。例如,以下是故事文件 Button.stories.js|ts 的默认导出

Button.stories.ts|tsx
import type { Meta } from '@storybook/react';
 
import { Button } from './Button';
 
const meta: Meta<typeof Button> = {
  component: Button,
};
 
export default meta;

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

定义故事

使用 CSF 文件的命名导出定义您的组件的故事。我们建议您对故事导出使用 UpperCamelCase。以下是如何在“primary”状态下渲染 Button 并导出名为 Primary 的故事。

Button.stories.ts|tsx
import type { Meta, StoryObj } from '@storybook/react';
 
import { Button } from './Button';
 
const meta: Meta<typeof Button> = {
  component: Button,
};
 
export default meta;
type Story = StoryObj<typeof Button>;
 
export const Primary: Story = {
  args: {
    primary: true,
    label: 'Button',
  },
};

使用 React Hooks

React Hooks 是方便的辅助方法,用于使用更简化的方式创建组件。如果您需要它们,您可以在创建组件的故事时使用它们,尽管您应该将它们视为高级用例。我们建议在编写自己的故事时尽可能地使用 args。例如,以下是一个使用 React Hooks 更改按钮状态的故事

Button.stories.ts|tsx
import type { Meta, StoryObj } from '@storybook/react';
 
import { Button } from './Button';
 
const meta: Meta<typeof Button> = {
  component: Button,
};
 
export default meta;
type Story = StoryObj<typeof Button>;
 
/*
 *👇 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" />,
};

重命名故事

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

Button.stories.ts|tsx
import type { Meta, StoryObj } from '@storybook/react';
 
import { Button } from './Button';
 
const meta: Meta<typeof Button> = {
  component: Button,
};
 
export default meta;
type Story = StoryObj<typeof Button>;
 
export const Primary: Story = {
  // 👇 Rename this story
  name: 'I am the primary',
  args: {
    label: 'Button',
    primary: true,
  },
};

您的故事现在将与给定的文本一起显示在侧边栏中。

如何编写故事

故事是一个描述如何渲染组件的对象。您每个组件可以有多个故事,这些故事可以互相构建。例如,我们可以根据上面的主要故事添加辅助和三级故事。

Button.stories.ts|tsx
import type { Meta, StoryObj } from '@storybook/react';
 
import { Button } from './Button';
 
const meta: Meta<typeof Button> = {
  component: Button,
};
 
export default meta;
type Story = StoryObj<typeof Button>;
 
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: '📚📕📈🤓',
  },
};

更重要的是,您可以导入args以在为其他组件编写故事时重复使用,这在您构建复合组件时非常有用。例如,如果我们制作一个ButtonGroup故事,我们可能会从其子组件Button中混搭两个故事。

ButtonGroup.stories.ts|tsx
import type { Meta, StoryObj } from '@storybook/react';
 
import { ButtonGroup } from '../ButtonGroup';
 
//👇 Imports the Button stories
import * as ButtonStories from './Button.stories';
 
const meta: Meta<typeof ButtonGroup> = {
  component: ButtonGroup,
};
 
export default meta;
type Story = StoryObj<typeof ButtonGroup>;
 
export const Pair: Story = {
  args: {
    buttons: [{ ...ButtonStories.Primary.args }, { ...ButtonStories.Secondary.args }],
    orientation: 'horizontal',
  },
};

当 Button 的签名更改时,您只需要更改 Button 的故事以反映新的模式,ButtonGroup 的故事将自动更新。这种模式允许您在整个组件层次结构中重复使用数据定义,从而使您的故事更易于维护。

但这还不是全部!故事函数中的每个参数都可以使用 Storybook 的Controls 面板实时编辑。这意味着您的团队可以动态地在 Storybook 中更改组件以进行压力测试并查找边缘情况。

您还可以使用 Controls 面板在调整控制值后编辑或保存新故事。

加载项可以增强参数。例如,Actions 自动检测哪些参数是回调,并向它们附加一个日志记录函数。这样,交互(例如点击)就会记录在操作面板中。

使用 play 函数

Storybook 的play 函数和@storybook/addon-interactions 是方便的辅助方法,用于测试否则需要用户干预的组件场景。它们是小的代码片段,在您的故事渲染后执行一次。例如,假设您想验证一个表单组件,您可以使用play 函数编写以下故事,以检查组件在使用信息填充输入时如何响应。

LoginForm.stories.ts|tsx
import type { Meta, StoryObj } from '@storybook/react';
 
import { userEvent, within, expect } from '@storybook/test';
 
import { LoginForm } from './LoginForm';
 
const meta: Meta<typeof LoginForm> = {
  component: LoginForm,
};
 
export default meta;
type Story = StoryObj<typeof LoginForm>;
 
export const EmptyForm: Story = {};
 
/*
 * See https://storybook.org.cn/docs/writing-stories/play-function#working-with-the-canvas
 * to learn more about using the canvasElement to query the DOM
 */
export const FilledForm: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
 
    // 👇 Simulate interactions with the component
    await userEvent.type(canvas.getByTestId('email'), '[email protected]');
 
    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();
  },
};

如果没有play 函数和@storybook/addon-interactions 的帮助,您必须编写自己的故事并手动与组件进行交互以测试所有可能的用例场景。

使用参数

参数是 Storybook 定义故事静态元数据的方法。故事的参数可用于为故事或故事组的各个加载项提供配置。

例如,假设您想针对与应用程序中其他组件不同的背景集测试 Button 组件。您可以添加一个组件级backgrounds 参数。

Button.stories.ts|tsx
// Replace your-framework with the framework you are using (e.g., react-webpack5, vue3-vite)
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { Button } from './Button';
 
const meta: Meta<typeof Button> = {
  component: Button,
  // 👇 Meta-level parameters
  parameters: {
    backgrounds: {
      default: 'dark',
    },
  },
};
export default meta;
 
type Story = StoryObj<typeof Button>;
 
export const Basic: Story = {};

Parameters background color

此参数将指示背景加载项在选择 Button 故事时重新配置自身。大多数加载项都是通过基于参数的 API 配置的,并且可以在全局组件故事 级进行影响。

使用装饰器

装饰器是一种机制,可以在渲染故事时将组件包装在任意标记中。组件通常是使用对“渲染位置”的假设创建的。您的样式可能需要主题或布局包装器,或者您的 UI 可能需要特定的上下文或数据提供者。

一个简单的例子是在组件的故事中添加填充。使用一个装饰器来完成此操作,该装饰器将故事包装在一个带有填充的div 中,如下所示。

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

装饰器可能更加复杂,并且通常由加载项 提供。您也可以在故事组件全局 级配置装饰器。

两个或多个组件的故事

有时您可能有两个或多个组件创建为一起工作。例如,如果您有一个父List 组件,它可能需要子ListItem 组件。

List.stories.ts|tsx
import type { Meta, StoryObj } from '@storybook/react';
 
import { List } from './List';
 
const meta: Meta<typeof List> = {
  component: List,
};
 
export default meta;
type Story = StoryObj<typeof List>;
 
//👇 Always an empty list, not super interesting
export const Empty: Story = {};

在这种情况下,为每个故事渲染不同的函数是有意义的。

List.stories.ts|tsx
import type { Meta, StoryObj } from '@storybook/react';
 
import { List } from './List';
import { ListItem } from './ListItem';
 
const meta: Meta<typeof List> = {
  component: List,
};
 
export default meta;
type Story = StoryObj<typeof List>;
 
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>
  ),
};

您还可以在List 组件中重复使用子ListItem故事数据。这更容易维护,因为您不必在多个地方更新它。

List.stories.ts|tsx
import type { Meta, StoryObj } from '@storybook/react';
 
import { List } from './List';
import { ListItem } from './ListItem';
 
//👇 We're importing the necessary stories from ListItem
import { Selected, Unselected } from './ListItem.stories';
 
const meta: Meta<typeof List> = {
  component: List,
};
 
export default meta;
type Story = StoryObj<typeof List>;
 
export const ManyItems: Story = {
  render: (args) => (
    <List {...args}>
      <ListItem {...Selected.args} />
      <ListItem {...Unselected.args} />
      <ListItem {...Unselected.args} />
    </List>
  ),
};

请注意,以这种方式编写故事有缺点,因为在构建更复杂的复合组件时,您无法充分利用参数机制和组合参数。有关更多讨论,请参见多组件故事 工作流文档。