返回博客

在 TypeScript 中编写 stories

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

loading
Kyle Gach
@kylegach
最后更新

开发 UI 组件时,你需要在脑中记住很多细节——prop 名称、样式、状态管理、事件处理等等。通过使用 TypeScript 构建组件,你可以更好地捕获这些细节并自动化你的工作流程。你的代码编辑器会检查代码类型并提供更好的自动补全,显著节省开发时间和精力。

在 Storybook 中使用 TypeScript 也能获得同样的改进体验。这就是为什么 Microsoft、Github 和 Codecademy 等领先团队使用 TypeScript 编写 stories。

本文将向你展示如何在 TypeScript 中编写 stories。内容涵盖从基础知识到最佳实践,并提供 React、Angular 和 Vue 的代码示例。

为什么要在 TypeScript 中编写 stories?

在 TypeScript 中编写 stories 可以提高你的效率。你不必在文件之间跳转来查找组件 props。你的代码编辑器会提示你缺少必需的 props,甚至可以自动补全 prop 值。此外,Storybook 会推断这些组件类型,自动生成 ArgsTable

Screenshot of code editor showing a TypeScript error in a stories file

使用 TypeScript 还能使你的代码更健壮。编写 stories 时,你是在模拟组件在应用中的使用方式。通过类型检查,你可以在编写代码时捕获 bug 和边缘情况。

Storybook 内置了 TypeScript 支持,因此无需任何配置即可开始使用。接下来,让我们深入探讨如何为 story 添加类型。

使用 Meta 和 Story 实用类型为 stories 添加类型

编写 stories 时,有两个方面值得类型化。首先是组件元数据 (component meta),它描述和配置组件及其 stories。在CSF 文件中,这是默认导出。其次是stories 本身。Storybook 为它们分别提供了名为 MetaStory 的实用类型。以下是如何使用这些类型的一些基本示例:

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",
};

启用更具体的类型检查

MetaStory 类型都是泛型,因此你可以为它们提供 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" },
  },
};
Animation of changing the control for the number of ListItems and watching the story update

React 特有的实用类型

React 组件通常不导出其 props 的类型。因此,Storybook for React 暴露了 ComponentMetaComponentStory 类型。它们等同于 MetaStory 泛型类型,但可以从组件本身推断 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,如前一节所示。

相反,你可以使用 MetaStory 类型以及 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

关于 ComponentStoryObjComponentStoryComponentStoryFn 也存在相应的变化。

最后,CSF 3 版本的类型仍然是泛型,根据类型接受组件或其 args 的类型变量。

Storybook 7.0 的文档已经更新以反映这些变化。

总结

在 TypeScript 中编写 stories 使开发更健壮的组件变得更容易。你可以获得类型安全和错误检查、自动补全建议等等。

Storybook 提供零配置的 TypeScript 支持。你可以进一步自定义此设置以更好地满足你的需求。此外,Storybook 文档中的所有代码片段都提供了 JavaScript 和 TypeScript 版本。

Screenshot of CSF file written in TypeScript from https://storybook.org.cn/docs/react/writing-stories/introduction

如果你想继续讨论,请加入 Storybook Discord 中的 #typescript 频道。我在那里等你!

加入 Storybook 邮件列表

获取最新消息、更新和发布

7,180名开发者及更多

我们正在招聘!

加入 Storybook 和 Chromatic 背后的团队。构建被数十万开发者用于生产环境的工具。远程优先。

查看职位

热门文章

7.0 design alpha

试用新的布局、图标和性能
loading
Dominic Nguyen

为什么在 2022 年选择 Storybook?

Storybook 为何备受关注?
loading
Dominic Nguyen

Storybook 7.0 设计先睹为快

视觉更新、UX 调整和更快的性能
loading
Dominic Nguyen
加入社区
7,180名开发者及更多
原因为什么选择 Storybook组件驱动 UI
文档指南教程更新日志遥测
社区插件参与其中博客
案例展示探索项目组件词汇表
开源软件
Storybook - Storybook 中文

特别感谢 Netlify CircleCI