返回博客

用 TypeScript 编写 Stories

了解如何为您的 Story 添加类型,使它们更容易编写且更健壮

loading
Kyle Gach
@kylegach
最后更新

在开发 UI 组件时,你需要记住很多细节——属性名称、样式、状态管理、事件处理程序等等。通过使用 TypeScript 构建组件,你可以更好地捕捉这些细节并自动化你的工作流程。你的代码编辑器将对代码进行类型检查并提供更好的自动完成,从而节省大量开发时间和精力。

当将 TypeScript 与 Storybook 结合使用时,你将获得相同的改进的人体工程学。这就是为什么 Microsoft、Github 和 Codecademy 等领先团队使用 TypeScript 来编写 stories。

本文将向你展示如何使用 TypeScript 编写 stories。它涵盖了从基础到最佳实践的所有内容,并附有 React、Angular 和 Vue 的代码示例。

为什么用 TypeScript 编写 stories?

用 TypeScript 编写 stories 可以提高你的生产力。你不必在文件之间跳转来查找组件属性。你的代码编辑器会提示你缺少必需的属性,甚至可以自动完成属性值。此外,Storybook 会推断这些组件类型以自动生成 ArgsTable

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

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

Storybook 内置了 TypeScript 支持,因此你可以开始使用,无需任何配置。让我们深入探讨类型化 story 的具体细节。

使用 Meta 和 Story 工具类型键入 stories

编写 stories 时,有两个方面键入很有帮助。第一个是 组件元数据,它描述和配置组件及其 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 类型都是 泛型,因此你可以为它们提供一个属性类型参数。通过这样做,TypeScript 将阻止你定义无效属性,并且所有 装饰器play 函数loader 都将得到更全面的类型。

// 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 需要定义不包含在组件属性中的 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 组件通常不导出其属性的类型。因此,Storybook for React 公开了 ComponentMetaComponentStory 类型。它们等同于 MetaStory 泛型类型,但可以从组件本身推断属性类型。

// 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 运算符来推断组件的属性类型。因此,它们不能用于处理自定义 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,468开发者及更多

我们正在招聘!

加入 Storybook 和 Chromatic 团队。构建被数十万开发人员在生产中使用的工具。远程优先。

查看职位

热门帖子

7.0 设计 Alpha 版

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

为什么选择 Storybook(2022年)?

关于 Storybook 的所有热议
loading
Dominic Nguyen

Storybook 7.0 设计预览

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

特别感谢 Netlify CircleCI