返回Storybook 简介
章节
  • 开始上手
  • 简单的组件
  • 复合组件
  • 数据
  • 页面
  • 部署
  • 视觉测试
  • 可访问性测试
  • 结论
  • 贡献

构建一个简单的组件

独立构建简单的组件

我们将遵循组件驱动开发(CDD)方法来构建我们的 UI。这是一个“自下而上”构建 UI 的过程,从组件开始,以屏幕结束。CDD 有助于扩展你在构建 UI 时面临的复杂性。

任务

Task component in three states

Task 是我们应用程序的核心组件。每个任务根据其所处的状态显示略有不同。我们显示一个已选中(或未选中)的复选框、有关任务的一些信息以及一个“固定”按钮,允许我们将任务在列表中上下移动。将这些组合起来,我们将需要以下 props:

  • title – 描述任务的字符串
  • state - 任务当前在哪一个列表中,以及是否已选中?

当我们开始构建 Task 时,我们首先编写对应于上面草图的不同任务类型的测试状态。然后,我们使用 Storybook 使用模拟数据在隔离环境中构建组件。我们将逐步“视觉测试”组件在每种状态下的外观。

准备就绪

首先,让我们创建任务组件及其配套的故事文件:src/app/components/task.component.tssrc/app/components/task.stories.ts

我们将从 Task 组件的基线实现开始,该实现仅接收我们知道需要的输入以及您可以对任务执行的两个操作(在列表之间移动它们)

复制
src/app/components/task.component.ts
import { CommonModule } from '@angular/common';
import { Component, Input, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'app-task',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="list-item">
      <label [attr.aria-label]="task.title + ''" for="title">
        <input
          type="text"
          [value]="task.title"
          readonly="true"
          id="title"
          name="title"
        />
      </label>
    </div>
  `
})

export class TaskComponent {
  /**
  * The shape of the task object
 */
  @Input() task: any;

  @Output()
  onPinTask = new EventEmitter<Event>();

  @Output()
  onArchiveTask = new EventEmitter<Event>();
}

上面,我们根据 Todos 应用程序的现有 HTML 结构渲染了 Task 组件的简单标记。

下面,我们在故事文件中构建 Task 的三个测试状态。

复制
src/app/components/task.stories.ts
import type { Meta, StoryObj } from '@storybook/angular';

import { fn } from 'storybook/test';

import { TaskComponent } from './task.component';

export const TaskData = {
  id: '1',
  title: 'Test Task',
  state: 'TASK_INBOX',
  events: {
    onArchiveTask: fn(),
    onPinTask: fn(),
  },
};

const meta: Meta<TaskComponent> = {
  title: 'Task',
  component: TaskComponent,
  //👇 Our exports that end in "Data" are not stories.
  excludeStories: /.*Data$/,
  tags: ['autodocs'],
  args: {
    ...TaskData.events
  },
};


export default meta;
type Story = StoryObj<TaskComponent>;

export const Default: Story = {
  args: {
    task: TaskData,
  },
}

export const Pinned: Story = {
  args: {
    task: {
      ...Default.args?.task,
      state: 'TASK_PINNED',
    },
  },
}

export const Archived: Story = {
  args: {
    task: {
      ...Default.args?.task,
      state: 'TASK_ARCHIVED',
    },
  },
}

💡 Actions 帮助你在隔离环境中构建 UI 组件时验证交互。通常你无法访问应用程序上下文中的函数和状态。使用 fn() 来模拟它们。

Storybook 有两个基本的组织级别:组件及其子故事。将每个故事视为组件的排列。你可以根据需要为每个组件创建任意数量的故事。

  • 组件
    • 故事
    • 故事
    • 故事

为了让 Storybook 了解我们正在测试的组件,我们创建一个 default 导出,其中包含

  • component -- 组件本身
  • title -- 如何在 Storybook 侧边栏中对组件进行分组或分类
  • tags -- 自动为我们的组件生成文档
  • excludeStories-- 故事所需的额外信息,但不应在 Storybook 中呈现
  • args -- 定义组件期望的 action args 来模拟自定义事件。

为了定义我们的故事,我们将使用 Component Story Format 3(也称为 CSF3)来构建我们的每个测试用例。这种格式旨在以简洁的方式构建我们的每个测试用例。通过导出包含每个组件状态的对象,我们可以更直观地定义我们的测试,并更有效地编写和重用故事。

参数或简称为 args,允许我们在不重启 Storybook 的情况下使用 controls 插件实时编辑我们的组件。一旦 args 值发生变化,组件也会随之变化。

fn() 允许我们创建一个回调函数,当点击时会在 Storybook UI 的 **Actions** 面板中显示。因此,当我们构建一个固定按钮时,我们就能在 UI 中判断按钮点击是否成功。

由于我们需要将相同的操作集传递给我们组件的所有排列组合,因此将它们打包到一个 TaskData 变量中并在每次将它们传递到我们的故事定义中会很方便。将组件需要的 TaskData 打包的另一个好处是您可以 export 它们并在重用此组件的组件的故事中使用它们,我们稍后会看到。

配置

我们还需要对 Storybook 配置进行一个小更改,以识别我们最近创建的故事。将您的配置文件(.storybook/main.ts)更改为以下内容

复制
.storybook/main.ts
import type { StorybookConfig } from '@storybook/angular';

const config: StorybookConfig = {
- stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
+ stories: ['../src/app/components/**/*.stories.ts'],
  addons: ['@storybook/addon-docs'],
  staticDirs: ['../public'],
  framework: {
    name: '@storybook/angular',
    options: {},
  },
};

export default config;

完成此操作后,重新启动 Storybook 服务器应该会显示三种 Task 状态的测试用例。

构建状态

现在我们已经设置好了 Storybook,导入了样式,并构建了测试用例,我们可以快速开始实现组件的 HTML,以匹配设计。

目前该组件仍然很粗糙。首先,编写实现设计的代码,但不要过于详细。

复制
src/app/components/task.component.ts
import { CommonModule } from '@angular/common';
import { Component, Input, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'app-task',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="list-item {{ task?.state }}">
      <label
        [attr.aria-label]="'archiveTask-' + task?.id"
        for="checked-{{ task?.id }}"
        class="checkbox"
      >
        <input
          type="checkbox"
          disabled="true"
          [defaultChecked]="task?.state === 'TASK_ARCHIVED'"
          name="checked-{{ task?.id }}"
          id="checked-{{ task?.id }}"
        />
        <span class="checkbox-custom" (click)="onArchive(task?.id)"></span>
      </label>
      <label
        [attr.aria-label]="task?.title + ''"
        for="title-{{ task?.id }}"
        class="title"
      >
        <input
          type="text"
          [value]="task?.title"
          readonly="true"
          id="title-{{ task?.id }}"
          name="title-{{ task?.id }}"
          placeholder="Input title"
        />
      </label>
      <button
        *ngIf="task?.state !== 'TASK_ARCHIVED'"
        class="pin-button"
        [attr.aria-label]="'pinTask-' + task?.id"
        (click)="onPin(task?.id)"
      >
        <span class="icon-star"></span>
      </button>
    </div>
  `,
})

