构建一个简单组件
我们将遵循组件驱动开发 (Component-Driven Development, CDD)方法构建我们的 UI。这个过程从“自下而上”构建 UI,从组件开始,到页面结束。CDD 有助于你在构建 UI 时应对不断增加的复杂性。
任务
Task
是我们应用的核心组件。每个任务根据其具体状态显示略有不同。我们会显示一个选中(或未选中)的复选框、一些关于任务的信息以及一个“置顶”按钮,用于在列表中上下移动任务。综合来看,我们需要以下这些 props:
title
– 描述任务的字符串state
- 任务当前在哪一个列表中,以及是否已完成?
当我们开始构建 Task
时,首先编写与上述草图中的不同任务类型相对应的测试状态。然后我们使用 Storybook 独立地创建组件,使用模拟数据。在构建过程中,我们会手动测试组件在各种状态下的外观。
准备工作
首先,创建任务组件及其配套的 story 文件:src/app/components/task.component.ts
和 src/app/components/task.stories.ts
。
我们将从 Task
组件的基础实现开始,简单地接收我们知道需要的输入以及可以在任务上执行的两个操作(用于在列表之间移动任务)
import { Component, Input, Output, EventEmitter } from '@angular/core';
@Component({
selector: 'app-task',
standalone: false,
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 default class TaskComponent {
/**
* The shape of the task object
*/
@Input() task: any;
// tslint:disable-next-line: no-output-on-prefix
@Output()
onPinTask = new EventEmitter<Event>();
// tslint:disable-next-line: no-output-on-prefix
@Output()
onArchiveTask = new EventEmitter<Event>();
}
上面,我们根据 Todos 应用现有的 HTML 结构,为 Task
组件渲染了简单的标记。
下面我们在 story 文件中构建 Task 的三个测试状态
import type { Meta, StoryObj } from '@storybook/angular';
import { fn } from '@storybook/test';
import TaskComponent from './task.component';
export const ActionsData = {
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: {
...ActionsData,
},
};
export default meta;
type Story = StoryObj<TaskComponent>;
export const Default: Story = {
args: {
task: {
id: '1',
title: 'Test Task',
state: 'TASK_INBOX',
},
},
};
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 中有两种基本的组织层级:组件及其子 stories。可以将每个 story 视为组件的一种变体。每个组件可以拥有任意数量的 stories。
- 组件
- Story
- Story
- Story
为了让 Storybook 了解我们正在文档化的组件,我们创建一个包含以下内容的 default
导出:
component
-- 组件本身title
-- 如何在 Storybook 侧边栏中对组件进行分组或分类tags
-- 用于自动为我们的组件生成文档excludeStories
-- story 需要但不能在 Storybook 中渲染的附加信息args
-- 定义组件期望用于模拟自定义事件的 action args
为了定义我们的 stories,我们将使用组件 Story 格式 3(也称为 CSF3)来构建每个测试用例。这种格式旨在以简洁的方式构建我们的每个测试用例。通过导出包含每个组件状态的对象,我们可以更直观地定义测试,并更高效地编写和重用 stories。
参数,或简称 args
,允许我们在不重启 Storybook 的情况下使用 controls 插件实时编辑组件。一旦 args
值改变,组件也会随之改变。
fn()
允许我们创建一个回调,当被点击时,该回调会出现在 Storybook UI 的 Actions 面板中。因此,当我们构建一个置顶按钮时,将能够确定按钮点击在 UI 中是否成功。
由于我们需要将同一组 actions 传递给组件的所有变体,因此方便的做法是将它们捆绑到一个单独的 ActionsData
变量中,并每次将其传递给我们的 story 定义。捆绑组件所需的 ActionsData
还有一个好处是,你可以 export
它们并在重用此组件的组件的 stories 中使用它们,我们稍后会看到这一点。
创建 story 时,我们使用一个基础的 task
arg 来构建组件期望的任务形状。通常是根据实际数据样子建模。同样,export
这个形状将使我们能够在后续的 stories 中重用它,我们稍后会看到这一点。
配置
我们还需要对 Storybook 配置进行一个小的更改,以便它注意到我们最近创建的 stories。将你的配置文件 (.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-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/angular',
options: {},
},
};
export default config;
完成此操作后,重启 Storybook 服务器应该会生成 TaskComponent 的三个状态的测试用例
指定数据要求
最佳实践是指定组件期望的数据形状。这不仅是自文档化的,还有助于尽早发现问题。在这里,我们将使用 Typescript 并为 Task
模型创建一个接口。
在 app
目录内,添加一个名为 models
的新目录,然后是一个名为 task.model.ts
的新文件
export interface Task {
id?: string;
title?: string;
state?: string;
}
构建状态
现在我们已经设置好 Storybook,导入了样式,并构建了测试用例,可以快速开始实现组件的 HTML 以匹配设计。
该组件目前还很基础。首先,编写能够实现设计但不必过于详细的代码
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Task } from '../models/task.model';
@Component({
selector: 'app-task',
standalone: false,
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 default class TaskComponent {
/**
* The shape of the task object
*/
@Input() task?: Task;
// tslint:disable-next-line: no-output-on-prefix
@Output()
onPinTask = new EventEmitter<Event>();
// tslint:disable-next-line: no-output-on-prefix
@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.css 和 angular.json),得到以下 UI:
组件构建完成!
现在我们已经成功构建了一个组件,而无需服务器或运行整个前端应用。下一步是按照类似的方式逐个构建剩余的 Taskbox 组件。
如你所见,独立构建组件既简单又快速。我们可以期望产出更高质量、更少 bug、更精致的 UI,因为可以深入测试每一个可能的状态。
捕获可访问性问题
可访问性测试是指使用自动化工具根据基于 WCAG 规则和其他行业公认的最佳实践的一系列启发法来审计渲染的 DOM 的做法。它们是 QA 的第一道防线,用于捕获明显的可访问性违规行为,确保应用程序尽可能多地供用户使用,包括视力障碍、听力问题和认知障碍等残障人士。
Storybook 包含一个官方的可访问性插件。它由 Deque 的 axe-core 提供支持,可以捕获高达 57% 的 WCAG 问题。
来看看它是如何工作的!运行以下命令安装插件
npm install @storybook/addon-a11y --save-dev
然后,更新你的 Storybook 配置文件 (.storybook/main.ts
) 来启用它
import type { StorybookConfig } from '@storybook/angular';
const config: StorybookConfig = {
stories: ['../src/app/components/**/*.stories.ts'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
+ '@storybook/addon-a11y',
],
framework: {
name: '@storybook/angular',
options: {},
},
};
export default config;
最后,重启 Storybook 以在 UI 中查看新启用的插件。
循环查看我们的 stories 时,可以看到插件发现了一个测试状态存在可访问性问题。消息 “元素必须具有足够的颜色对比度” 本质上意味着任务标题和背景之间没有足够的对比度。我们可以通过在应用 CSS(位于 src/styles.css
中)中将文本颜色更改为更深的灰色来快速修复它。
.list-item.TASK_ARCHIVED input[type="text"] {
- color: #a0aec0;
+ color: #4a5568;
text-decoration: line-through;
}
就这样!我们已经迈出了确保 UI 变得可访问的第一步。随着我们不断增加应用的复杂性,我们可以为所有其他组件重复此过程,而无需启动额外的工具或测试环境。