构建屏幕
我们专注于从底层向上构建 UI,从小处着手并增加复杂性。这样做使我们能够独立开发每个组件,弄清楚其数据需求,并在 Storybook 中进行操作。所有这些都无需启动服务器或构建屏幕!
在本章中,我们将继续提高复杂性,将组件组合在一个屏幕中,并在 Storybook 中开发该屏幕。
嵌套容器组件
由于我们的应用很简单,因此我们将构建的屏幕非常简单,只是将 TaskList
组件(通过 ngxs 提供自己的数据)包装在一些布局中,并从我们的 store 中拉出一个顶级的 error
字段(假设如果我们在连接到服务器时遇到一些问题,我们将设置该字段)。
让我们首先更新我们的 store(在 src/app/state/task.state.ts
中)以包含我们想要的 error 字段
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
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
组件的数据
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
组件(最终,我们将使用路由器来选择正确的屏幕,但我们在这里不必担心这一点)
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
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
中设置我们的 story 时
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,
},
};
我们看到我们所有的 story 都不再工作了。这是因为两者都依赖于我们的 store,即使我们正在为错误使用“纯”组件,但两个 story 仍然需要上下文。
解决此问题的一种方法是永远不要在你的应用中的任何地方渲染容器组件,而只在最高级别渲染,而是将所有数据需求向下传递到组件层次结构中。
但是,开发人员将不可避免地需要在组件层次结构中进一步渲染容器。如果我们想在 Storybook 中渲染大部分或全部应用(我们确实想!),我们需要一个解决此问题的方法。
使用装饰器提供上下文
好消息是,在 story 中向 PureInboxScreen
组件提供 Store
非常容易!我们可以在装饰器中导入我们的 Store
,并通过 applicationConfig
API 启用它,并将其传递给 PureInboxScreen
组件。
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,
},
};
存在类似的方法来为其他数据库(例如 @ngrx 或 Apollo)提供模拟上下文。
在 Storybook 中循环浏览状态可以轻松测试我们是否正确地完成了此操作
组件测试
到目前为止,我们已经能够从头开始构建一个功能齐全的应用,从一个简单的组件到一个屏幕,并使用我们的 story 持续测试每个更改。但是,每个新的 story 也需要手动检查所有其他 story,以确保 UI 没有崩溃。这是很多额外的工作。
我们是否可以自动化此工作流程并自动测试我们的组件交互?
使用 play 函数编写组件测试
Storybook 的 play
和 @storybook/addon-interactions
可以帮助我们做到这一点。play 函数包含在 story 渲染后运行的小代码片段。
play 函数帮助我们验证当任务更新时 UI 会发生什么。它使用与框架无关的 DOM API,这意味着我们可以使用 play 函数编写 story,以与 UI 交互并模拟人类行为,无论前端框架如何。
@storybook/addon-interactions
帮助我们在 Storybook 中可视化我们的测试,提供逐步流程。它还提供了一组方便的 UI 控件,用于暂停、恢复、倒带和单步执行每个交互。
让我们看看它的实际效果!更新你新创建的 pure-inbox-screen
story,并通过添加以下内容来设置组件交互
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 函数内部的交互列表。
使用测试运行器自动化测试
借助 Storybook 的 play 函数,我们能够回避我们的问题,使我们能够与 UI 交互并快速检查如果我们更新任务,UI 会如何响应——在无需额外手动工作的情况下保持 UI 的一致性。
但是,如果我们仔细查看我们的 Storybook,我们可以看到它仅在查看 story 时运行组件测试。因此,如果我们进行更改,我们仍然必须遍历每个 story 才能运行所有检查。我们不能自动化它吗?
好消息是我们可以!Storybook 的 测试运行器 允许我们做到这一点。它是一个独立的实用程序——由 Playwright 提供支持——它运行我们所有的交互测试并捕获损坏的 story。
让我们看看它是如何工作的!运行以下命令进行安装
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 组件的绝佳方法。它可以做的事情远不止我们在这里看到的;我们建议阅读 官方文档 以了解更多信息。
要更深入地了解测试,请查看 测试手册。它涵盖了规模化前端团队用于增强你的开发工作流程的测试策略。
成功!现在我们有了一个工具,可以帮助我们验证是否所有 story 都在没有错误的情况下渲染,并且所有断言都自动通过。更重要的是,如果测试失败,它将为我们提供一个链接,该链接会在浏览器中打开失败的 story。
组件驱动开发
我们从底层的 Task
开始,然后逐步发展到 TaskList
,现在我们来到了一个完整的屏幕 UI。我们的 InboxScreen
容纳了一个嵌套的容器组件,并包含随附的 story。
组件驱动开发 允许你在向上移动组件层次结构时逐步扩展复杂性。好处包括更集中的开发过程和所有可能的 UI 排列的增加覆盖率。简而言之,CDD 帮助你构建更高质量和更复杂的用户界面。
我们还没有完成——当 UI 构建完成时,工作并没有结束。我们还需要确保它随着时间的推移保持持久。