参加直播:周四美国东部时间上午 11 点,Storybook 9 发布及 AMA
文档
Storybook Docs

组件故事格式 (CSF)

组件故事格式 (CSF) 是编写故事的推荐方式。它是一个基于 ES6 模块的开放标准,可在 Storybook 之外移植使用。

如果您使用较旧的 storiesOf() 语法编写故事,该语法已在 Storybook 8.0 中移除,并且不再维护。我们建议将您的故事迁移到 CSF。有关更多信息,请参阅迁移指南

在 CSF 中,故事和组件元数据被定义为 ES 模块。每个组件故事文件都包含一个必需的默认导出和一个或多个命名导出

默认导出

默认导出定义了组件的元数据,包括组件本身、其title(它将出现在导航 UI 故事层级中的位置)、装饰器参数

component 字段是必需的,插件会使用它来自动生成 prop 表并显示其他组件元数据。title 字段是可选的,并且应该是唯一的(即,不在多个文件之间重复使用)。

MyComponent.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 { MyComponent } from './MyComponent';
 
const meta = {
  /* 👇 The title prop is optional.
   * See https://storybook.org.cn/docs/configure/#configure-story-loading
   * to learn how to generate automatic titles
   */
  title: 'Path/To/MyComponent',
  component: MyComponent,
  decorators: [
    /* ... */
  ],
  parameters: {
    /* ... */
  },
} satisfies Meta<typeof MyComponent>;
 
export default meta;

有关更多示例,请参阅编写故事

命名故事导出

在 CSF 中,文件中的每个命名导出默认都代表一个故事对象。

MyComponent.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 { MyComponent } from './MyComponent';
 
const meta = {
  component: MyComponent,
} satisfies Meta<typeof MyComponent>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
export const Basic: Story = {};
 
export const WithProp: Story = {
  render: () => <MyComponent prop="value" />,
};

导出的标识符将使用 Lodash 的startCase 函数转换为“起始大写”(start case)。例如

标识符转换结果
nameName
someNameSome Name
someNAMESome NAME
some_custom_NAMESome Custom NAME
someName1234Some Name 1 2 3 4

我们建议所有导出名称都以大写字母开头。

故事对象可以使用一些不同的字段进行注解,以定义故事级别的装饰器参数,并定义故事的name

Storybook 的 name 配置元素在特定情况下很有帮助。常见的用例包括包含特殊字符或 Javascript 保留字名称。如果未指定,Storybook 默认为命名导出。

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

Args 故事输入

从 SB 6.0 开始,故事接受称为 Args 的命名输入。Args 是由 Storybook 及其插件提供(并可能更新)的动态数据。

考虑 Storybook 关于一个文本按钮的“Button”示例,该按钮记录其点击事件

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 { action } from 'storybook/actions';
 
import { Button } from './Button';
 
const meta = {
  component: Button,
} satisfies Meta<typeof Button>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
export const Basic: Story = {
  render: () => <Button label="Hello" onClick={action('clicked')} />,
};

现在考虑使用 args 重写的相同示例

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 { action } from 'storybook/actions';
 
import { Button } from './Button';
 
const meta = {
  component: Button,
} satisfies Meta<typeof Button>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
export const Text = {
  args: {
    label: 'Hello',
    onClick: action('clicked'),
  },
  render: ({ label, onClick }) => <Button label={label} onClick={onClick} />,
};

或者更简单地说

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 Text: Story = {
  args: {},
};

这些版本不仅比不使用 args 的版本更短、更容易编写,而且由于代码不特别依赖于 actions 插件,因此也更具可移植性。

有关设置文档Actions的更多信息,请参阅其各自的文档。

Play 函数

Storybook 的 play 函数是在故事在 UI 中渲染时执行的一小段代码片段。它们是方便的辅助方法,可帮助您测试那些否则不可能或需要用户交互的用例。

