如何撰写故事
故事捕获 UI 组件的渲染状态。它是一个带有注释的对象,根据一组参数描述组件的行为和外观。
¥A story captures the rendered state of a UI component. It's an object with annotations that describe the component's behavior and appearance given a set of arguments.
Storybook 在谈论 React 的 props、Vue 的 props、Angular 的 @Input 和其他类似概念时使用通用术语参数(简称 args)。
¥Storybook uses the generic term arguments (args for short) when talking about React’s props, Vue’s props, Angular’s @Input, and other similar concepts.
故事放在哪里
¥Where to put stories
组件的故事在与组件文件共存的故事文件中定义。故事文件仅用于开发,不会包含在你的生产包中。在你的文件系统中,它看起来像这样:
¥A component’s stories are defined in a story file that lives alongside the component file. The story file is for development-only, and it won't be included in your production bundle. In your filesystem, it looks something like this:
components/
├─ Button/
│ ├─ Button.js | ts | jsx | tsx | vue | svelte
│ ├─ Button.stories.js | ts | jsx | tsx | svelte
组件故事格式
¥Component Story Format
我们根据 组件故事格式 (CSF) 定义故事,这是一种基于 ES6 模块的标准,易于编写且可在工具之间移植。
¥We define stories according to the Component Story Format (CSF), an ES6 module-based standard that is easy to write and portable between tools.
¥The key ingredients are the default export that describes the component, and named exports that describe the stories.
默认导出
¥Default export
默认导出元数据控制 Storybook 如何列出你的故事并提供插件使用的信息。例如,这是故事文件 Button.stories.js|ts 的默认导出:
¥The default export metadata controls how Storybook lists your stories and provides information used by addons. For example, here’s the default export for a story file 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 也必须是静态可读的。
¥Starting with Storybook version 7.0, story titles are analyzed statically as part of the build process. The default export must contain a title property that can be read statically or a component property from which an automatic title can be computed. Using the id property to customize your story URL must also be statically readable.
定义故事
¥Defining stories
使用 CSF 文件的命名导出来定义组件的故事。我们建议你使用 UpperCamelCase 导出故事。以下是如何在“主要”状态下渲染 Button 并导出名为 Primary 的故事。
¥Use the named exports of a CSF file to define your component’s stories. We recommend you use UpperCamelCase for your story exports. Here’s how to render Button in the “primary” state and export a story called 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',
},
};自定义渲染
¥Custom rendering
默认情况下,故事将渲染在 meta(默认导出)中定义的组件,并将 args 传递给它。如果你需要渲染其他内容,可以向 render 属性提供一个返回所需输出的函数。
¥By default, stories will render the component defined in the meta (default export), with the args passed to it. If you need to render something else, you can provide a function to the render property that returns the desired output.
例如,如果你想在 Alert 中渲染 Button,你可以定义一个自定义渲染函数,如下所示:
¥For example, if you want to render a Button inside an Alert, you can define a custom render function like this:
// 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>
),
};你可以通过在元级别应用渲染函数,在多个故事中重复使用它:
¥You can re-use the same render function across stories by applying it at the meta level:
// 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',
},
};你在元级别定义的任何内容都可以在故事级别被覆盖,因此你仍然可以根据需要自定义单个故事的渲染。
¥Whatever you define at the meta level can be overridden at the story level, so you can still customize the rendering of individual stories if needed.
最后,render 函数将接收第二个 context 参数,其中包含故事的所有其他详细信息,包括 parameters、globals 等。
¥Finally, render functions receive a second context argument, which contains all other details for the story, including parameters, globals, and more.
使用 React Hooks
¥Working with React Hooks
React Hooks 是使用更简化的方法创建组件的便捷辅助方法。如果需要,你可以在创建组件的故事时使用它们,尽管你应该将它们视为高级用例。我们尽可能推荐你在编写自己的故事时使用 args。作为示例,这是一个使用 React Hooks 更改按钮状态的故事:
¥React Hooks are convenient helper methods to create components using a more streamlined approach. You can use them while creating your component's stories if you need them, although you should treat them as an advanced use case. We recommend args as much as possible when writing your own stories. As an example, here’s a story that uses React Hooks to change the button's state:
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;重命名故事
¥Rename stories
默认情况下,Storybook 使用故事导出时的名称作为故事名称的基础。但是,你可以通过向故事对象添加 name 属性来自定义故事名称。当你希望为故事提供更具描述性或更易于理解的名称时,此功能非常有用。
¥By default, Storybook uses the name of the story export as the basis for the story name. However, you can customize the name of your story by adding a name property to the story object. This is useful when you want to provide a more descriptive or user-friendly name for your story.
// 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,
},
};你的故事现在将与给定的文本一起显示在侧边栏中。
¥Your story will now be shown in the sidebar with the given text.
如何撰写故事
¥How to write stories
故事是一个描述如何渲染组件的对象。每个组件可以有多个故事,这些故事可以相互构建。例如,我们可以根据上面的主要故事添加次要和第三级故事。
¥A story is an object that describes how to render a component. You can have multiple stories per component, and those stories can build upon one another. For example, we can add Secondary and Tertiary stories based on our Primary story from above.
// 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 中重新混合两个故事。
¥What’s more, you can import args to reuse when writing stories for other components, and it's helpful when you’re building composite components. For example, if we make a ButtonGroup story, we might remix two stories from its child component 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 的故事将自动更新。此模式允许你在组件层次结构中重用数据定义,使你的故事更易于维护。
¥When Button’s signature changes, you only need to change Button’s stories to reflect the new schema, and ButtonGroup’s stories will automatically be updated. This pattern allows you to reuse your data definitions across the component hierarchy, making your stories more maintainable.
这还不是全部!故事函数中的每个参数都可以使用 Storybook 的 控件 面板进行实时编辑。这意味着你的团队可以动态更改 Storybook 中的组件以进行压力测试并查找边缘情况。
¥That’s not all! Each of the args from the story function are live editable using Storybook’s Controls panel. It means your team can dynamically change components in Storybook to stress test and find edge cases.
你还可以在调整其控制值后使用“控件”面板编辑或保存新故事。
¥You can also use the Controls panel to edit or save a new story after adjusting its control values.
插件可以增强参数。例如,操作 自动检测哪些参数是回调并将日志记录函数附加到它们。这样,交互(如点击)就会记录在操作面板中。
¥Addons can enhance args. For instance, Actions auto-detects which args are callbacks and appends a logging function to them. That way, interactions (like clicks) get logged in the actions panel.
使用播放函数
¥Using the play function
Storybook 的 play 函数是一种便捷的辅助方法,可用于测试原本需要用户干预的组件场景。它们是故事渲染后执行的小代码片段。例如,假设你想要验证表单组件,你可以使用 play 函数编写以下故事,以检查组件在填写信息输入时如何响应:
¥Storybook's play function is a convenient helper methods to test component scenarios that otherwise require user intervention. They're small code snippets that execute once your story renders. For example, suppose you wanted to validate a form component, you could write the following story using the play function to check how the component responds when filling in the inputs with information:
// 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.js.org/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();
},
};你可以在 交互面板 中与故事的播放功能进行交互和调试。
¥You can interact with and debug your story's play function in the interactions panel.
使用参数
¥Using parameters
参数是 Storybook 定义故事静态元数据的方法。故事的参数可用于在故事或故事组级别为各种插件提供配置。
¥Parameters are Storybook’s method of defining static metadata for stories. A story’s parameters can be used to provide configuration to various addons at the level of a story or group of stories.
例如,假设你想针对与应用中其他组件不同的一组背景测试你的按钮组件。你可以添加组件级 backgrounds 参数:
¥For instance, suppose you wanted to test your Button component against a different set of backgrounds than the other components in your app. You might add a component-level backgrounds parameter:
// 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;
此参数将指示背景功能在选择按钮故事时重新配置自身。大多数功能和插件都是通过基于参数的 API 进行配置的,并且可以在 global、component 和 story 级别上受到影响。
¥This parameter would instruct the backgrounds feature to reconfigure itself whenever a Button story is selected. Most features and addons are configured via a parameter-based API and can be influenced at a global, component, and story level.
使用装饰器
¥Using decorators
装饰器是一种在渲染故事时将组件封装在任意标记中的机制。组件通常是在假设它们渲染的“位置”的情况下创建的。你的样式可能需要主题或布局封装器,或者你的 UI 可能需要特定的上下文或数据提供程序。
¥Decorators are a mechanism to wrap a component in arbitrary markup when rendering a story. Components are often created with assumptions about ‘where’ they render. Your styles might expect a theme or layout wrapper, or your UI might expect specific context or data providers.
一个简单的例子是向组件的故事添加填充。使用装饰器完成此操作,该装饰器将故事封装在带有填充的 div 中,如下所示:
¥A simple example is adding padding to a component’s stories. Accomplish this using a decorator that wraps the stories in a div with padding, like so:
// 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;装饰器 可能更复杂 和 addons 通常提供。你还可以在 story、component 和 global 级别配置装饰器。
¥Decorators can be more complex and are often provided by addons. You can also configure decorators at the story, component and global level.
两个或更多组件的故事
¥Stories for two or more components
有时你可能需要创建两个或多个组件来协同工作。例如,如果你有一个父 List 组件,它可能需要子 ListItem 组件。
¥Sometimes you may have two or more components created to work together. For instance, if you have a parent List component, it may require child ListItem components.
// 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 组件是有意义的。
¥In such cases, it makes sense to customize the rendering to output the List component with different numbers of ListItem children.
// 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.js.org/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 中的故事数据。这更容易维护,因为你不必在多个地方更新它。
¥You can also reuse story data from the child ListItem in your List component. That’s easier to maintain because you don’t have to update it in multiple places.
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。有关更多讨论,请参阅 多组件故事 工作流文档。
¥Note that there are disadvantages in writing stories like this as you cannot take full advantage of the args mechanism and composing args as you build even more complex composite components. For more discussion, see the multi component stories workflow documentation.