export class TaskComponent {
  /**
  * The shape of the task object
 */
  @Input() task: any;

  /**
   * Event handler for pinning tasks
   */
  @Output()
  onPinTask = new EventEmitter<Event>();

  /**
   * Event handler for archiving tasks
   */
  @Output()
  onArchiveTask = new EventEmitter<Event>();

  /**
   * @ignore
   * Component method to trigger the onPin event
   * @param id string
   */
  onPin(id: any) {
    this.onPinTask.emit(id);
  }
  /**
   * @ignore
   * Component method to trigger the onArchive event
   * @param id string
   */
  onArchive(id: any) {
    this.onArchiveTask.emit(id);
  }
}

上述附加标记与我们现有的 CSS(请参阅 src/styles.cssangular.json 进行配置)相结合,可产生以下 UI

指定数据要求

在我们继续构建组件时,可以通过定义 TypeScript 类型来指定 Task 组件期望的数据形状。这样,我们可以及早捕获错误并确保在添加更多复杂性时正确使用组件。首先在 src/app 文件夹中创建一个 types.ts 文件,并将我们现有的 TaskData 类型移到那里

复制
src/app/types.ts
export type TaskData = {
  id?: string;
  title?: string;
  state?: string;
};

然后,更新 Task 组件以使用我们新创建的类型。

复制
src/app/components/task.component.ts
import type { TaskData } from '../types';

import { CommonModule } from '@angular/common';
import { Component, Input, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'app-task',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="list-item {{ task?.state }}">
      <label
        [attr.aria-label]="'archiveTask-' + task?.id"
        for="checked-{{ task?.id }}"
        class="checkbox"
      >
        <input
          type="checkbox"
          disabled="true"
          [defaultChecked]="task?.state === 'TASK_ARCHIVED'"
          name="checked-{{ task?.id }}"
          id="checked-{{ task?.id }}"
        />
        <span class="checkbox-custom" (click)="onArchive(task?.id)"></span>
      </label>
      <label
        [attr.aria-label]="task?.title + ''"
        for="title-{{ task?.id }}"
        class="title"
      >
        <input
          type="text"
          [value]="task?.title"
          readonly="true"
          id="title-{{ task?.id }}"
          name="title-{{ task?.id }}"
          placeholder="Input title"
        />
      </label>
      <button
        *ngIf="task?.state !== 'TASK_ARCHIVED'"
        class="pin-button"
        [attr.aria-label]="'pinTask-' + task?.id"
        (click)="onPin(task?.id)"
      >
        <span class="icon-star"></span>
      </button>
    </div>
  `,
})

export class TaskComponent {
  /**
  * The shape of the task object
 */
  @Input() task?: TaskData;

  /**
   * Event handler for pinning tasks
   */
  @Output()
  onPinTask = new EventEmitter<Event>();

  /**
   * Event handler for archiving tasks
   */
  @Output()
  onArchiveTask = new EventEmitter<Event>();

  /**
   * @ignore
   * Component method to trigger the onPin event
   * @param id string
   */
  onPin(id: any) {
    this.onPinTask.emit(id);
  }
  /**
   * @ignore
   * Component method to trigger the onArchive event
   * @param id string
   */
  onArchive(id: any) {
    this.onArchiveTask.emit(id);
  }
}

组件构建完成!

我们现在已经成功地在不需要服务器或运行整个前端应用程序的情况下构建了一个组件。下一步是继续以类似的方式一个接一个地构建剩余的 Taskbox 组件。

正如你所见,在隔离环境中开始构建组件既简单又快速。由于我们可以深入测试每一种可能的状态,因此我们可以生产出质量更高、错误更少、更完善的 UI。

💡 别忘了使用 git 提交你的更改!
同步本章代码。在 GitHub 上查看 a4bf2a8。
这个免费指南有帮助吗?发推文致谢并帮助其他开发者找到它。
下一章
复合组件
从更简单的组件组合成复合组件
✍️ 在 GitHub 上编辑 – PR 欢迎!
加入社区
7,424开发者及更多
原因为什么选择 Storybook组件驱动的 UI
文档指南教程更新日志遥测
社区插件参与进来博客
展示探索关于
开源软件
Storybook - Storybook 中文

特别感谢 Netlify CircleCI