
在 TypeScript 中编写 stories
学习如何为你的 stories 添加类型,使其更容易编写和更健壮

开发 UI 组件时,你需要在脑中记住很多细节——prop 名称、样式、状态管理、事件处理等等。通过使用 TypeScript 构建组件,你可以更好地捕获这些细节并自动化你的工作流程。你的代码编辑器会检查代码类型并提供更好的自动补全,显著节省开发时间和精力。
在 Storybook 中使用 TypeScript 也能获得同样的改进体验。这就是为什么 Microsoft、Github 和 Codecademy 等领先团队使用 TypeScript 编写 stories。
本文将向你展示如何在 TypeScript 中编写 stories。内容涵盖从基础知识到最佳实践,并提供 React、Angular 和 Vue 的代码示例。
为什么要在 TypeScript 中编写 stories?
在 TypeScript 中编写 stories 可以提高你的效率。你不必在文件之间跳转来查找组件 props。你的代码编辑器会提示你缺少必需的 props,甚至可以自动补全 prop 值。此外,Storybook 会推断这些组件类型,自动生成 ArgsTable。

使用 TypeScript 还能使你的代码更健壮。编写 stories 时,你是在模拟组件在应用中的使用方式。通过类型检查,你可以在编写代码时捕获 bug 和边缘情况。
Storybook 内置了 TypeScript 支持,因此无需任何配置即可开始使用。接下来,让我们深入探讨如何为 story 添加类型。
使用 Meta 和 Story 实用类型为 stories 添加类型
编写 stories 时,有两个方面值得类型化。首先是组件元数据 (component meta),它描述和配置组件及其 stories。在CSF 文件中,这是默认导出。其次是stories 本身。Storybook 为它们分别提供了名为 Meta
和 Story
的实用类型。以下是如何使用这些类型的一些基本示例:
React
// Button.stories.tsx
import * as React from "react";
import { Meta, Story } from "@storybook/react";
import { Button } from "./Button";
export default {
component: Button,
} as Meta;
export const Primary: Story = (args) => <Button {...args} />;
Primary.args = {
label: "Button",
primary: true,
};
对于 Angular 和 Vue 等基于模板的框架,story 的结构有所不同,但类型化策略保持不变。
Angular
// Button.stories.ts
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { CommonModule } from "@angular/common";
import { Button } from "./button.component";
export default {
component: Button,
decorators: [
moduleMetadata({
declarations: [Button],
imports: [CommonModule],
}),
],
} as Meta;
export const Primary: Story = (args) => ({
props: args,
template: `<app-button></app-button>`,
});
Primary.args = {
label: "Button",
primary: true,
};
Vue 3
// Button.stories.ts
import { Meta, Story } from "@storybook/vue3";
import Button from "./Button.vue";
export default {
component: Button,
} as Meta;
export const Primary: Story = (args) => ({
components: { Button },
setup() {
return { args };
},
template: `<Button v-bind="args" />`,
});
Primary.args = {
primary: true,
label: "Button",
};
启用更具体的类型检查
Meta
和 Story
类型都是泛型,因此你可以为它们提供 prop 类型参数。这样做,TypeScript 将防止你定义无效的 prop,并且所有装饰器、播放函数或加载器都将获得更完整的类型信息。
// Button.stories.ts
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { CommonModule } from "@angular/common";
import { Button } from "./button.component";
export default {
component: Button,
decorators: [
moduleMetadata({
declarations: [Button],
imports: [CommonModule],
}),
],
} as Meta<Button>;
export const Primary: Story<Button> = (args) => ({
props: args,
});
Primary.args = {
label: "Button",
primary: true,
size: "xl",
// ^ TypeScript error: type of `Button` does not contain `size`
};
上述代码将显示一个 TypeScript 错误,因为 Button
不支持 size 输入
为模板 story 函数添加类型
通常会提取模板函数以在多个 stories 中共享。例如,上面片段中的 story 可以写成:
const Template: Story<Button> = (args) => ({
props: args,
});
export const Primary = Template.bind({});
Primary.args = {
label: "Primary Button",
primary: true,
};
export const Secondary = Template.bind({});
Secondary.args = {
label: "Secondary Button",
primary: false,
};
在此片段中,Primary 和 Secondary stories 正在克隆 Template 函数。通过启用 strictBindCallApply
TypeScript 选项,你的 stories 可以自动继承 Template 的类型。换句话说,你不必在每个 story 上重新声明类型。你可以在 tsconfig 中启用此选项。
为自定义 args 添加类型
有时 stories 需要定义组件 props 中未包含的 args。例如,一个 List 组件可以包含 ListItem 组件,你可能希望为渲染的 ListItem 数量提供一个控件。
对于这种情况,你可以使用交叉类型来扩展你作为 args 类型变量提供的内容
// List.stories.ts
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { CommonModule } from "@angular/common";
import { List } from "./list.component";
import { ListItem } from "./list-item.component";
export default {
component: List,
subcomponents: { ListItem },
decorators: [
moduleMetadata({
declarations: [List, ListItem],
imports: [CommonModule],
}),
],
} as Meta<List>;
// Expand Story’s type variable with `& { numberOfItems: number }`
export const NumberOfItems: Story<List & { numberOfItems: number }> = ({
numberOfItems,
...args
}) => {
// Generate an array of item labels, with length equal to the numberOfItems
const itemLabels = [...Array(numberOfItems)].map(
(_, index) => `Item ${index + 1}`
);
return {
// Pass the array of item labels to the template
props: { ...args, itemLabels },
// Iterate over those labels to render each ListItem
template: `<app-list>
<div *ngFor="let label of itemLabels">
<app-list-item [label]="label"></app-list-item>
</div>
</app-list>`,
};
};
NumberOfItems.args = {
numberOfItems: 1,
};
NumberOfItems.argTypes = {
numberOfItems: {
name: "Number of ListItems",
options: [1, 2, 3, 4, 5],
control: { type: "inline-radio" },
},
};

