返回Storybook 入门
章节
  • 开始
  • 简单组件
  • 复合组件
  • 数据
  • 屏幕
  • 部署
  • 可视化测试
  • 插件
  • 总结
  • 贡献

构建一个屏幕

用组件构建屏幕

我们专注于自底向上构建 UI,从小处着手逐步增加复杂性。这样做使我们能够独立开发每个组件,确定其数据需求,并在 Storybook 中进行尝试。所有这些都无需搭建服务器或构建屏幕!

在本章节中,我们将通过在一个屏幕中组合组件并在 Storybook 中开发该屏幕来继续提升其复杂性。

嵌套容器组件

由于我们的应用很简单,我们将构建的屏幕也相当基础,只需将 TaskList 组件(通过 ngxs 提供自身数据)包装在某个布局中,并从我们的 store 中提取顶层 error 字段(假设如果连接服务器出现问题,我们将设置该字段)。

首先,我们来更新我们的 store(在 src/app/state/task.state.ts 中),包含我们想要的 error 字段

复制
src/app/state/task.state.ts
import { Injectable } from '@angular/core';
import { State, Selector, Action, StateContext } from '@ngxs/store';
import { patch, updateItem } from '@ngxs/store/operators';
import { Task } from '../models/task.model';

// Defines the actions available to the app
export const actions = {
  ARCHIVE_TASK: 'ARCHIVE_TASK',
  PIN_TASK: 'PIN_TASK',
+ ERROR: 'APP_ERROR',
};

export class ArchiveTask {
  static readonly type = actions.ARCHIVE_TASK;

  constructor(public payload: string) {}
}

export class PinTask {
  static readonly type = actions.PIN_TASK;

  constructor(public payload: string) {}
}
+ // The class definition for our error field
+ export class AppError {
+   static readonly type = actions.ERROR;
+   constructor(public payload: boolean) {}
+ }

// The initial state of our store when the app loads.
// Usually you would fetch this from a server
const defaultTasks = [
  { id: '1', title: 'Something', state: 'TASK_INBOX' },
  { id: '2', title: 'Something more', state: 'TASK_INBOX' },
  { id: '3', title: 'Something else', state: 'TASK_INBOX' },
  { id: '4', title: 'Something again', state: 'TASK_INBOX' },
];

export interface TaskStateModel {
  tasks: Task[];
  status: 'idle' | 'loading' | 'success' | 'error';
  error: boolean;
}

// Sets the default state
@State<TaskStateModel>({
  name: 'taskbox',
  defaults: {
    tasks: defaultTasks,
    status: 'idle',
    error: false,
  },
})
@Injectable()
export class TasksState {
  // Defines a new selector for the error field
  @Selector()
  static getError(state: TaskStateModel): boolean {
    return state.error;
  }

  @Selector()
  static getAllTasks(state: TaskStateModel): Task[] {
    return state.tasks;
  }

  // Triggers the PinTask action, similar to redux
  @Action(PinTask)
  pinTask(
    { getState, setState }: StateContext<TaskStateModel>,
    { payload }: PinTask
  ) {
    const task = getState().tasks.find((task) => task.id === payload);

    if (task) {
      const updatedTask: Task = {
        ...task,
        state: 'TASK_PINNED',
      };
      setState(
        patch({
          tasks: updateItem<Task>(
            (pinnedTask) => pinnedTask?.id === payload,
            updatedTask
          ),
        })
      );
    }
  }
  // Triggers the archiveTask action, similar to redux
  @Action(ArchiveTask)
  archiveTask(
    { getState, setState }: StateContext<TaskStateModel>,
    { payload }: ArchiveTask
  ) {
    const task = getState().tasks.find((task) => task.id === payload);
    if (task) {
      const updatedTask: Task = {
        ...task,
        state: 'TASK_ARCHIVED',
      };
      setState(
        patch({
          tasks: updateItem<Task>(
            (archivedTask) => archivedTask?.id === payload,
            updatedTask
          ),
        })
      );
    }
  }
+ // Function to handle how the state should be updated when the action is triggered
+ @Action(AppError)
+ setAppError(
+   { patchState, getState }: StateContext<TaskStateModel>,
+   { payload }: AppError
+ ) {
+   const state = getState();
+   patchState({
+     error: !state.error,
+   });
+ }
}

