构建一个简单的组件
我们将遵循组件驱动开发(CDD)方法来构建我们的 UI。这是一个“自下而上”构建 UI 的过程,从组件开始,以屏幕结束。CDD 有助于扩展你在构建 UI 时面临的复杂性。
任务

Task 是我们应用程序的核心组件。每个任务根据其所处的状态显示略有不同。我们显示一个已选中(或未选中)的复选框、有关任务的一些信息以及一个“固定”按钮,允许我们将任务在列表中上下移动。将这些组合起来,我们将需要以下 props:
title– 描述任务的字符串state- 任务当前在哪一个列表中,以及是否已选中?
当我们开始构建 Task 时,我们首先编写对应于上面草图的不同任务类型的测试状态。然后,我们使用 Storybook 使用模拟数据在隔离环境中构建组件。我们将逐步“视觉测试”组件在每种状态下的外观。
准备就绪
首先,我们来创建任务组件及其配套的故事文件:src/components/Task.vue 和 src/components/Task.stories.ts。
我们将从 Task 的基础实现开始,仅接收我们知道将需要的属性以及可以在任务上执行的两个操作(在列表之间移动任务)。
<template>
<div class="list-item">
<label for="title" :aria-label="task.title">
<input type="text" readonly :value="task.title" id="title" name="title" />
</label>
</div>
</template>
<script lang="ts" setup>
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
}
const props = withDefaults(defineProps<TaskProps>(), {
task: { id: '', title: '', state: 'TASK_INBOX' },
})
</script>
上面,我们根据待办事项应用程序现有的 HTML 结构,渲染了 Task 的简单标记。
下面,我们在故事文件中构建 Task 的三个测试状态。
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { fn } from 'storybook/test'
import Task from './Task.vue'
export const TaskData = {
id: '1',
title: 'Test Task',
state: 'TASK_INBOX' as 'TASK_INBOX' | 'TASK_ARCHIVED' | 'TASK_PINNED',
events: {
onArchiveTask: fn(),
onPinTask: fn(),
},
}
const meta = {
component: Task,
title: 'Task',
tags: ['autodocs'],
//👇 Our exports that end in "Data" are not stories.
excludeStories: /.*Data$/,
args: {
...TaskData.events,
},
} satisfies Meta<typeof Task>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
task: TaskData,
},
}
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 中判断按钮点击是否成功。
由于我们需要将相同的操作集传递给我们组件的所有排列组合,因此将它们打包到一个 TaskData 变量中并在每次将它们传递到我们的故事定义中会很方便。将组件需要的 TaskData 打包的另一个好处是您可以 export 它们并在重用此组件的组件的故事中使用它们,我们稍后会看到。
配置
我们需要对 Storybook 的配置文件进行一些更改,以便它能识别我们最近创建的故事,并允许我们使用应用程序的 CSS 文件(位于 src/index.css)。
首先,将你的 Storybook 配置文件(.storybook/main.ts)更改为以下内容:
import type { StorybookConfig } from '@storybook/vue3-vite'
const config: StorybookConfig = {
- stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
+ stories: ['../src/components/**/*.stories.ts'],
staticDirs: ['../public'],
addons: [
'@chromatic-com/storybook',
'@storybook/addon-docs',
'@storybook/addon-vitest',
],
framework: {
name: '@storybook/vue3-vite',
options: {},
},
};
export default config;
完成上述更改后,在 .storybook 文件夹内,将你的 preview.ts 更改为以下内容:
import type { Preview } from '@storybook/vue3-vite'
+ import '../src/index.css'
//👇 Configures Storybook to log the actions( onArchiveTask and onPinTask ) in the UI.
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
};
export default preview;
parameters 通常用于控制 Storybook 的功能和插件的行为。在我们的例子中,我们将不为此目的使用它们。相反,我们将导入我们应用程序的 CSS 文件。
完成此操作后,重新启动 Storybook 服务器应该会显示三种 Task 状态的测试用例。
构建状态
现在我们已经设置好了 Storybook,导入了样式,并构建了测试用例,我们可以快速开始实现组件的 HTML,以匹配设计。
目前该组件仍然很粗糙。首先,编写实现设计的代码,但不要过于详细。
<template>
<div :class="classes">
<label :for="'checked' + task.id" :aria-label="'archiveTask-' + task.id" class="checkbox">
<input
type="checkbox"
:checked="isChecked"
disabled
:name="'checked' + task.id"
:id="'archiveTask-' + task.id"
/>
<span class="checkbox-custom" @click="archiveTask" />
</label>
<label :for="'title-' + task.id" :aria-label="task.title" class="title">
<input
type="text"
readonly
:value="task.title"
:id="'title-' + task.id"
name="title"
placeholder="Input title"
/>
</label>
<button
v-if="!isChecked"
class="pin-button"
@click="pinTask"
:id="'pinTask-' + task.id"
:aria-label="'pinTask-' + task.id"
>
<span class="icon-star" />
</button>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
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
}
const props = withDefaults(defineProps<TaskProps>(), {
task: { id: '', title: '', state: 'TASK_INBOX' },
})
const classes = computed(() => {
return `list-item ${props.task.state}`
})
/*
* Computed property for checking the state of the task
*/
const isChecked = computed(() => props.task.state === 'TASK_ARCHIVED')
const emit = defineEmits<{
(e: 'archive-task', id: string): void
(e: 'pin-task', id: string): void
}>()
/**
* Event handler for archiving tasks
*/
function archiveTask() {
emit('archive-task', props.task.id)
}
/**
* Event handler for pinning tasks
*/
function pinTask(): void {
emit('pin-task', props.task.id)
}
</script>
上面附加的标记与我们之前导入的 CSS 一起,会产生以下 UI:
指定数据要求
随着我们继续构建组件,我们可以通过定义 TypeScript 类型来指定 Task 组件期望的数据形状。这样,我们可以及早捕获错误,并确保在添加更多复杂性时组件被正确使用。首先,在 src 文件夹中创建一个 types.ts 文件,并将我们现有的 TaskData 类型移到那里。
export type TaskData = {
id: string;
title: string;
state: 'TASK_ARCHIVED' | 'TASK_INBOX' | 'TASK_PINNED';
};
然后,更新 Task 组件以使用我们新创建的类型。
<template>
<div :class="classes">
<label :for="'checked' + task.id" :aria-label="'archiveTask-' + task.id" class="checkbox">
<input
type="checkbox"
:checked="isChecked"
disabled
:name="'checked' + task.id"
:id="'archiveTask-' + task.id"
/>
<span class="checkbox-custom" @click="archiveTask" />
</label>
<label :for="'title-' + task.id" :aria-label="task.title" class="title">
<input
type="text"
readonly
:value="task.title"
:id="'title-' + task.id"
name="title"
placeholder="Input title"
/>
</label>
<button
v-if="!isChecked"
class="pin-button"
@click="pinTask"
:id="'pinTask-' + task.id"
:aria-label="'pinTask-' + task.id"
>
<span class="icon-star" />
</button>
</div>
</template>
<script lang="ts" setup>
import type { TaskData } from '../types'
import { computed } from 'vue'
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
}
const props = defineProps<TaskProps>()
const classes = computed(() => {
return `list-item ${props.task.state}`
})
/*
* Computed property for checking the state of the task
*/
const isChecked = computed(() => props.task.state === 'TASK_ARCHIVED')
const emit = defineEmits<{
(e: 'archive-task', id: string): void
(e: 'pin-task', id: string): void
}>()
/**
* Event handler for archiving tasks
*/
function archiveTask() {
emit('archive-task', props.task.id)
}
/**
* Event handler for pinning tasks
*/
function pinTask(): void {
emit('pin-task', props.task.id)
}
</script>
组件构建完成!
我们现在已经成功地在不需要服务器或运行整个前端应用程序的情况下构建了一个组件。下一步是继续以类似的方式一个接一个地构建剩余的 Taskbox 组件。
正如你所见,在隔离环境中开始构建组件既简单又快速。由于我们可以深入测试每一种可能的状态,因此我们可以生产出质量更高、错误更少、更完善的 UI。