构建一个简单组件
我们将遵循组件驱动开发 (CDD) 方法来构建我们的 UI。这是一个自下而上构建 UI 的过程,从组件开始,到屏幕结束。CDD 帮助您扩展在构建 UI 时面临的复杂性。
Task (任务)
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
(参数) – 定义组件期望模拟自定义事件的操作 args (参数)
为了定义我们的故事,我们将使用 Component Story Format 3(也称为 CSF3)来构建我们的每个测试用例。此格式旨在以简洁的方式构建我们的每个测试用例。通过导出包含每个组件状态的对象,我们可以更直观地定义测试,并更有效地编写和重用故事。
参数或简称 args
,允许我们使用 controls 插件实时编辑组件,而无需重启 Storybook。一旦 args
值发生更改,组件也会随之更改。
fn()
允许我们创建一个回调,当单击时,它会出现在 Storybook UI 的 Actions (操作) 面板中。因此,当我们构建一个置顶按钮时,我们将能够确定按钮单击在 UI 中是否成功。
由于我们需要将同一组操作传递给组件的所有排列,因此方便的做法是将它们捆绑到一个 ActionsData
变量中,并在每次将其传递到我们的故事定义中。捆绑组件需要的 ActionsData
的另一个好处是,您可以 export
它们并在重用此组件的组件的故事中使用它们,我们稍后会看到。
在创建故事时,我们使用基本 task
arg 来构建组件期望的任务的形状。通常从实际数据的外观建模。同样,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 组件。
如您所见,开始隔离构建组件既简单又快速。我们可以期望生产出更高质量、更少错误、更精细的 UI,因为可以深入研究并测试每种可能的状态。
捕获可访问性问题
可访问性测试是指使用自动化工具,根据 WCAG 规则和其他行业公认的最佳实践,审核渲染的 DOM 的实践。它们充当 QA 的第一道防线,以捕获明显的无障碍违规行为,确保应用程序尽可能多的人可以使用,包括视力障碍、听力问题和认知障碍的人。
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 中启用的新插件。
在我们的故事中循环,我们可以看到该插件在一个测试状态中发现了一个可访问性问题。消息 “Elements must have sufficient color contrast (元素必须具有足够的颜色对比度)” 本质上意味着任务标题和背景之间没有足够的对比度。我们可以通过在应用程序的 CSS (位于 src/index.css
中) 中将文本颜色更改为更深的灰色来快速修复它。
.list-item.TASK_ARCHIVED input[type="text"] {
- color: #a0aec0;
+ color: #4a5568;
text-decoration: line-through;
}
就是这样!我们已经迈出了确保 UI 变得可访问的第一步。当我们继续为应用程序添加复杂性时,我们可以为所有其他组件重复此过程,而无需启动额外的工具或测试环境。