返回博客

用 TypeScript 编写 Story

了解如何为你的 story 添加类型,使其更易于编码且更健壮

loading
Kyle Gach
@kylegach
最后更新

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

当将 TypeScript 与 Storybook 结合使用时,你也会获得同样改进的人体工程学。这就是为什么像 Microsoft、Github 和 Codecademy 这样的领先团队使用 TypeScript 编写 story 的原因。

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

为什么要用 TypeScript 编写 story?

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

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

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

Storybook 具有内置的 TypeScript 支持,因此你可以零配置入门。让我们深入了解 story 类型化的具体细节。

使用 Meta 和 Story 实用程序类型对 story 进行类型化

在编写 story 时,有两个方面有助于类型化。第一个是 组件元数据,它描述和配置组件及其 story。在 CSF 文件中,这是默认导出。第二个是 story 本身。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 函数加载器 都将进行更完整的类型化。

// 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 函数添加类型

通常会 提取模板函数 以在多个 story 之间共享。例如,上面代码段中的 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 story 正在克隆 Template 函数。通过启用 strictBindCallApply TypeScript 选项,你的 story 可以自动继承 Template 的类型。换句话说,你无需在每个 story 上重新声明类型。你可以在你的 tsconfig 中启用此选项。

为自定义 args 添加类型

有时,story 需要定义组件属性中未包含的 args。例如,List 组件可以包含 ListItem 组件,你可能希望为渲染的 ListItems 数量提供一个 控件

对于这种情况,你可以使用 交叉类型 来扩展你作为 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 组件通常不导出其属性的类型。因此,React 的 Storybook 公开了 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,这是一种更紧凑和可组合的 story 编写方式。这是第一个代码段,重写为 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 编写 story 可以更轻松地开发更健壮的组件。你获得了类型安全和错误检查、自动完成建议等等。

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 邮件列表

获取最新新闻、更新和发布

6,730开发者人数统计中

我们正在招聘!

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

查看职位

热门文章

7.0 设计 alpha 版

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

2022 年为什么要使用 Storybook?

关于 Storybook 的所有 fuss 是什么
loading
Dominic Nguyen

Storybook 7.0 设计先睹为快

视觉更新、UX 调整和更快的性能
loading
Dominic Nguyen
加入社区
6,730开发者人数统计中
为什么为什么选择 Storybook组件驱动的 UI
文档指南教程更新日志遥测
社区插件参与进来博客
展示探索项目组件词汇表
开源软件
Storybook - Storybook 中文

特别鸣谢 Netlify CircleCI