React 特有的实用类型
React 组件通常不导出其 props 的类型。因此,Storybook for React 暴露了 ComponentMeta
和 ComponentStory
类型。它们等同于 Meta
和 Story
泛型类型,但可以从组件本身推断 props 类型。
// Button.stories.tsx
import * as React from "react";
import { ComponentMeta, ComponentStory } from "@storybook/react";
import { Button } from "./Button";
export default {
component: Button,
} as ComponentMeta<typeof Button>;
export const Primary: ComponentStory<typeof Button> = (args) => (
<Button {...args} />
);
Primary.args = {
label: "Button",
primary: true,
};
这些实用工具使用 typeof
运算符来推断组件的 prop 类型。因此,它们不能用于容纳自定义 args,如前一节所示。
相反,你可以使用 Meta
和 Story
类型以及 React 的 ComponentProps
实用工具。例如:
// Button.stories.tsx
import * as React from "react";
import { Meta, Story } from "@storybook/react";
import { Button } from "./Button";
type ButtonProps = React.ComponentProps<typeof Button>;
export default {
component: Button,
} as Meta<ButtonProps>;
export const WithCustomArg: Story<ButtonProps & { customArg: number }> = (args) => (
<Button {...args} />
);
Primary.args = {
label: "Button",
primary: true,
customArg: 3,
};
Storybook v7 中的变化
上述所有片段都采用 CSF 2 格式编写。Storybook 6.3 引入了 CSF 3,这是一种更紧凑、更易组合的 stories 编写方式。这是第一个片段,重写为 CSF 3:
// Button.stories.ts
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { CommonModule } from "@angular/common";
import { Button } from './button.component';
export default {
component: Button,
decorators: [
moduleMetadata({
declarations: [Button],
imports: [CommonModule],
}),
],
} as Meta<Button>;
export const Primary: StoryObj<Button> = {
args: {
label: "Button",
primary: true,
},
};
请注意 StoryObj
实用类型。在 Storybook 7.0 中,CSF 3 将成为默认格式,StoryObj
类型将重命名为 Story
(以匹配其默认性质),而用于 CSF 2 的类型将从 Story
重命名为 StoryFn
。
关于 ComponentStoryObj
、ComponentStory
和 ComponentStoryFn
也存在相应的变化。
最后,CSF 3 版本的类型仍然是泛型,根据类型接受组件或其 args 的类型变量。
Storybook 7.0 的文档已经更新以反映这些变化。
总结
在 TypeScript 中编写 stories 使开发更健壮的组件变得更容易。你可以获得类型安全和错误检查、自动补全建议等等。
Storybook 提供零配置的 TypeScript 支持。你可以进一步自定义此设置以更好地满足你的需求。此外,Storybook 文档中的所有代码片段都提供了 JavaScript 和 TypeScript 版本。

如果你想继续讨论,请加入 Storybook Discord 中的 #typescript
频道。我在那里等你!
在 @typescript 中编写 stories 可以让你更高效。
— Storybook (@storybookjs) August 4, 2022
你的 IDE 会在你编写代码时捕获错误,并且你将获得更好的自动补全!
查看我们的新指南,学习如何为 stories 添加类型。它涵盖了 Storybook 实用类型和编写复杂 stories 的最佳实践。https://#/HF8pvlqIqQ