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

一个组件通常同时存在四类信息:

  1. 结构:组件渲染出什么 DOM。
  2. 输入:props、插槽、v-model、注入值和全局配置。
  3. 状态:默认态、加载态、禁用态、错误态、空态和交互后的状态。
  4. 行为:点击、键盘操作、表单提交、异步请求和事件触发。

如果只在业务页面中检查组件,测试结果会混入路由、接口、权限、布局和其他组件的影响。例如,一个按钮在页面中看起来不可点击,原因可能是按钮自身的 disabled,也可能是外层元素覆盖、接口状态错误或 CSS 层级问题。

Storybook 的核心做法是把组件场景显式化:

UI=f(props,slots,globals,state)UI = f(props, slots, globals, state)

其中:

  • 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>

这个组件有一个重要的状态推导:

isDisabled=disabledloadingisDisabled = disabled \lor loading

也就是说,外部显式禁用或加载中任一条件成立,按钮都必须不可用。若只在模板中给按钮绑定 :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 面板会根据 argsargTypes 生成可编辑控件。

例如,将 variantprimary 改为 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 已挂载的组件上执行用户行为,并验证行为结果。例如:

  1. 找到按钮;
  2. 模拟用户点击;
  3. 验证事件触发;
  4. 验证按钮进入加载态或页面出现反馈。

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()
  },
}

这个测试验证的是:

loading=truedisabled=trueclick 不产生业务事件loading = true \Rightarrow disabled = true \Rightarrow click\ 不产生业务事件

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 已经覆盖它;应在流水线中确认:

  1. Storybook 能成功构建;
  2. 测试进程能启动浏览器或测试环境;
  3. 失败的 play 会返回非零退出码;
  4. 报告中能定位到具体 Story;
  5. 测试环境的字体、时区、网络模拟和浏览器版本稳定。

交互测试常见失败原因包括:

  • 组件异步更新尚未完成就断言;
  • 使用了不稳定的 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,实际组件契约已经改变。

诊断顺序应是:

  1. 直接打开对应 .stories.ts,确认 Story 能独立渲染;
  2. 检查 main.ts 是否包含 .mdx
  3. 检查组件 props 是否有明确的 TypeScript 类型;
  4. 对无法推断的参数显式写 argTypes
  5. 确认文档引用的是当前 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 为入口的协作过程。评审对象至少包括四个维度:

Review=Structure+Behavior+Visual+AccessibilityReview = Structure + Behavior + Visual + Accessibility

其中:

  • 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 渲染为截图,并与基线进行比较。其基本判断可以写成:

D(Icurrent,Ibaseline)>ϵ需要评审D(I_{current}, I_{baseline}) > \epsilon \Rightarrow \text{需要评审}

其中:

  • 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 至少应验证:

  1. 用户能看到失败原因;
  2. 错误不会被静默吞掉;
  3. 重试按钮可访问;
  4. 重试不会导致重复请求;
  5. 加载、失败、成功之间的状态转换一致。

例如,状态转换可以表示为:

idle
  ↓ submit
loading
  ├── response 2xx → success
  ├── response 4xx → validation-error
  └── network error → retryable-error

如果组件在 loading 状态下等待网络,但 Storybook 使用真实网络,测试就会同时受到服务可用性、凭证和延迟影响。生产服务不应成为组件 Story 的隐式前置条件。


十二、常见误解与失败表现

12.1 “每个 prop 一个 Story 就够了”

不够。单个 prop 的组合可能产生新的状态。例如:

  • disabled=falseloading=true
  • 长文本 + size=small
  • 暗色主题 + danger;
  • 无数据 + 可重试;
  • 错误提示 + 键盘焦点。

Story 应优先覆盖有业务意义的状态组合,而不是机械排列所有参数。

12.2 “Controls 可以代替测试”

Controls 只能让人手动修改输入,它不会自动验证:

  • 点击是否触发事件;
  • 键盘是否能完成操作;
  • 加载时是否禁止提交;
  • 错误状态是否能恢复。

Controls 是探索工具,play 和断言才是可重复的行为检查。

12.3 “能渲染就代表组件没问题”

组件可能成功渲染,但仍然存在:

  • 事件没有发出;
  • 事件发出两次;
  • 视觉状态与实际 disabled 状态不一致;
  • 只支持鼠标、不支持键盘;
  • 主题切换后文字不可读;
  • 插槽为空时布局崩坏。

因此静态 Story、交互 Story、可访问性检查和视觉评审承担不同职责,不能相互替代。

12.4 “Snapshot 失败就一定是代码缺陷”

截图变化可能来自:

  • 字体未加载;
  • 浏览器升级;
  • 操作系统抗锯齿变化;
  • 动画截取时机不同;
  • 日期、随机数或网络数据变化;
  • Story 没有固定 viewport。

诊断时先判断差异类别:

  1. DOM 或状态改变;
  2. CSS 规则改变;
  3. 渲染环境改变;
  4. 测试数据不稳定;
  5. 基线本身错误。

只有确认变更符合预期,才能更新基线。

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、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。