现在我们已经更新了 store 并添加了新字段。让我们在 src/app/components/ 目录中创建 pure-inbox-screen.component.ts

复制
src/app/components/pure-inbox-screen.component.ts
import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-pure-inbox-screen',
  standalone: false,
  template: `
    <div *ngIf="error" class="page lists-show">
      <div class="wrapper-message">
        <span class="icon-face-sad"></span>
        <p class="title-message">Oh no!</p>
        <p class="subtitle-message">Something went wrong</p>
      </div>
    </div>

    <div *ngIf="!error" class="page lists-show">
      <nav>
        <h1 class="title-page">Taskbox</h1>
      </nav>
      <app-task-list></app-task-list>
    </div>
  `,
})
export default class PureInboxScreenComponent {
  @Input() error: any;
}

然后,我们可以创建一个容器,它再次在 inbox-screen.component.ts 中抓取 PureInboxScreen 组件的数据

复制
src/app/components/inbox-screen.component.ts
import { Component } from '@angular/core';
import { Store } from '@ngxs/store';
import { Observable } from 'rxjs';

@Component({
  selector: 'app-inbox-screen',
  standalone: false,
  template: `
    <app-pure-inbox-screen [error]="error$ | async"></app-pure-inbox-screen>
  `,
})
export default class InboxScreenComponent {
  error$: Observable<boolean>;
  constructor(private store: Store) {
    this.error$ = store.select((state) => state.taskbox.error);
  }
}

我们还需要修改 AppComponent 组件来渲染 InboxScreen 组件(最终,我们将使用路由器来选择正确的屏幕,但在这里我们暂时不考虑这一点)

复制
src/app/app.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  standalone: false,
- templateUrl: './app.component.html',
- styleUrls: ['./app.component.css']
+ template: `
+   <app-inbox-screen></app-inbox-screen>
+ `,
})
export class AppComponent {
  title = 'taskbox';
}

最后,是 app.module.ts

复制
src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { TaskModule } from './components/task.module';
import { NgxsModule } from '@ngxs/store';
import { NgxsReduxDevtoolsPluginModule } from '@ngxs/devtools-plugin';
import { NgxsLoggerPluginModule } from '@ngxs/logger-plugin';

import { environment } from '../environments/environment';
import { AppComponent } from './app.component';

+ import InboxScreenComponent from './components/inbox-screen.component';
+ import PureInboxScreenComponent from './components/pure-inbox-screen.component';

