构建简单组件
我们将遵循组件驱动开发 (CDD) 方法构建我们的 UI。这个过程从“自下而上”构建 UI,从组件开始,到屏幕结束。CDD 有助于你在构建 UI 时应对日益增加的复杂性。
任务
Task
是我们应用的核心组件。每个任务根据其所处的状态略有不同。我们会显示一个已选中(或未选中)的复选框、一些关于任务的信息以及一个“置顶”按钮,以便我们可以上下移动列表中的任务。综合这些,我们需要以下属性(props):
title
– 描述任务的字符串state
- 任务当前在哪个列表中,以及是否已勾选?
当我们开始构建 Task
时,我们首先编写与上面草图中的不同任务类型相对应的测试状态。然后我们使用 Storybook 隔离地创建组件,使用模拟数据。我们将手动测试组件在不同状态下的外观。
设置
首先,让我们创建任务组件及其配套的故事文件:src/components/Task.tsx
和 src/components/Task.stories.tsx
。
我们将从 Task
的基础实现开始,它只需要我们已知需要的属性以及你可以对任务执行的两个操作(在列表之间移动)
type TaskData = {
id: string;
title: string;
state: 'TASK_ARCHIVED' | 'TASK_INBOX' | 'TASK_PINNED';
};
type TaskProps = {
task: TaskData;
onArchiveTask: (id: string) => void;
onPinTask: (id: string) => void;
};
export default function Task({
task: { id, title, state },
onArchiveTask,
onPinTask,
}: TaskProps) {
return (
<div className="list-item">
<label htmlFor={`title-${id}`} aria-label={title}>
<input
type="text"
value={title}
readOnly={true}
name="title"
id={`title-${id}`}
/>
</label>
</div>
);
}
在上方,我们基于 Todos 应用现有的 HTML 结构,为 Task
渲染了简单的标记。
下方我们在故事文件中构建了 Task 的三个测试状态
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import Task from './Task';
export const ActionsData = {
onArchiveTask: fn(),
onPinTask: fn(),
};
const meta = {
component: Task,
title: 'Task',
tags: ['autodocs'],
//👇 Our exports that end in "Data" are not stories.
excludeStories: /.*Data$/,
args: {
...ActionsData,
},
} satisfies Meta<typeof Task>;
export default meta;
type Story = StoryObj<typeof meta>;
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 中有两种基本的组织级别:组件及其子故事。把每个故事看作是组件的一个变体。每个组件可以有任意数量的故事。
- 组件
- 故事
- 故事
- 故事
为了告诉 Storybook 我们正在文档化和测试的组件,我们创建一个包含以下内容的 default
导出:
component
-- 组件本身title
-- 如何在 Storybook 侧边栏中对组件进行分组或分类tags
-- 自动生成组件文档excludeStories
-- 故事所需的额外信息,但不应在 Storybook 中渲染args
-- 定义组件期望用于模拟自定义事件的 action args
为了定义我们的故事,我们将使用组件故事格式 3(也称为 CSF3)来构建每个测试用例。这种格式旨在以简洁的方式构建每个测试用例。通过导出一个包含每个组件状态的对象,我们可以更直观地定义我们的测试,并更高效地编写和重用故事。
参数,简称 args
,允许我们使用控件插件实时编辑组件,而无需重启 Storybook。一旦 args
的值改变,组件也会随之改变。
fn()
允许我们创建一个回调函数,当点击时,该函数会显示在 Storybook UI 的 Actions 面板中。因此,当我们构建一个置顶按钮时,我们将能够在 UI 中确定按钮点击是否成功。
由于我们需要将同一组动作传递给组件的所有变体,因此方便的做法是将它们打包到一个 ActionsData
变量中,并每次将其传递给我们的故事定义。捆绑组件所需的 ActionsData
的另一个好处是,你可以 export
它们,并在重用此组件的组件的故事中使用它们,我们稍后会看到。
创建故事时,我们使用一个基础的 task
参数来构建组件期望的任务形状。这通常模仿实际数据的样子。同样,export
这个形状将使我们能够在以后的故事中重用它,我们稍后会看到。
配置
我们需要对 Storybook 的配置文件进行一些更改,以便它能识别我们最近创建的故事,并允许我们使用应用程序的 CSS 文件(位于 src/index.css
)。
首先将你的 Storybook 配置文件(.storybook/main.ts
)修改如下
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
- stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
+ stories: ['../src/components/**/*.stories.@(ts|tsx)'],
staticDirs: ['../public'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
],
framework: {
name: "@storybook/react-vite",
options: {},
},
};
export default config;
完成上述更改后,在 .storybook
文件夹内,将你的 preview.ts
修改如下
import type { Preview } from '@storybook/react';
+ import '../src/index.css';
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
};
export default preview;
parameters
通常用于控制 Storybook 功能和插件的行为。在我们这里,我们不会将其用于该目的。相反,我们将导入应用程序的 CSS 文件。
完成这些后,重启 Storybook 服务器应该会生成 Task 的三个状态的测试用例
构建状态
现在我们已经设置好 Storybook,导入了样式,并构建了测试用例,我们可以快速开始实现组件的 HTML,以匹配设计。
目前组件还很初级。首先,编写实现设计的代码,无需过于详细
type TaskData = {
id: string;
title: string;
state: 'TASK_ARCHIVED' | 'TASK_INBOX' | 'TASK_PINNED';
};
type TaskProps = {
/** Composition of the task */
task: TaskData;
/** Event to change the task to archived */
onArchiveTask: (id: string) => void;
/** Event to change the task to pinned */
onPinTask: (id: string) => void;
};
export default function Task({
task: { id, title, state },
onArchiveTask,
onPinTask,
}: TaskProps) {
return (
<div className={`list-item ${state}`}>
<label
htmlFor={`archiveTask-${id}`}
aria-label={`archiveTask-${id}`}
className="checkbox"
>
<input
type="checkbox"
disabled={true}
name="checked"
id={`archiveTask-${id}`}
checked={state === "TASK_ARCHIVED"}
/>
<span className="checkbox-custom" onClick={() => onArchiveTask(id)} />
</label>
<label htmlFor={`title-${id}`} aria-label={title} className="title">
<input
type="text"
value={title}
readOnly={true}
name="title"
id={`title-${id}`}
placeholder="Input title"
/>
</label>
{state !== "TASK_ARCHIVED" && (
<button
className="pin-button"
onClick={() => onPinTask(id)}
id={`pinTask-${id}`}
aria-label={`pinTask-${id}`}
key={`pinTask-${id}`}
>
<span className={`icon-star`} />
</button>
)}
</div>
);
}
上面额外的标记与我们之前导入的 CSS 结合,产生了以下 UI:
指定数据需求
当我们继续构建组件时,可以通过定义 TypeScript 类型来指定 Task
组件期望的数据形状。这样,我们可以尽早捕获错误,并确保在增加更多复杂性时正确使用组件。首先在 src
文件夹中创建一个 types.ts
文件,并将我们现有的 TaskData
类型移到那里
export type TaskData = {
id: string;
title: string;
state: 'TASK_ARCHIVED' | 'TASK_INBOX' | 'TASK_PINNED';
};
然后,更新 Task
组件以使用我们新创建的类型
+ import type { TaskData } from '../types';
- type Task = {
- id: string;
- title: string;
- state: 'TASK_ARCHIVED' | 'TASK_INBOX' | 'TASK_PINNED';
- };
type TaskProps = {
/** Composition of the task */
task: TaskData;
/** Event to change the task to archived */
onArchiveTask: (id: string) => void;
/** Event to change the task to pinned */
onPinTask: (id: string) => void;
};
export default function Task({
task: { id, title, state },
onArchiveTask,
onPinTask,
}: TaskProps) {
return (
<div className={`list-item ${state}`}>
<label
htmlFor={`archiveTask-${id}`}
aria-label={`archiveTask-${id}`}
className="checkbox"
>
<input
type="checkbox"
disabled={true}
name="checked"
id={`archiveTask-${id}`}
checked={state === "TASK_ARCHIVED"}
/>
<span className="checkbox-custom" onClick={() => onArchiveTask(id)} />
</label>
<label htmlFor={`title-${id}`} aria-label={title} className="title">
<input
type="text"
value={title}
readOnly={true}
name="title"
id={`title-${id}`}
placeholder="Input title"
/>
</label>
{state !== "TASK_ARCHIVED" && (
<button
className="pin-button"
onClick={() => onPinTask(id)}
id={`pinTask-${id}`}
aria-label={`pinTask-${id}`}
key={`pinTask-${id}`}
>
<span className={`icon-star`} />
</button>
)}
</div>
);
}
现在,如果 Task 组件被误用,开发中将会出现错误。
组件构建完成!
我们现在已经成功构建了一个组件,而无需服务器或运行整个前端应用。下一步是以类似的方式逐个构建剩余的 Taskbox 组件。
如你所见,开始隔离构建组件既简单又快速。我们可以期待产出更高质量、更少 bug、更精良的 UI,因为可以深入测试每一种可能的状态。
捕获无障碍性问题
无障碍性测试是指使用自动化工具,根据基于 WCAG 规则和其他行业认可的最佳实践的一系列启发式方法,审计渲染的 DOM 的实践。它们作为质量保证的第一道防线,用于捕获明显的无障碍性违规,确保应用对尽可能多的人可用,包括视力障碍、听力问题和认知障碍人士。
Storybook 包含一个官方的无障碍性插件。它由 Deque 的 axe-core 提供支持,可以捕获高达 57% 的 WCAG 问题。
让我们看看它是如何工作的!运行以下命令安装插件:
yarn add --dev @storybook/addon-a11y
然后,更新你的 Storybook 配置文件(.storybook/main.ts
)来启用它
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/components/**/*.stories.@(ts|tsx)'],
staticDirs: ['../public'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
+ '@storybook/addon-a11y'
],
framework: {
name: '@storybook/react-vite',
options: {},
},
};
export default config;
最后,重启你的 Storybook,在 UI 中查看新启用的插件。
遍历我们的故事时,我们可以看到插件在我们其中一个测试状态中发现了一个无障碍性问题。消息 “元素必须具有足够的颜色对比度” 基本上意味着任务标题和背景之间没有足够的对比度。我们可以通过将应用程序的 CSS(位于 src/index.css
)中的文本颜色更改为较深的灰色来快速解决这个问题。
.list-item.TASK_ARCHIVED input[type="text"] {
- color: #a0aec0;
+ color: #4a5568;
text-decoration: line-through;
}
就这样!我们已经迈出了确保 UI 可访问的第一步。随着我们继续增加应用程序的复杂性,我们可以为所有其他组件重复此过程,而无需启动额外的工具或测试环境。