如何编写 Story
一个 story(故事)捕获了 UI 组件的渲染状态。它是一个带有注解的对象,描述了组件在给定参数集下的行为和外观。
Storybook 在讨论 React 的 props、Vue 的 props、Angular 的 @Input 以及其他类似概念时,使用了通用术语 arguments(简称 args)。
故事存放位置
组件的故事定义在一个与组件文件并存的故事文件中。故事文件仅用于开发,不会包含在生产构建中。在文件系统中,它的样子大致如下
components/
├─ Button/
│ ├─ Button.js | ts | jsx | tsx | vue | svelte
│ ├─ Button.stories.js | ts | jsx | tsx | svelte
组件故事格式
我们根据 组件故事格式(CSF)来定义故事,这是一种基于 ES6 模块的标准,易于编写且可在工具之间移植。
默认导出
默认导出元数据控制 Storybook 如何列出您的故事,并提供供插件使用信息。例如,这是故事文件 Button.stories.js|ts 的默认导出
// 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 版本开始,故事标题在构建过程中被静态分析。默认导出必须包含一个可以静态读取的 title 属性,或者一个可以从中计算出自动标题的 component 属性。使用 id 属性来自定义您的故事 URL 也必须是静态可读的。
定义故事
使用 CSF 文件的命名导出(named exports)来定义您的组件故事。我们建议您为故事导出使用 UpperCamelCase。以下是如何渲染“primary”状态的 Button 并导出一个名为 Primary 的故事。
// 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',
},
};自定义渲染
默认情况下,故事将渲染 meta(默认导出)中定义的组件,并将传递的 args 应用于它。如果您需要渲染其他内容,可以为 render 属性提供一个函数,该函数返回所需的输出。
例如,如果您想在 Alert 中渲染一个 Button,您可以定义一个自定义渲染函数,如下所示
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import { Meta, StoryObj } from '@storybook/your-framework';
import { Alert } from './Alert';
import { Button } from './Button';
const meta = {
component: Button,
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const PrimaryInAlert: Story = {
args: {
primary: true,
label: 'Button',
},
render: (args) => (
<Alert>
Alert text
<Button {...args} />
</Alert>
),
};请注意,render 函数如何将 args 展开到 Button 组件上。这确保了诸如 Controls 之类的功能能够按预期工作,允许您在 Storybook UI 中动态更改 Button 的属性。
您可以通过在 meta 级别应用相同的渲染函数来跨故事重用它
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import { Meta, StoryObj } from '@storybook/your-framework';
import { Alert } from './Alert';
import { Button } from './Button';
const meta = {
component: Button,
render: (args) => (
<Alert>
Alert text
<Button {...args} />
</Alert>
),
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const DefaultInAlert: Story = {
args: {
label: 'Button',
},
};
export const PrimaryInAlert: Story = {
args: {
primary: true,
label: 'Button',
},
};您在 meta 级别定义的任何内容都可以被故事级别覆盖,因此您仍然可以根据需要自定义单个故事的渲染。
最后,render 函数会接收第二个 context 参数,其中包含故事的所有其他详细信息,包括 parameters、globals 等等。
使用 React Hooks
React Hooks 是创建组件的便捷辅助方法,可实现更简化的方法。如果您需要,可以在创建组件故事时使用它们,尽管您应该将它们视为高级用例。我们强烈建议在编写自己的故事时尽可能多地使用 args。例如,这是一个使用 React Hooks 来更改按钮状态的故事
import React, { useState } from 'react';
// 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>;
const ButtonWithHooks = () => {
// Sets the hooks for both the label and primary props
const [value, setValue] = useState('Secondary');
const [isPrimary, setIsPrimary] = useState(false);
// Sets a click handler to change the label's value
const handleOnChange = () => {
if (!isPrimary) {
setIsPrimary(true);
setValue('Primary');
}
};
return <Button primary={isPrimary} onClick={handleOnChange} label={value} />;
};
export const Primary = {
render: () => <ButtonWithHooks />,
} satisfies Story;重命名故事
默认情况下,Storybook 使用故事导出的名称作为故事名称的基础。但是,您可以通过向故事对象添加 name 属性来自定义故事的名称。当您想为故事提供更具描述性或用户友好的名称时,这很有用。
// 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 是一个描述如何渲染组件的对象。每个组件可以有多个故事,并且这些故事可以相互构建。例如,我们可以基于上面 Primary 故事添加 Secondary 和 Tertiary 故事。
// 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: '📚📕📈🤓',
},
};更重要的是,您可以导入 args 来在为其他组件编写故事时重用,这对于构建复合组件非常有用。例如,如果我们创建一个 ButtonGroup 故事,我们可能会重组其子组件 Button 的两个故事。
// 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 的故事来反映新模式,而 ButtonGroup 的故事将自动更新。这种模式允许您在组件层次结构中重用数据定义,从而使您的故事更易于维护。
这还不是全部!故事函数中的每个 arg 都可以使用 Storybook 的 Controls 面板进行实时编辑。这意味着您的团队可以动态地在 Storybook 中更改组件,以进行压力测试和查找边缘情况。
调整控件值后,您还可以使用 Controls 面板编辑或保存新故事。
Addons 可以增强 args。例如,Actions 会自动检测哪些 args 是回调函数,并向它们添加一个日志记录函数。这样,交互(如点击)就会被记录在 actions 面板中。
使用 play 函数
Storybook 的 play 函数是测试需要用户干预的组件场景的便捷辅助方法。它们是故事渲染后执行的小代码片段。例如,假设您想验证一个表单组件,您可以使用 play 函数编写以下故事,以检查组件在填写信息时的响应情况
// 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();
},
};您可以在 interactions panel 中与故事的 play 函数进行交互和调试。
使用参数
参数是 Storybook 定义故事静态元数据的方法。故事的参数可用于在故事或故事组的级别为各种插件提供配置。
例如,假设您想让您的 Button 组件在与应用程序中其他组件不同的背景下进行测试。您可以添加一个组件级别的 backgrounds 参数
// 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;此参数将指示 backgrounds 功能在选择 Button 故事时重新配置自身。大多数功能和插件都通过基于参数的 API 进行配置,并且可以在 全局、组件和 故事 级别进行影响。
使用装饰器
装饰器(Decorators)是一种在渲染故事时将组件包装在任意标记中的机制。组件通常在假设它们“将要”渲染在哪里时创建。您的样式可能需要主题或布局包装器,或者您的 UI 可能需要特定的上下文或数据提供者。
一个简单的例子是为组件的故事添加内边距。使用一个将故事包装在带有内边距的 div 中的装饰器来实现这一点,如下所示
// 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;装饰器可以更复杂,并且通常由插件提供。您还可以配置故事、组件和全局级别的装饰器。
两个或多个组件的故事
有时您可能有两个或多个组件协同工作。例如,如果您有一个父 List 组件,它可能需要子 ListItem 组件。
// 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 = {};在这种情况下,有必要自定义渲染以输出带有不同数量 ListItem 子项的 List 组件。
// 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>
),
};您还可以重用子 ListItem 的故事数据来构建您的 List 组件。这更容易维护,因为您不必在多个地方更新它。
import * as React from 'react';
// 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>
),
};请注意,像这样编写故事存在缺点,因为您无法充分利用 args 机制和组合 args 来构建更复杂的复合组件。有关更多讨论,请参阅 多组件故事 工作流文档。