play 函数的一个很好的用例是表单组件。在以前的 Storybook 版本中,您会编写一组故事并必须与组件交互才能验证它。使用 Storybook 的 play 函数,您可以编写以下故事

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();
  },
};

当故事在 UI 中渲染时,Storybook 会执行 play 函数中定义的每个步骤,并在无需用户交互的情况下运行断言。

自定义渲染函数

从 Storybook 6.4 开始,您可以将故事编写为 JavaScript 对象,从而减少测试组件所需的样板代码,从而提高功能性和可用性。Render 函数是很有用的方法,可以让您更好地控制故事的渲染方式。例如,如果您将故事编写为对象并想指定组件应如何渲染,则可以编写以下代码

MyComponent.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 { Layout } from './Layout';
 
import { MyComponent } from './MyComponent';
 
const meta = {
  component: MyComponent,
} satisfies Meta<typeof MyComponent>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
// This story uses a render function to fully control how the component renders.
export const Example: Story = {
  render: () => (
    <Layout>
      <header>
        <h1>Example</h1>
      </header>
      <article>
        <MyComponent />
      </article>
    </Layout>
  ),
};

当 Storybook 加载此故事时,它会检测是否存在 render 函数,并根据定义的内容相应地调整组件渲染。

Storybook 导出与 name 处理

Storybook 对命名导出和 name 选项的处理方式略有不同。您应该何时使用其中一种,何时使用另一种?

Storybook 始终使用命名导出确定故事 ID 和 URL。

如果您指定 name 选项,它将用作 UI 中的故事显示名称。否则,它默认为命名导出,通过 Storybook 的 storyNameFromExportlodash.startCase 函数处理。

MyComponent-test.js
it('should format CSF exports with sensible defaults', () => {
  const testCases = {
    name: 'Name',
    someName: 'Some Name',
    someNAME: 'Some NAME',
    some_custom_NAME: 'Some Custom NAME',
    someName1234: 'Some Name 1234',
    someName1_2_3_4: 'Some Name 1 2 3 4',
  };
  Object.entries(testCases).forEach(([key, val]) => {
    expect(storyNameFromExport(key)).toBe(val);
  });
});

当您想更改故事的名称时,请重命名 CSF 导出。这将更改故事的名称,同时更改故事的 ID 和 URL。

在以下情况下,最好使用 name 配置元素

  1. 您希望名称以命名导出无法实现的方式显示在 Storybook UI 中,例如:“default”等保留关键字、表情符号等特殊字符,或者不同于 storyNameFromExport 提供的间距/大小写。
  2. 您想独立于更改显示方式来保留故事 ID。拥有稳定的故事 ID 有助于与第三方工具集成。

非故事导出

在某些情况下,您可能希望混合导出故事和非故事内容(例如,模拟数据)。

您可以在默认导出中使用可选的配置字段 includeStoriesexcludeStories 来实现此目的。您可以将它们定义为字符串数组或正则表达式。

考虑以下故事文件

MyComponent.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 { MyComponent } from './MyComponent';
 
import someData from './data.json';
 
const meta = {
  component: MyComponent,
  includeStories: ['SimpleStory', 'ComplexStory'], // 👈 Storybook loads these stories
  excludeStories: /.*Data$/, // 👈 Storybook ignores anything that contains Data
} satisfies Meta<typeof MyComponent>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
export const simpleData = { foo: 1, bar: 'baz' };
export const complexData = { foo: 1, foobar: { bar: 'baz', baz: someData } };
 
export const SimpleStory: Story = {
  args: {
    data: simpleData,
  },
};
 
export const ComplexStory: Story = {
  args: {
    data: complexData,
  },
};

当此文件在 Storybook 中渲染时,它会将 ComplexStorySimpleStory 视为故事,并忽略 data 命名导出。

