构建一个简单的组件
我们将遵循组件驱动开发(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-vite';
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 来模拟自定义事件。
为了定义我们的故事,我们将使用 Component Story Format 3(也称为 CSF3)来构建我们的每个测试用例。这种格式旨在以简洁的方式构建我们的每个测试用例。通过导出包含每个组件状态的对象,我们可以更直观地定义我们的测试,并更有效地编写和重用故事。
参数或简称为 args,允许我们在不重启 Storybook 的情况下使用 controls 插件实时编辑我们的组件。一旦 args 值发生变化,组件也会随之变化。
fn() 允许我们创建一个回调函数,当点击时会在 Storybook UI 的 **Actions** 面板中显示。因此,当我们构建一个固定按钮时,我们就能在 UI 中判断按钮点击是否成功。
由于我们需要将相同的动作集传递给组件的所有排列,因此方便将其打包到一个名为 ActionsData 的变量中,并在每次将它们传递给我们的故事定义时使用。将组件所需的 ActionsData 打包的另一个好处是,你可以 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-docs',
'@storybook/addon-vitest',
'@chromatic-com/storybook',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
};
export default config;
完成上述更改后,在 .storybook 文件夹内,将你的 preview.ts 更改为以下内容:
import type { Preview } from '@storybook/react-vite';
+ 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 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。