Vue 基础体系 · 第 53/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue Storybook:Story、交互测试、文档、主题和组件评审
Storybook 是一个在应用之外独立渲染 UI 组件的开发与评审环境。它把组件的输入、状态、用户操作和说明组织成可访问的页面,使组件不必先嵌入完整业务流程,便可以被开发、测试、设计和产品人员单独检查。
本文使用 Vue 3、Composition API、TypeScript 和 Vite。示例假设组件库位于一个现代前端项目中,Storybook 使用 @storybook/vue3-vite。Storybook 的命令、测试插件和文档 API 会随主版本变化,涉及具体插件的地方会明确说明版本敏感性。
一、先建立问题模型:组件为什么需要 Storybook
一个组件通常同时存在四类信息:
- 结构:组件渲染出什么 DOM。
- 输入:props、插槽、
v-model、注入值和全局配置。 - 状态:默认态、加载态、禁用态、错误态、空态和交互后的状态。
- 行为:点击、键盘操作、表单提交、异步请求和事件触发。
如果只在业务页面中检查组件,测试结果会混入路由、接口、权限、布局和其他组件的影响。例如,一个按钮在页面中看起来不可点击,原因可能是按钮自身的 disabled,也可能是外层元素覆盖、接口状态错误或 CSS 层级问题。
Storybook 的核心做法是把组件场景显式化:
其中:
props是组件接收的属性;slots是插槽内容;globals是主题、语言、方向等全局环境;state是组件内部或交互过程中产生的状态;f是组件的渲染和事件逻辑。
一个 Story 就是这组输入和初始状态的可复现描述。Story 不等于组件本身,而是组件在某个具体场景下的实例化方式。
例如,同一个 Button 可以有以下 Story:
- 主要按钮;
- 次要按钮;
- 禁用按钮;
- 加载按钮;
- 长文本按钮;
- 点击后触发事件的按钮;
- 暗色主题下的按钮。
这些场景不是为了展示数量,而是为了覆盖组件契约中的不同边界。
二、准备一个 Vue 3 + Vite + Storybook 项目
如果已有 Vue 3 + Vite 项目,可以在项目根目录初始化 Storybook:
npm create vite@latest vue-storybook-demo -- --template vue-ts
cd vue-storybook-demo
npm install
npx storybook@latest init
npm run storybook
各命令的作用如下:
npm create vite@latest创建 Vue + TypeScript 项目;storybook init检测项目技术栈,并生成.storybook配置;npm run storybook启动 Storybook 开发服务。
初始化后通常会出现:
.storybook/
main.ts
preview.ts
src/
stories/
在 Vue + Vite 项目中,.storybook/main.ts 的关键配置类似:
import type { StorybookConfig } from '@storybook/vue3-vite'
const config: StorybookConfig = {
stories: [
'../src/**/*.mdx',
'../src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-a11y',
],
framework: {
name: '@storybook/vue3-vite',
options: {},
},
}
export default config
这里有两个重要事实:
@storybook/vue3-vite让 Storybook 使用 Vite 构建 Vue Story;stories是文件匹配规则,只有匹配到的.stories.ts、.stories.tsx或.mdx才会被加载。
如果项目使用路径别名,例如:
import Button from '@/components/Button.vue'
则 Vite 应用配置中的别名也必须能被 Storybook 使用。现代 Storybook 通常复用 Vite 配置,但自定义别名、插件或环境变量时,仍应检查 Storybook 是否真的加载了这些配置。不能因为应用开发服务器能解析 @/,就假定 Storybook 一定能解析。
三、组件示例:一个可测试的 Vue 按钮
先定义组件,而不是直接从 Storybook 配置开始。组件的契约必须足够明确,否则 Story 只能掩盖设计问题。
src/components/AppButton.vue:
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'small' | 'medium' | 'large'
disabled?: boolean
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'medium',
disabled: false,
loading: false,
})
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const isDisabled = computed(() => props.disabled || props.loading)
function handleClick(event: MouseEvent) {
if (isDisabled.value) {
return
}
emit('click', event)
}
</script>
<template>
<button
class="app-button"
:class="[
`app-button--${variant}`,
`app-button--${size}`,
]"
type="button"
:disabled="isDisabled"
:aria-busy="loading || undefined"
@click="handleClick"
>
<span v-if="loading" class="app-button__spinner" aria-hidden="true" />
<span v-if="loading" class="sr-only">处理中</span>
<slot />
</button>
</template>
<style scoped>
.app-button {
border: 0;
border-radius: 6px;
cursor: pointer;
font: inherit;
padding: 0.6rem 1rem;
}
.app-button--primary {
background: #2563eb;
color: white;
}
.app-button--secondary {
background: #e5e7eb;
color: #111827;
}
.app-button--danger {
background: #dc2626;
color: white;
}
.app-button--small {
font-size: 0.875rem;
}
.app-button--medium {
font-size: 1rem;
}
.app-button--large {
font-size: 1.125rem;
}
.app-button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.app-button__spinner {
display: inline-block;
width: 0.8em;
height: 0.8em;
margin-right: 0.4em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
这个组件有一个重要的状态推导:
也就是说,外部显式禁用或加载中任一条件成立,按钮都必须不可用。若只在模板中给按钮绑定 :disabled="disabled",而 loading 只显示图标,用户仍然可以重复提交,这就是一个可由 Story 发现的行为缺陷。
四、Story 是什么:组件场景的可执行契约
4.1 CSF 文件结构
Storybook 使用 CSF(Component Story Format)组织 Story。一个 .stories.ts 文件通常包含:
- 默认导出:组件元数据
meta; - 命名导出:一个或多个具体 Story;
args:传给组件的输入;parameters:当前 Story 的配置;decorators:包裹组件的环境;play:渲染完成后的交互步骤。
src/components/AppButton.stories.ts:
import type { Meta, StoryObj } from '@storybook/vue3'
import { expect, fn, userEvent, within } from '@storybook/test'
import AppButton from './AppButton.vue'
const meta = {
title: 'Components/AppButton',
component: AppButton,
tags: ['autodocs'],
args: {
onClick: fn(),
},
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'danger'],
description: '按钮的视觉语义',
},
size: {
control: 'inline-radio',
options: ['small', 'medium', 'large'],
description: '按钮尺寸',
},
disabled: {
control: 'boolean',
description: '是否禁止用户操作',
},
loading: {
control: 'boolean',
description: '是否显示加载状态并禁止重复操作',
},
},
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof AppButton>
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {
variant: 'primary',
},
render: (args) => ({
components: { AppButton },
setup() {
return { args }
},
template: '<AppButton v-bind="args">保存</AppButton>',
}),
}
export const Secondary: Story = {
args: {
variant: 'secondary',
},
render: (args) => ({
components: { AppButton },
setup() {
return { args }
},
template: '<AppButton v-bind="args">取消</AppButton>',
}),
}
export const Loading: Story = {
args: {
loading: true,
},
render: (args) => ({
components: { AppButton },
setup() {
return { args }
},
template: '<AppButton v-bind="args">提交订单</AppButton>',
}),
}
export const Disabled: Story = {
args: {
disabled: true,
},
render: (args) => ({
components: { AppButton },
setup() {
return { args }
},
template: '<AppButton v-bind="args">不可用</AppButton>',
}),
}
Meta<typeof AppButton> 为元数据提供类型约束,satisfies 能检查对象是否符合 Storybook 类型,同时保留更精确的 TypeScript 推断。
render 的作用是补充插槽内容。Vue 组件的插槽不是普通 prop,因此不能只写:
args: {
default: '保存',
}
然后期待所有 Vue 配置都正确传入。通过 render 返回一个小型 Vue 拓扑,可以明确声明组件、响应式上下文和模板。
4.2 args、Controls 和可复现性
args 是 Story 的输入状态。Storybook Controls 面板会根据 args 和 argTypes 生成可编辑控件。
例如,将 variant 从 primary 改为 danger 时,Storybook 会重新渲染同一个 Story,而不是让开发者手动修改源码、刷新页面。这使得状态检查从:
修改代码 → 重新构建 → 进入页面 → 找到组件
变成:
选择 Story → 修改输入 → 观察输出
但 args 不是任意运行时变量。以下状态不适合强行建模为 args:
- 组件内部计时器;
- 服务器响应;
- 依赖当前时间的逻辑;
- 无法序列化的复杂对象;
- 只在用户完成一串操作后出现的状态。
这类场景通常应由 play、模拟服务或专门的测试组件驱动。
五、Story 的生命周期和数据流
一个 Story 从加载到交互,大致经过以下路径:
flowchart LR
A[CSF 文件] --> B[Storybook 解析 meta]
B --> C[合并全局参数与 Story 参数]
C --> D[生成 args 和渲染上下文]
D --> E[创建 Vue 组件树]
E --> F[挂载到预览 iframe]
F --> G[执行 play]
G --> H[更新组件状态]
H --> I[断言、截图或人工评审]
关键数据流是:
Story args
↓
render 上下文
↓
Vue props / listeners / slots
↓
DOM
↓
用户事件
↓
emit 或内部状态变化
↓
新的 DOM 与可观察结果
args 的变化会导致组件重新渲染;组件内部状态变化不会自动改变 Story 的 args。这是常见误解。
例如:
const count = ref(0)
function increment() {
count.value++
}
点击后 count 改变,是 Vue 组件内部状态变化;它并不意味着 Story 的 args.count 被修改。若希望把交互后的值同步到 Controls,需要使用受控组件模式,并在事件中更新 args。对于普通展示型 Story,直接断言 DOM 结果通常更简单、更稳定。
六、交互测试:让 Story 不只是静态截图
6.1 交互测试的定义
交互测试是在 Story 已挂载的组件上执行用户行为,并验证行为结果。例如:
- 找到按钮;
- 模拟用户点击;
- 验证事件触发;
- 验证按钮进入加载态或页面出现反馈。
Storybook 的 play 函数适合表达这种流程。它不是组件初始化代码,而是组件渲染完成后执行的测试脚本。
下面为按钮增加一个点击交互 Story:
export const Clickable: Story = {
args: {
variant: 'primary',
onClick: fn(),
},
render: (args) => ({
components: { AppButton },
setup() {
return { args }
},
template: '<AppButton v-bind="args">保存</AppButton>',
}),
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement)
const button = canvas.getByRole('button', { name: '保存' })
await userEvent.click(button)
await expect(args.onClick).toHaveBeenCalledTimes(1)
},
}
这里每一步都有明确含义:
within(canvasElement)将查询限制在当前 Story 的画布中;getByRole按可访问语义查找元素,而不是依赖脆弱的 CSS 类名;userEvent.click模拟用户点击,而不是直接调用 Vue 方法;args.onClick是一个由fn()创建的可观察函数;toHaveBeenCalledTimes(1)验证点击和事件之间的因果关系。
如果按钮没有正确渲染为 <button>,或者没有可访问名称,getByRole 会失败。这比:
canvasElement.querySelector('.app-button')
更能发现真实用户无法操作的问题。
6.2 Vue 事件与 Story args
Vue 组件声明了:
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
在 Story 中,通常使用 onClick 形式的 arg 观察事件:
args: {
onClick: fn(),
}
这是 Storybook Vue 集成约定下的事件监听映射。它并不是 Vue emit API 规定的 TypeScript 语法;它是 Storybook 将事件监听器作为 args 传入组件的约定。若使用自定义事件,例如 submit-form,应检查当前 Storybook Vue 版本对事件监听器命名的推断和映射方式,必要时在 render 中显式绑定:
render: (args) => ({
components: { FormComponent },
setup() {
return { args }
},
template: `
<FormComponent
v-bind="args"
@submit-form="args.onSubmit"
/>
`,
})
6.3 验证加载状态和禁止重复操作
按钮的核心业务风险不是“是否显示了 spinner”,而是加载期间是否仍能触发提交。因此可以写出更有价值的 Story:
export const LoadingDoesNotEmit: Story = {
args: {
loading: true,
onClick: fn(),
},
render: (args) => ({
components: { AppButton },
setup() {
return { args }
},
template: '<AppButton v-bind="args">提交</AppButton>',
}),
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement)
const button = canvas.getByRole('button', { name: /提交/ })
await expect(button).toBeDisabled()
await userEvent.click(button)
await expect(args.onClick).not.toHaveBeenCalled()
},
}
这个测试验证的是:
若 AppButton.vue 忘记把 loading 纳入 isDisabled,测试会在 toBeDisabled() 或事件断言处失败,失败位置直接对应组件契约。
6.4 交互测试与单元测试的边界
两者关注层次不同:
| 类型 | 主要验证对象 | 典型问题 |
|---|---|---|
| 单元测试 | 函数、组合式函数、纯状态转换 | 输入 loading 后是否得到正确布尔值 |
| 组件测试 | 组件渲染、事件、DOM 行为 | 点击按钮是否触发 emit |
| Story 交互测试 | 面向用户的完整场景 | 用户按语义找到按钮后,流程是否正确 |
| 端到端测试 | 多页面、真实服务或接近真实服务 | 下单流程是否跨页面完成 |
Story 交互测试不能替代所有单元测试。例如,复杂日期算法不应通过几十步 UI 操作间接验证。反过来,单元测试也无法保证组件的可访问名称、插槽内容和实际交互路径是正确的。
6.5 在 CI 中执行交互测试
play 可以在 Storybook 预览中执行,但“浏览器中能执行”不等于“CI 自动执行”。当前 Storybook 版本可能使用官方测试插件、Vitest 集成或独立的测试运行器,安装方式和命令随版本变化。
一种常见流程是:
npm run build-storybook
npm run test-storybook
前置条件是项目已经根据所使用的 Storybook 主版本配置了对应测试插件或 runner。不要只把 play 写进代码,就认为 CI 已经覆盖它;应在流水线中确认:
- Storybook 能成功构建;
- 测试进程能启动浏览器或测试环境;
- 失败的
play会返回非零退出码; - 报告中能定位到具体 Story;
- 测试环境的字体、时区、网络模拟和浏览器版本稳定。
交互测试常见失败原因包括:
- 组件异步更新尚未完成就断言;
- 使用了不稳定的
setTimeout; - Story 依赖真实网络;
- 查询文本受国际化影响;
- 测试共享了前一个 Story 的全局状态;
fn()没有放在 Story 或 meta 的 args 中,导致无法观察事件。
七、文档:从“展示组件”变成“解释契约”
7.1 自动文档不是完整文档
为 meta 增加:
tags: ['autodocs']
后,Storybook 可以根据组件类型、props、事件和 Story 生成文档页面。自动文档的价值是减少重复维护,但它只能读取工具链能够静态推断出的信息。
例如,TypeScript 能较好地推断:
interface Props {
variant?: 'primary' | 'secondary' | 'danger'
loading?: boolean
}
但以下信息往往不能仅靠类型完整推导:
variant="danger"在设计系统中代表删除或不可逆操作;loading是否需要由父组件控制;- 插槽应该放置纯文本还是带图标内容;
- 组件是否必须位于某个
provide/inject环境中; - 某个事件触发前是否要经过表单校验。
因此,自动生成的表格是 API 索引,不是使用说明。
可以在 Story 中补充描述:
const meta = {
title: 'Components/AppButton',
component: AppButton,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'用于触发明确操作。loading 状态会同时禁止按钮,避免重复提交。',
},
},
},
} satisfies Meta<typeof AppButton>
同时可以给 props 写 JSDoc:
interface Props {
/** 按钮的视觉语义;danger 通常用于删除等高风险操作 */
variant?: 'primary' | 'secondary' | 'danger'
/** 加载中时显示进度提示,并禁止新的点击事件 */
loading?: boolean
}
7.2 MDX 文档页面
当文档需要解释设计原则、组合方式或反例时,可以使用 MDX。以下写法适用于启用了 Docs blocks 的 Storybook 配置;具体组件名称和导入路径属于版本敏感 API,应以当前版本文档为准。
src/components/AppButton.mdx:
import { Meta, Canvas, Controls, Story } from '@storybook/blocks'
import * as ButtonStories from './AppButton.stories'
<Meta of={ButtonStories} />
# AppButton
`AppButton` 用于触发一个明确的用户操作。
## 语义选择
- `primary`:当前页面最主要的操作。
- `secondary`:次要或取消操作。
- `danger`:删除、撤销等高风险操作。
## 基础用法
<Canvas of={ButtonStories.Primary} />
## 参数
<Controls of={ButtonStories.Primary} />
## 加载状态
加载状态必须同时满足两个条件:
1. 给用户提供处理中反馈;
2. 阻止重复操作。
<Canvas of={ButtonStories.Loading} />
<Canvas> 展示某个 Story 的实际渲染结果,<Controls> 展示其输入控制。这样文档中的示例不是手写静态 HTML,而是和测试、组件实现共享同一个执行对象。
7.3 文档失败的表现和诊断
文档页面可能出现以下问题:
- Props 表格为空:TypeScript 类型未被 Storybook 文档插件识别,或类型通过复杂泛型、运行时包装丢失;
- Story 能渲染但 Controls 不完整:没有声明
argTypes,或框架无法推断某些类型; - MDX 页面找不到 Story:
Meta of={...}引用的对象不是正确的默认 meta; - 文档中的 Story 和测试行为不一致:文档展示了一个旧 Story,实际组件契约已经改变。
诊断顺序应是:
- 直接打开对应
.stories.ts,确认 Story 能独立渲染; - 检查
main.ts是否包含.mdx; - 检查组件 props 是否有明确的 TypeScript 类型;
- 对无法推断的参数显式写
argTypes; - 确认文档引用的是当前 Story 文件的 meta 和命名导出。
八、主题:组件视觉环境与 Storybook 工作台主题不是一回事
“主题”至少有两层含义。
8.1 组件主题
组件主题决定组件本身的颜色、间距、字体、阴影和暗色模式。常见实现是 CSS 自定义属性:
:root {
--color-primary: #2563eb;
--color-surface: #ffffff;
--color-text: #111827;
}
.theme-dark {
--color-primary: #60a5fa;
--color-surface: #111827;
--color-text: #f9fafb;
}
组件使用变量:
<template>
<button class="themed-button">
<slot />
</button>
</template>
<style scoped>
.themed-button {
background: var(--color-primary);
color: var(--color-surface);
border: 1px solid var(--color-primary);
}
</style>
如果 CSS 变量定义在组件外部,Storybook 也必须加载这些全局样式。可以在 .storybook/preview.ts 中导入:
import '../src/styles/tokens.css'
import '../src/styles/global.css'
const preview = {
parameters: {
backgrounds: {
default: 'light',
values: [
{ name: 'light', value: '#ffffff' },
{ name: 'dark', value: '#111827' },
],
},
},
}
export default preview
backgrounds 只改变 Story 背景,不会自动改变组件的 CSS 变量。因此,暗色主题验证不能只依赖背景色。
8.2 通过工具栏切换组件主题
可以用全局变量表示主题,并在 decorator 中把主题传给组件树。@storybook/addon-themes 提供的 withThemeByClassName 是一种常见实现,但它属于插件能力,具体导出名和配置格式应以当前 Storybook 版本为准。
// .storybook/preview.ts
import type { Preview } from '@storybook/vue3'
import { withThemeByClassName } from '@storybook/addon-themes'
import '../src/styles/tokens.css'
const preview: Preview = {
globalTypes: {
theme: {
description: '组件主题',
toolbar: {
title: 'Theme',
icon: 'paintbrush',
items: ['light', 'dark'],
dynamicTitle: true,
},
},
},
decorators: [
withThemeByClassName({
themes: {
light: 'theme-light',
dark: 'theme-dark',
},
defaultTheme: 'light',
}),
],
}
export default preview
配套 CSS:
.theme-light {
--color-primary: #2563eb;
--color-surface: #ffffff;
--color-text: #111827;
}
.theme-dark {
--color-primary: #60a5fa;
--color-surface: #111827;
--color-text: #f9fafb;
}
数据流为:
工具栏选择 theme
↓
context.globals.theme 改变
↓
主题 decorator 更新根节点 class
↓
CSS 自定义属性重新计算
↓
组件视觉更新
主题 Story 的关键不是“有一个暗色按钮”,而是验证主题切换后:
- 文本与背景仍有足够对比度;
- hover、focus、disabled 状态仍可辨识;
- 图标不会因固定黑色或白色而消失;
- 弹层、Tooltip、Portal 内容也继承正确主题;
- 主题切换不会改变组件语义或交互逻辑。
8.3 Storybook 管理器主题
Storybook 左侧导航、顶部工具栏和面板自身的颜色,属于 manager theme;组件预览区域的颜色属于 preview theme。二者相互独立。
如果只配置 manager theme,组件不会变成暗色;如果只配置组件 CSS 变量,Storybook 的工作台仍可能是浅色。这种区分对于截图评审尤其重要,因为截图可能包含预览区域,也可能只包含组件画布。
九、全局装饰器:模拟真实组件环境
有些组件无法脱离应用环境运行,例如依赖:
provide/inject;- Pinia;
- Vue Router;
- 国际化;
- 全局 CSS;
- 浏览器 API;
- 设计系统的 ThemeProvider。
这时应使用 decorator 创建测试环境,而不是在每个 Story 中复制初始化逻辑。
例如,一个需要注入语言的组件:
// .storybook/preview.ts
import { provide } from 'vue'
import type { Preview } from '@storybook/vue3'
const preview: Preview = {
decorators: [
(story) => ({
components: { Story: story },
setup() {
provide('locale', 'zh-CN')
},
template: '<Story />',
}),
],
}
export default preview
需要注意 Vue decorator 的职责边界:
- decorator 应提供环境,不应偷偷改变被测组件的业务逻辑;
- 每个 Story 都应得到隔离的状态;
- 不应把真实生产 store 直接连接到 Storybook 后让 Story 之间共享用户数据;
- 异步初始化必须可等待,否则 Story 可能先渲染空壳再发生不可预测变化。
如果组件必须依赖路由,可以使用 Storybook 推荐的 Vue Router 集成方式,或在 decorator 中创建每个 Story 独立的 router。不要复用一个会累积导航历史的全局 router,否则测试顺序可能影响结果。
十、组件评审:评审的不只是颜色
组件评审是一个以 Story 为入口的协作过程。评审对象至少包括四个维度:
其中:
- Structure:DOM 结构、插槽、响应式布局;
- Behavior:点击、键盘、焦点、加载和错误路径;
- Visual:尺寸、间距、颜色、主题和边界内容;
- Accessibility:语义角色、名称、状态和操作可达性。
10.1 用 Story 表达评审矩阵
对于 AppButton,可以建立如下矩阵:
| 维度 | 场景 | 评审问题 |
|---|---|---|
| 视觉 | primary / secondary / danger | 语义颜色是否正确 |
| 状态 | loading | 是否显示反馈,是否阻止重复提交 |
| 状态 | disabled | 是否不可操作,文字是否仍可读 |
| 内容 | 长文本 | 是否溢出或破坏布局 |
| 输入 | 键盘 | 是否能聚焦和触发 |
| 主题 | light / dark | 对比度和 focus 样式是否保留 |
| 可访问性 | 无障碍检查 | 是否有正确 role、name、状态 |
可以补充长文本 Story:
export const LongLabel: Story = {
args: {
size: 'large',
},
render: (args) => ({
components: { AppButton },
setup() {
return { args }
},
template: `
<AppButton v-bind="args">
提交一份包含较长说明文字的申请
</AppButton>
`,
}),
}
这个 Story 的价值在于暴露 CSS 中的固定宽度、不可换行、图标挤压和移动端布局问题。它比只展示“保存”两个字更接近真实边界。
10.2 可访问性检查
启用 @storybook/addon-a11y 后,Storybook 可以对当前 Story 运行自动化可访问性规则。自动检查不是完整的可访问性证明,但能发现一部分高频问题,例如:
- 按钮没有可访问名称;
- 颜色对比不足;
- 表单控件缺少关联标签;
- 无效的 ARIA 属性;
- 交互元素语义错误。
自动规则可能受到浏览器、组件结构和插件版本影响。通过检查不代表键盘流程、屏幕阅读器体验和内容语义已经完整正确,因此仍需要人工键盘评审。
10.3 视觉回归
视觉回归测试把 Story 渲染为截图,并与基线进行比较。其基本判断可以写成:
其中:
I_current是当前提交生成的图片;I_baseline是已接受的基准图片;D是像素或感知差异;\epsilon是允许的差异阈值。
差异超过阈值并不一定代表缺陷,字体加载、浏览器版本、动画、时间、随机数据和屏幕尺寸都可能造成误报。因此视觉测试必须固定:
- viewport;
- 浏览器和操作系统环境;
- 字体;
- 时区和语言;
- 动画;
- 网络响应;
- Story 的随机输入。
组件评审流程可以是:
flowchart TD
A[提交组件或 Story] --> B[构建 Storybook]
B --> C[运行交互测试]
B --> D[运行可访问性检查]
B --> E[生成视觉快照]
C --> F{是否失败}
D --> F
E --> F
F -- 是 --> G[定位具体 Story 与状态]
G --> H[修复代码或更新契约]
H --> B
F -- 否 --> I[设计与工程评审]
I --> J[接受基线并合并]
更新视觉基线必须经过人工确认。直接在 CI 失败后无条件更新基线,会把真实回归永久记录成“正确结果”。
十一、错误路径和异步状态
Storybook 特别适合展示平时难以稳定复现的错误状态:
- 请求失败;
- 权限不足;
- 网络超时;
- 空数据;
- 部分数据加载成功;
- 提交过程中用户重复点击。
推荐把外部请求抽象为可模拟的边界,而不是在 Story 中直接请求生产接口。例如使用 MSW(Mock Service Worker)时,可以为不同 Story 配置不同响应。具体 addon 包和配置方式依赖当前 Storybook 版本,但原则不变:
组件请求抽象
↓
Story 提供成功 / 失败 / 延迟响应
↓
组件渲染对应状态
↓
play 验证用户可见结果
成功 Story 不应覆盖失败 Story,原因是错误状态通常有不同的 DOM、按钮可用性和恢复路径。一个错误 Story 至少应验证:
- 用户能看到失败原因;
- 错误不会被静默吞掉;
- 重试按钮可访问;
- 重试不会导致重复请求;
- 加载、失败、成功之间的状态转换一致。
例如,状态转换可以表示为:
idle
↓ submit
loading
├── response 2xx → success
├── response 4xx → validation-error
└── network error → retryable-error
如果组件在 loading 状态下等待网络,但 Storybook 使用真实网络,测试就会同时受到服务可用性、凭证和延迟影响。生产服务不应成为组件 Story 的隐式前置条件。
十二、常见误解与失败表现
12.1 “每个 prop 一个 Story 就够了”
不够。单个 prop 的组合可能产生新的状态。例如:
disabled=false、loading=true;- 长文本 +
size=small; - 暗色主题 + danger;
- 无数据 + 可重试;
- 错误提示 + 键盘焦点。
Story 应优先覆盖有业务意义的状态组合,而不是机械排列所有参数。
12.2 “Controls 可以代替测试”
Controls 只能让人手动修改输入,它不会自动验证:
- 点击是否触发事件;
- 键盘是否能完成操作;
- 加载时是否禁止提交;
- 错误状态是否能恢复。
Controls 是探索工具,play 和断言才是可重复的行为检查。
12.3 “能渲染就代表组件没问题”
组件可能成功渲染,但仍然存在:
- 事件没有发出;
- 事件发出两次;
- 视觉状态与实际 disabled 状态不一致;
- 只支持鼠标、不支持键盘;
- 主题切换后文字不可读;
- 插槽为空时布局崩坏。
因此静态 Story、交互 Story、可访问性检查和视觉评审承担不同职责,不能相互替代。
12.4 “Snapshot 失败就一定是代码缺陷”
截图变化可能来自:
- 字体未加载;
- 浏览器升级;
- 操作系统抗锯齿变化;
- 动画截取时机不同;
- 日期、随机数或网络数据变化;
- Story 没有固定 viewport。
诊断时先判断差异类别:
- DOM 或状态改变;
- CSS 规则改变;
- 渲染环境改变;
- 测试数据不稳定;
- 基线本身错误。
只有确认变更符合预期,才能更新基线。
12.5 “Storybook 可以直接复用应用所有上下文”
不应无条件复用。应用上下文可能包含真实用户、缓存、路由历史和网络请求,导致 Story 之间互相污染。Story 的环境应该尽可能小、明确、可重建。
十三、目录组织和维护边界
一种可维护的目录结构是:
src/
components/
AppButton.vue
AppButton.stories.ts
AppButton.mdx
styles/
tokens.css
global.css
.storybook/
main.ts
preview.ts
将 Story 与组件放在一起有三个好处:
- 删除或重命名组件时容易同步处理 Story;
- 评审者可以在同一目录看到实现、场景和文档;
- 文档和测试更容易沿组件边界组织。
但 Story 不应复制整个业务页面。一个 Story 的职责是表达组件契约,而不是重建线上页面。若组件只有放入复杂页面才有意义,说明它可能是页面级模块,应该将依赖环境通过 decorator、fixture 或 mock 明确注入。
维护时应特别关注以下一致性:
组件 props / emits
↕
Story args / play
↕
文档说明
↕
视觉与可访问性基线
任一层发生变化,都可能使其他层失效。例如新增 loading prop 后,如果只修改组件而没有新增 Loading Story,文档和评审矩阵就无法表达新的契约。
十四、从本地开发到 CI 的完整路径
本地开发通常使用:
npm run storybook
用于交互式查看和修改 Controls。
生产构建使用:
npm run build-storybook
它会生成静态 Storybook。构建失败通常说明:
- Story 文件存在 TypeScript 或模块导入错误;
- Vite 插件在 Storybook 环境中缺失;
- MDX 语法或文档引用错误;
- 环境变量没有提供;
- 某个 Story 在构建时执行了不应执行的浏览器或网络逻辑。
CI 中至少应执行:
npm run build-storybook
如果已配置对应交互测试集成,再执行:
npm run test-storybook
若接入视觉回归服务,则应对固定 Story 集合生成截图,并将差异作为评审输入,而不是将截图测试视为无条件阻断。最终合并前要验证:
- 失败 Story 能被单独打开;
- 测试失败信息包含组件和场景名称;
- 视觉差异有明确审查人;
- 新增状态有对应文档;
- 主题和可访问性检查没有被全局禁用。
结语
Storybook 的核心不是把组件排列成目录,而是把组件契约变成可执行对象:
- Story 描述组件在一个具体输入和环境下如何使用;
- 交互测试 验证用户操作与可观察结果之间的因果关系;
- 文档 解释参数、语义、组合方式和边界;
- 主题 让同一组件在不同视觉环境中可重复验证;
- 组件评审 综合结构、行为、视觉和可访问性,而不是只看一张截图。
当组件的默认态、错误态、加载态、长内容、主题和交互路径都能通过独立 Story 复现时,组件库才真正获得了稳定的工程边界。此时 Storybook 不只是开发工具,也成为组件 API、测试场景和跨角色评审之间的共同接口。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 视觉回归测试:截图基线、字体、动画、阈值和审阅
- 下一篇:Vue DevTools 调试:组件、状态、时间线、性能和生产诊断
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论