@NgModule({
+ declarations: [AppComponent, InboxScreenComponent, PureInboxScreenComponent],
  imports: [
    BrowserModule,
    TaskModule,
    NgxsModule.forRoot([], { developmentMode: !environment.production, }),
    NgxsReduxDevtoolsPluginModule.forRoot(),
    NgxsLoggerPluginModule.forRoot({ disabled: environment.production, }),
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

然而,有趣的地方在于在 Storybook 中渲染 story。

正如我们之前看到的,TaskList 组件是一个容器,它渲染 PureTaskList 展示型组件。根据定义,容器组件不能独立简单地渲染;它们需要被传入一些上下文或连接到服务。这意味着要在 Storybook 中渲染容器,我们必须模拟(即提供一个假版本)它所需的上下文或服务。

在将 TaskList 放入 Storybook 时,我们通过简单地渲染 PureTaskList 并避开容器来规避了这个问题。我们将做类似的事情,在 Storybook 中也渲染 PureInboxScreen

然而,PureInboxScreen 组件存在一个问题,尽管 PureInboxScreen 本身是展示型的,但它的子组件 TaskList 不是。从某种意义上说,PureInboxScreen 被“容器性”污染了。所以当我们在 pure-inbox-screen.stories.ts 中设置 stories 时

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

import { moduleMetadata } from '@storybook/angular';

import { CommonModule } from '@angular/common';

import PureInboxScreenComponent from './pure-inbox-screen.component';

import { TaskModule } from './task.module';

const meta: Meta<PureInboxScreenComponent> = {
  component: PureInboxScreenComponent,
  title: 'PureInboxScreen',
  tags: ['autodocs'],
  decorators: [
    moduleMetadata({
      imports: [CommonModule, TaskModule],
    }),
  ],
};

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

export const Default: Story = {};

export const Error: Story = {
  args: {
    error: true,
  },
};

我们发现所有的 stories 都无法工作了。这是因为它们都依赖于我们的 store,即使我们对 error 使用的是“纯”组件,这两个 stories 仍然需要上下文。

Broken inbox

规避这个问题的一种方法是永远不要在应用中的最高层级之外的任何地方渲染容器组件,而是将所有数据需求沿着组件层级向下传递。

然而,开发者不可避免地需要在组件层级更深的地方渲染容器。如果想在 Storybook 中渲染大部分或全部应用(我们确实想!),我们需要解决这个问题。

💡 顺便说一句,沿着层级向下传递数据是一种合法的方法,特别是在使用 GraphQL 时。我们就是这样构建了 Chromatic,并拥有 800 多个 stories。

使用 decorators 提供上下文

好消息是,在 story 中为 PureInboxScreen 组件提供 Store 非常容易!我们可以在 decorator 中导入我们的 Store,并通过 applicationConfig API 启用它并将其传递给 PureInboxScreen 组件。

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

+ import { importProvidersFrom } from '@angular/core';

+ import { Store, NgxsModule } from '@ngxs/store';
+ import { TasksState } from '../state/task.state';

+ import { moduleMetadata, applicationConfig } from '@storybook/angular';

import { CommonModule } from '@angular/common';

import PureInboxScreenComponent from './pure-inbox-screen.component';

import { TaskModule } from './task.module';

const meta: Meta<PureInboxScreenComponent> = {
  component: PureInboxScreenComponent,
  title: 'PureInboxScreen',
  tags: ['autodocs'],
  decorators: [
    moduleMetadata({
      imports: [CommonModule, TaskModule],
    }),
+   applicationConfig({
+     providers: [Store, importProvidersFrom(NgxsModule.forRoot([TasksState]))],
    }),
  ],
};

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

export const Default: Story = {};

export const Error: Story = {
  args: {
    error: true,
  },
};

对于其他数据库,如 @ngrxApollo,也存在提供模拟上下文的类似方法。

在 Storybook 中切换不同状态可以轻松测试我们是否正确完成了这项工作

组件测试

到目前为止,我们已经能够从头开始构建一个功能齐全的应用,从一个简单的组件到整个屏幕,并使用 stories 持续测试每次更改。但是每个新的 story 也需要手动检查所有其他 stories,以确保 UI 没有损坏。这会增加很多额外的工作。

难道我们不能自动化这个工作流程并自动测试组件交互吗?

使用 play 函数编写组件测试

Storybook 的 play@storybook/addon-interactions 可以帮助我们实现这一点。play 函数包含在 story 渲染后运行的小段代码。

play 函数帮助我们验证当任务更新时 UI 会发生什么。它使用与框架无关的 DOM API,这意味着无论前端框架是什么,我们都可以使用 play 函数编写 stories 来与 UI 交互并模拟人类行为。

@storybook/addon-interactions 帮助我们在 Storybook 中可视化测试,提供一个分步流程。它还提供了一套方便的 UI 控件,可以暂停、继续、回退和单步执行每个交互。

让我们看看它的实际应用!更新你新创建的 pure-inbox-screen story,并通过添加以下内容设置组件交互

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

import { importProvidersFrom } from '@angular/core';

import { Store, NgxsModule } from '@ngxs/store';
import { TasksState } from '../state/task.state';

import { moduleMetadata, applicationConfig } from '@storybook/angular';

+ import { fireEvent, within } from '@storybook/test';

import { CommonModule } from '@angular/common';

import PureInboxScreenComponent from './pure-inbox-screen.component';

import { TaskModule } from './task.module';

const meta: Meta<PureInboxScreenComponent> = {
  component: PureInboxScreenComponent,
  title: 'PureInboxScreen',
  tags: ['autodocs'],
  decorators: [
    moduleMetadata({
      imports: [CommonModule, TaskModule],
    }),
    applicationConfig({
      providers: [Store, importProvidersFrom(NgxsModule.forRoot([TasksState]))],
    }),
  ],
};

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

export const Default: Story = {};

export const Error: Story = {
  args: {
    error: true,
  },
};

+ export const WithInteractions: Story = {
+   play: async ({ canvasElement }) => {
+     const canvas = within(canvasElement);
+     // Simulates pinning the first task
+     await fireEvent.click(canvas.getByLabelText('pinTask-1'));
+     // Simulates pinning the third task
+     await fireEvent.click(canvas.getByLabelText('pinTask-3'));
+   },
+ };

💡 @storybook/test 包取代了 @storybook/jest@storybook/testing-library 测试包,提供了更小的包体积和基于 Vitest 包的更直接的 API。

检查你新创建的 story。点击 Interactions 面板,查看 story 的 play 函数中的交互列表。

使用 test runner 自动化测试

有了 Storybook 的 play 函数,我们能够规避之前的问题,允许我们与 UI 交互并快速检查更新任务时它的响应方式——这使得 UI 保持一致,无需额外的F手动工作。

但是,如果我们仔细查看 Storybook,会发现它只在查看 story 时运行组件测试。因此,如果进行更改,我们仍然必须遍历每个 story 来运行所有检查。难道不能自动化吗?

好消息是我们可以!Storybook 的 test runner 正是为此而生。它是一个独立的实用工具——由 Playwright 提供支持——可以运行所有交互测试并捕获损坏的 stories。

我们来看看它是如何工作的!运行以下命令进行安装

复制
npm install @storybook/test-runner --save-dev

接下来,更新你的 package.json 中的 scripts,并添加一个新的测试任务

{
  "scripts": {
    "test-storybook": "test-storybook"
  }
}

最后,在 Storybook 运行时,打开一个新的终端窗口并运行以下命令

复制
npm run test-storybook -- --url http://localhost:6006/ -- --watch

💡 使用 play 函数进行组件测试是测试 UI 组件的一种绝佳方式。它可以做的比我们在这里看到的要多得多;我们建议阅读官方文档了解更多信息。

要更深入地了解测试,请查看测试手册。它涵盖了大型前端团队使用的测试策略,以提升你的开发工作流程。

Storybook test runner successfully runs all tests

成功了!现在我们有一个工具可以帮助我们自动验证所有 stories 是否无错误渲染,并且所有断言都通过。更重要的是,如果测试失败,它会提供一个链接,在浏览器中打开失败的 story。

组件驱动开发

我们从最底层的 Task 开始,然后进展到 TaskList,现在我们拥有了整个屏幕 UI。我们的 InboxScreen 包含一个嵌套的容器组件,并附带了相应的 stories。

组件驱动开发(Component-Driven Development)允许你在组件层级向上移动时逐步扩展复杂性。其优势包括更集中的开发过程和提高所有可能的 UI 组合的覆盖范围。简而言之,CDD 帮助你构建更高质量、更复杂的 UI。

我们还没完成——构建 UI 后工作并没有结束。我们还需要确保它随着时间的推移保持稳定。

💡 别忘了用 git 提交你的更改!
保持你的代码与本章同步。在 GitHub 查看 da405c1。
本免费指南对你有帮助吗?发推文点赞并帮助其他开发者找到它。
下一章节
部署
学习如何在线部署 Storybook
✍️ 在 GitHub 上编辑 – 欢迎提交 PR!
加入社区
6,975开发者及仍在增加
为什么为什么选择 Storybook组件驱动 UI
开源软件
Storybook - Storybook 中文

特别感谢 Netlify CircleCI