对于此特定示例,您可以根据方便程度通过不同方式实现相同结果

  • includeStories: /^[A-Z]/
  • includeStories: /.*Story$/
  • includeStories: ['SimpleStory', 'ComplexStory']
  • excludeStories: /^[a-z]/
  • excludeStories: /.*Data$/
  • excludeStories: ['simpleData', 'complexData']

如果您遵循以大写字母开头故事导出的最佳实践(即,使用 UpperCamelCase),则第一种选项是推荐的解决方案。

从 CSF 2 升级到 CSF 3

在 CSF 2 中,命名导出始终是实例化组件的函数,并且这些函数可以使用配置选项进行注解。例如

CSF 2 - Button.stories.ts|tsx
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import type { ComponentStory, ComponentMeta } from '@storybook/your-framework';
 
import { Button } from './Button';
 
export default {
  title: 'Button',
  component: Button,
} as ComponentMeta<typeof Button>;
 
export const Primary: ComponentStory<typeof Button> = (args) => <Button {...args} />;
Primary.args = { primary: true };

这声明了一个 Button 的 Primary 故事,它通过将 { primary: true } 展开到组件中来渲染自身。default.title 元数据说明了将故事放置在导航层级中的位置。

以下是 CSF 3 的等效示例

CSF 3 - 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 } };

让我们逐个了解这些变化。

可展开的故事对象

在 CSF 3 中,命名导出是对象,而不是函数。这使我们能够使用 JS 展开运算符更有效地重用故事。

考虑在介绍示例中添加以下内容,它创建了一个 PrimaryOnDark 故事,该故事在深色背景下渲染

以下是 CSF 2 的实现

CSF 2 - Button.stories.js|jsx|ts|tsx
export const PrimaryOnDark = Primary.bind({});
PrimaryOnDark.args = Primary.args;
PrimaryOnDark.parameters = { background: { default: 'dark' } };

Primary.bind({}) 复制了故事函数,但它不复制函数上的注解,因此我们必须添加 PrimaryOnDark.args = Primary.args 来继承 args。

在 CSF 3 中,我们可以展开 Primary 对象来继承其所有注解

CSF 3 - Button.stories.ts|tsx
export const PrimaryOnDark: Story = {
  ...Primary,
  parameters: { background: { default: 'dark' } },
};

详细了解命名故事导出

默认渲染函数

在 CSF 3 中,您通过 render 函数指定故事如何渲染。我们可以通过以下步骤将 CSF 2 示例重写为 CSF 3。

让我们从一个简单的 CSF 2 故事函数开始

CSF 2 - Button.stories.ts|tsx
// Other imports and story implementation
export const Default: ComponentStory<typeof Button> = (args) => <Button {...args} />;

现在,让我们将其重写为 CSF 3 中的故事对象,并使用显式的 render 函数来告诉故事如何渲染自身。与 CSF 2 一样,这使我们可以完全控制如何渲染单个组件甚至组件集合。

CSF 3 - Button.stories.ts|tsx
// Other imports and story implementation
export const Default: Story = {
  render: (args) => <Button {...args} />,
};

详细了解渲染函数

但在 CSF 2 中,许多故事函数是相同的:获取默认导出中指定的组件,并将 args 展开到其中。这些故事有趣的地方不在于函数,而在于传递给函数的 args。

CSF 3 为每个渲染器提供了默认渲染函数。如果您所做的只是将 args 展开到组件中(这是最常见的情况),则完全不需要指定任何 render 函数

CSF 3 - Button.stories.js|jsx|ts|tsx
export const Default = {};

有关更多信息,请参阅自定义渲染函数部分。

自动生成标题

最后,CSF 3 可以自动生成标题。

CSF 2 - Button.stories.js|jsx|ts|tsx
export default {
  title: 'components/Button',
  component: Button,
};
CSF 3 - Button.stories.js|jsx|ts|tsx
export default { component: Button };

您仍然可以像在 CSF 2 中一样指定标题,但如果未指定,则可以从故事在磁盘上的路径推断。有关更多信息,请参阅配置故事加载部分。