Vue 基础体系 · 第 19/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 组件库与设计系统:Token、主题、无障碍、文档和版本治理

组件库解决的是“如何复用组件实现界面”,设计系统解决的是“哪些界面决策可以复用、为什么这样复用,以及这些决策如何持续演进”。前者通常交付 Vue 组件、样式和类型;后者还包括设计变量、交互规则、内容规范、无障碍要求、文档、设计工具和发布流程。

如果只维护一组按钮、表格和弹窗,项目仍然可能出现以下问题:

  • 同一种“主要按钮”在不同页面使用了不同蓝色;
  • 主题切换只修改了背景色,却没有修改阴影、边框和焦点环;
  • 弹窗鼠标可用,但键盘无法进入或退出;
  • 组件文档只展示正常状态,没有展示加载、错误、禁用和长文本;
  • 修改一个 CSS 类名就导致下游项目构建失败;
  • 组件库发布了新版本,却没有说明哪些视觉变化会影响截图测试和业务验收。

因此,组件库和设计系统需要被视为一个完整的交付系统:

设计决策
   ↓
原始 Token → 语义 Token → 组件 Token
   ↓              ↓
主题映射       Vue 组件实现
   ↓              ↓
文档、示例、测试、无障碍验证
   ↓
版本、变更记录、迁移和发布

下面以 Vue 3、Composition API、TypeScript 和现代 Vite 工具链为基础,说明这套系统的核心机制。


一、先区分设计系统、组件库和 Token

1.1 设计系统不是组件目录

设计系统是一组可共享的界面决策及其约束。它至少回答四个问题:

  1. 视觉决策:颜色、字体、间距、圆角、阴影如何定义;
  2. 行为决策:按钮何时可点击,弹窗如何关闭,表单错误何时出现;
  3. 语义决策:什么内容使用标题、列表、按钮、链接或状态提示;
  4. 交付决策:如何记录、测试、发布和迁移这些规则。

组件库是设计系统的一种工程化载体。它通常包含:

  • Vue 组件;
  • TypeScript 类型;
  • 样式和 Token;
  • 组件间组合规则;
  • 文档和示例;
  • 测试与构建产物。

组件库可以没有完整设计系统,但一个成熟的设计系统通常需要某种组件库来落地。反过来,如果组件库只提供 API,不规定交互语义和视觉来源,它更像“可复用代码集合”,而不是完整设计系统。

1.2 Token 是可命名的设计决策

Design Token 可以定义为:

一个具有稳定名称、明确语义和可转换值的设计决策。

例如:

{
  "color": {
    "blue": {
      "500": "#2563eb"
    }
  },
  "space": {
    "4": "1rem"
  }
}

这里的 color.blue.500space.4 是命名后的值,但它们还没有说明使用场景。更适合组件消费的是语义 Token:

{
  "color": {
    "action": {
      "primary": {
        "bg": "{color.blue.500}",
        "fg": "#ffffff"
      }
    },
    "surface": {
      "default": "#ffffff",
      "muted": "#f8fafc"
    },
    "text": {
      "primary": "#0f172a",
      "muted": "#64748b"
    }
  }
}

这里的 color.action.primary.bg 表示“主要操作的背景色”,而不是“蓝色 500”。当品牌色从蓝色改成紫色时,组件使用的语义名称不变,只需重新映射其值。

1.3 三层 Token 解决不同问题

实际系统常将 Token 分成三层:

原始 Token

原始 Token 描述未经语义解释的设计值:

export const primitiveTokens = {
  color: {
    blue500: '#2563eb',
    blue600: '#1d4ed8',
    gray100: '#f1f5f9',
    gray700: '#334155',
  },
  radius: {
    sm: '0.25rem',
    md: '0.5rem',
  },
} as const

它适合表达色板、字号、间距刻度,但不应直接散落在组件中。

语义 Token

语义 Token 将原始值绑定到用途:

export const lightSemanticTokens = {
  color: {
    surfaceDefault: primitiveTokens.color.gray100,
    textPrimary: primitiveTokens.color.gray700,
    actionPrimaryBg: primitiveTokens.color.blue500,
    actionPrimaryBgHover: primitiveTokens.color.blue600,
  },
} as const

组件 Token

组件 Token 描述局部组件的可调参数:

:root {
  --button-primary-bg: var(--color-action-primary-bg);
  --button-primary-bg-hover: var(--color-action-primary-bg-hover);
  --button-radius: var(--radius-md);
}

组件样式消费组件 Token,而不是直接消费 #2563eb

.ds-button--primary {
  color: var(--button-primary-fg);
  background: var(--button-primary-bg);
  border-radius: var(--button-radius);
}

.ds-button--primary:hover:not(:disabled) {
  background: var(--button-primary-bg-hover);
}

这种分层的因果关系是:

原始色板改变
→ 语义 Token 重新映射
→ 组件 Token 得到新值
→ 多个组件统一变化

如果组件直接使用原始值,颜色修改就必须搜索整个代码库,无法保证所有场景同步。


二、Token 设计的约束、计算和反例

2.1 Token 名称必须表达用途,而非当前实现

下面两个名称的稳定性不同:

--blue-500: #2563eb;
--button-primary-background: #2563eb;

--blue-500 表示当前色板位置;--button-primary-background 表示组件语义。品牌换色时,前者可能仍存在,后者仍然是按钮需要的背景。

因此,以下命名通常更稳定:

color.text.primary
color.text.onAction
color.surface.default
color.surface.elevated
color.border.subtle
color.focus.ring
color.status.danger

而以下命名容易把实现细节泄漏给消费者:

blue500
gray700
border2
shadow3

原始 Token 可以使用后者,但组件和业务页面不应直接依赖它们。

2.2 语义 Token 必须满足映射完整性

设组件状态集合为:

S={default,hover,active,focus,disabled,loading,error}S = \{default, hover, active, focus, disabled, loading, error\}

设每个状态需要的视觉属性为:

P={background,foreground,border,focusRing}P = \{background, foreground, border, focusRing\}

一个完整的组件主题映射至少应满足:

sS,pPs,token(s,p) 有定义\forall s \in S', \forall p \in P_s,\quad token(s,p)\ \text{有定义}

其中 SS' 是产品真正支持的状态集合,PsP_s 是状态 ss 所需的属性集合。

例如,按钮的 disabled 状态可能不需要焦点环,但 focus-visible 状态必须有可见的焦点样式。如果只定义默认背景和文字颜色,而没有定义禁用文字色,组件可能会继承普通文字颜色,导致禁用状态对比度过高或过低。

2.3 对比度不是“看起来差不多”

对于普通文本,WCAG 2.x 的相对亮度对比度计算可表示为:

Contrast=Lmax+0.05Lmin+0.05Contrast = \frac{L_{max}+0.05}{L_{min}+0.05}

其中 LmaxL_{max}LminL_{min} 分别是前景色和背景色的相对亮度。亮度计算需要先将 sRGB 通道转换为线性 RGB:

clinear={c/12.92,c0.04045((c+0.055)/1.055)2.4,c>0.04045c_{linear} = \begin{cases} c/12.92, & c \le 0.04045 \\ ((c+0.055)/1.055)^{2.4}, & c > 0.04045 \end{cases}

再计算:

L=0.2126R+0.7152G+0.0722BL=0.2126R+0.7152G+0.0722B

常见 WCAG 2.1 AA 目标是:

  • 普通文本:至少 4.5:1
  • 大文本:至少 3:1
  • 非文本 UI 边界和焦点指示器也需要足够可辨识,具体要求取决于适用标准和场景。

例如 #777 文字放在白色背景上并不一定满足普通文本要求。不能仅凭设计稿中的视觉感受判断,必须对实际组合进行计算。

2.4 反例:只在深色主题中替换背景

[data-theme='dark'] {
  --surface-default: #111827;
  --text-primary: #f9fafb;
}

如果组件还使用了以下固定值:

border: 1px solid #e5e7eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25);

那么深色主题可能出现:

  • 边框过亮;
  • 阴影在深色背景上几乎不可见;
  • 焦点环与背景混合;
  • 状态颜色仍然使用浅色主题的值。

主题切换不是修改一个 background,而是为同一语义集合提供另一套完整映射。


三、使用 CSS 自定义属性实现主题

3.1 CSS 变量适合运行时主题切换

CSS 自定义属性可以在不重新构建组件的情况下切换值:

:root {
  --color-surface-default: #ffffff;
  --color-text-primary: #0f172a;
  --color-text-muted: #64748b;
  --color-action-primary-bg: #2563eb;
  --color-action-primary-bg-hover: #1d4ed8;
  --color-action-primary-fg: #ffffff;
  --color-focus-ring: #2563eb;
  --radius-md: 0.5rem;
}

[data-theme='dark'] {
  --color-surface-default: #0f172a;
  --color-text-primary: #f8fafc;
  --color-text-muted: #cbd5e1;
  --color-action-primary-bg: #60a5fa;
  --color-action-primary-bg-hover: #93c5fd;
  --color-action-primary-fg: #0f172a;
  --color-focus-ring: #93c5fd;
}

组件只依赖变量:

.ds-button {
  min-height: 2.5rem;
  padding-inline: 1rem;
  border: 0;
  border-radius: var(--radius-md);
  color: var(--color-action-primary-fg);
  background: var(--color-action-primary-bg);
}

.ds-button:hover:not(:disabled) {
  background: var(--color-action-primary-bg-hover);
}

.ds-button:focus-visible {
  outline: 3px solid var(--color-focus-ring);
  outline-offset: 2px;
}

主题切换:

import { ref, watch } from 'vue'

const theme = ref<'light' | 'dark'>('light')

watch(
  theme,
  value => {
    document.documentElement.dataset.theme = value
  },
  { immediate: true },
)

输入是 'light''dark',预期结果是 html 元素出现对应的 data-theme 属性,所有引用 CSS 变量的组件立即重新计算样式。该方式的前提是主题变量已在全局样式中加载,且组件没有用硬编码颜色绕过变量。

3.2 系统主题与用户选择存在优先级

常见优先级可以定义为:

effectiveTheme={userChoice,userChoicesystempreferscolorscheme,userChoice=systemeffectiveTheme = \begin{cases} userChoice, & userChoice \ne system \\ prefers-color-scheme, & userChoice = system \end{cases}

对应实现:

import { ref, watch } from 'vue'

type ThemePreference = 'light' | 'dark' | 'system'

const preference = ref<ThemePreference>('system')

function resolveTheme(value: ThemePreference): 'light' | 'dark' {
  if (value !== 'system') return value

  return window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark'
    : 'light'
}

function applyTheme(value: ThemePreference) {
  document.documentElement.dataset.theme = resolveTheme(value)
}

watch(preference, value => {
  localStorage.setItem('theme-preference', value)
  applyTheme(value)
}, { immediate: true })

const media = window.matchMedia('(prefers-color-scheme: dark)')
media.addEventListener('change', () => {
  if (preference.value === 'system') applyTheme('system')
})

生产实现还需要处理 SSR 或首屏闪烁:在服务端无法读取 localStorage,客户端首次执行 Vue 代码前可能短暂显示默认主题。通常可以在 HTML 的早期内联脚本中读取已保存偏好并设置 data-theme,同时避免直接把未经校验的用户输入拼接进脚本。

3.3 主题不等于皮肤

主题至少包含:

  • 颜色语义;
  • 交互状态颜色;
  • 焦点指示器;
  • 阴影和分割线;
  • 可能的字体、圆角和密度;
  • 高对比度或强制颜色模式下的适配。

如果只是把 .dark 类放到根节点,但组件内部仍然写死颜色,系统只有“深色外壳”,并不是真正的主题系统。


四、组件 API:属性、事件、插槽和状态边界

4.1 API 要表达用户意图

一个按钮组件不应要求调用方传入大量 CSS 类:

<!-- 不稳定:调用方知道了组件内部样式 -->
<Button class="blue-button-with-small-radius" />

更稳定的 API 表达意图:

<Button variant="primary" size="md" :loading="saving">
  保存
</Button>

组件内部:

<script setup lang="ts">
interface Props {
  variant?: 'primary' | 'secondary' | 'danger'
  size?: 'sm' | 'md' | 'lg'
  loading?: boolean
  disabled?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  variant: 'primary',
  size: 'md',
  loading: false,
  disabled: false,
})

const emit = defineEmits<{
  click: [event: MouseEvent]
}>()

function handleClick(event: MouseEvent) {
  if (props.disabled || props.loading) {
    event.preventDefault()
    return
  }

  emit('click', event)
}
</script>

<template>
  <button
    type="button"
    class="ds-button"
    :class="[
      `ds-button--${variant}`,
      `ds-button--${size}`,
      { 'is-loading': loading },
    ]"
    :disabled="disabled || loading"
    :aria-busy="loading || undefined"
    @click="handleClick"
  >
    <span v-if="loading" class="ds-button__spinner" aria-hidden="true" />
    <span :class="{ 'ds-button__content--hidden': loading }">
      <slot />
    </span>
    <span v-if="loading" class="sr-only">处理中</span>
  </button>
</template>

这里有几个重要边界:

  • disabled 是原生按钮属性,浏览器会阻止普通点击和键盘操作;
  • loading 是业务状态,不能只显示 spinner,还应阻止重复提交并向辅助技术表达忙碌;
  • slot 负责内容分发,组件不应假设按钮文字一定是字符串;
  • type="button" 防止组件放在表单中时意外触发表单提交。

4.2 插槽是内容分发,不是任意 DOM 注入

组件可以提供具名插槽:

<template>
  <article class="ds-card">
    <header v-if="$slots.header" class="ds-card__header">
      <slot name="header" />
    </header>

    <div class="ds-card__body">
      <slot />
    </div>

    <footer v-if="$slots.footer" class="ds-card__footer">
      <slot name="footer" />
    </footer>
  </article>
</template>

使用:

<Card>
  <template #header>
    <h2>账户设置</h2>
  </template>

  <p>修改密码和通知偏好。</p>

  <template #footer>
    <Button variant="secondary">取消</Button>
  </template>
</Card>

插槽内容由父组件作用域编译,子组件只能提供布局位置。若需要子组件向父组件暴露数据,应使用作用域插槽:

<List :items="items">
  <template #default="{ item, index }">
    <span>{{ index + 1 }}. {{ item.name }}</span>
  </template>
</List>

这与把任意 HTML 字符串传给 v-html 不同。v-html 会绕过 Vue 的模板转义,内容若来自不可信输入,可能产生 XSS。设计系统应优先提供结构化插槽和受控属性。

4.3 动态组件、KeepAlive、Teleport 和异步组件属于组合机制

组件库常需要渲染不同类型的面板:

<script setup lang="ts">
import { ref, defineAsyncComponent } from 'vue'
import OverviewPanel from './OverviewPanel.vue'

const active = ref<'overview' | 'analytics'>('overview')

const panels = {
  overview: OverviewPanel,
  analytics: defineAsyncComponent(() => import('./AnalyticsPanel.vue')),
}
</script>

<template>
  <button @click="active = 'overview'">概览</button>
  <button @click="active = 'analytics'">分析</button>

  <KeepAlive>
    <component :is="panels[active]" />
  </KeepAlive>
</template>

运行过程是:

  1. 初始渲染 OverviewPanel
  2. 切换到 analytics 时,异步加载其代码块;
  3. 加载期间可以通过 defineAsyncComponent 的配置提供加载组件和错误组件;
  4. KeepAlive 会缓存已切换出去的组件实例,而不是销毁它;
  5. 再次切回时,组件可能触发 onActivated,而不是重新经历完整挂载。

KeepAlive 适合保留编辑内容、筛选条件或滚动状态,但缓存组件也会保留内存和旧数据。不能因为“切换更快”就默认缓存所有动态组件。

弹窗、下拉菜单和浮层通常需要脱离当前 DOM 层级,以避免父容器的 overflow: hidden 或层叠上下文影响定位:

<Teleport to="body">
  <div v-if="open" class="ds-overlay">
    <section class="ds-dialog">
      <slot />
    </section>
  </div>
</Teleport>

Teleport 改变的是 DOM 挂载位置,不改变 Vue 的组件逻辑关系;事件和响应式数据仍然由原来的组件树管理。它不会自动提供焦点管理、滚动锁定或无障碍语义,这些职责必须由弹窗组件实现。


五、以状态机理解组件,而不是只看 CSS 类

复杂组件的错误往往来自状态组合,而不是单个样式错误。以异步按钮为例,状态可以写成:

idle
 ├─ submit → pending
pending
 ├─ resolve → success
 ├─ reject  → error
 └─ cancel  → idle
error
 ├─ retry   → pending
 └─ reset   → idle
success
 └─ reset   → idle

可以形式化为:

(state,event)(nextState,sideEffect)(state, event) \rightarrow (nextState, sideEffect)

例如:

(pending, resolve) → (success, 显示成功提示)
(pending, reject)  → (error, 显示错误信息)

如果用户快速点击两次,会产生并发请求:

请求 A:pending
请求 B:pending
A 返回成功
B 返回失败

若没有请求标识,B 的失败可能覆盖 A 的成功,或者更早发出的 A 在更晚返回时覆盖最新结果。

一个可取消、可识别请求的 composable:

import { ref, onBeforeUnmount } from 'vue'

export function useRequest<T, A extends unknown[]>(
  request: (...args: A) => Promise<T>,
) {
  const loading = ref(false)
  const error = ref<unknown>(null)
  const data = ref<T>()
  let sequence = 0
  let controller: AbortController | undefined

  async function execute(...args: A) {
    const current = ++sequence
    controller?.abort()
    controller = new AbortController()

    loading.value = true
    error.value = null

    try {
      const result = await request(...args)
      if (current !== sequence) return

      data.value = result
      return result
    } catch (err) {
      if (current !== sequence) return
      if (err instanceof DOMException && err.name === 'AbortError') return

      error.value = err
      throw err
    } finally {
      if (current === sequence) loading.value = false
    }
  }

  function cancel() {
    controller?.abort()
  }

  onBeforeUnmount(cancel)

  return { loading, error, data, execute, cancel }
}

这里同时处理了三种故障路径:

  1. 新请求开始时取消旧请求;
  2. 旧请求即使未能真正取消,返回结果也因 sequence 不匹配而被丢弃;
  3. 组件卸载时取消请求,避免无意义的网络和状态更新。

需要注意,AbortController 只能要求支持取消的异步操作停止;服务器端已经处理的请求无法被客户端撤回。因此如果操作具有写入副作用,还需要服务端幂等键或请求版本控制。


六、无障碍不是给元素补几个 ARIA 属性

6.1 先选择正确的原生语义

无障碍实现的优先顺序通常是:

  1. 使用合适的原生 HTML;
  2. 保留原生键盘和表单行为;
  3. 只有在原生元素无法表达时才使用 ARIA;
  4. 使用 ARIA 后必须实现相应的键盘、焦点和状态行为。

例如:

<!-- 正确:操作使用 button -->
<button type="button">展开筛选器</button>

<!-- 错误:用 div 模拟按钮 -->
<div class="button" @click="open">展开筛选器</div>

第二种写法至少还要补充 role="button"tabindex="0"、Enter/Space 键行为、禁用状态和焦点样式,但补完后仍然容易遗漏浏览器已为原生 button 提供的细节。因此应优先使用原生元素。

6.2 对话框必须处理完整生命周期

一个可用的对话框通常需要:

  • 有可计算的名称;
  • 背景内容对辅助技术不可操作;
  • 打开时将焦点移入;
  • Tab 不能逃出对话框;
  • Escape 关闭(如果业务允许);
  • 关闭后将焦点还给触发元素;
  • 关闭时清理事件监听和滚动状态。

示例骨架:

<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'

const props = defineProps<{
  open: boolean
  title: string
}>()

const emit = defineEmits<{
  'update:open': [value: boolean]
}>()

const dialog = ref<HTMLElement | null>(null)
let restoreTarget: HTMLElement | null = null

function close() {
  emit('update:open', false)
}

function onKeydown(event: KeyboardEvent) {
  if (event.key === 'Escape') {
    event.preventDefault()
    close()
  }

  if (event.key !== 'Tab' || !dialog.value) return

  const focusable = Array.from(
    dialog.value.querySelectorAll<HTMLElement>(
      'button:not([disabled]), [href], input:not([disabled]), ' +
      'select:not([disabled]), textarea:not([disabled]), ' +
      '[tabindex]:not([tabindex="-1"])',
    ),
  )

  if (focusable.length === 0) {
    event.preventDefault()
    dialog.value.focus()
    return
  }

  const first = focusable[0]
  const last = focusable[focusable.length - 1]

  if (event.shiftKey && document.activeElement === first) {
    event.preventDefault()
    last.focus()
  } else if (!event.shiftKey && document.activeElement === last) {
    event.preventDefault()
    first.focus()
  }
}

watch(
  () => props.open,
  async isOpen => {
    if (isOpen) {
      restoreTarget = document.activeElement as HTMLElement | null
      document.addEventListener('keydown', onKeydown)
      await nextTick()
      dialog.value?.focus()
    } else {
      document.removeEventListener('keydown', onKeydown)
      await nextTick()
      restoreTarget?.focus()
      restoreTarget = null
    }
  },
  { immediate: true },
)

onBeforeUnmount(() => {
  document.removeEventListener('keydown', onKeydown)
})
</script>

<template>
  <Teleport to="body">
    <div v-if="open" class="ds-dialog-layer">
      <div class="ds-dialog-backdrop" @click.self="close" />

      <section
        ref="dialog"
        class="ds-dialog"
        role="dialog"
        aria-modal="true"
        :aria-label="title"
        tabindex="-1"
      >
        <h2>{{ title }}</h2>
        <div><slot /></div>
        <button type="button" @click="close">关闭</button>
      </section>
    </div>
  </Teleport>
</template>

这个示例展示了焦点进入、循环和恢复,但它仍有工程边界:

  • 复杂对话框还需要处理嵌套弹窗;
  • 需要防止背景内容被鼠标和辅助技术操作;
  • 需要锁定滚动并处理移动端;
  • 焦点元素查询应覆盖项目允许的交互元素;
  • 如果使用 aria-labelledby,标题必须有稳定的 id,不能只把标题字符串放入 aria-label
  • 原生 <dialog> 的行为、浏览器支持和 showModal() 细节需结合目标浏览器单独验证。

因此,组件库应通过自动化测试和真实辅助技术测试验证行为,不能认为“有 role="dialog"”就完成了无障碍。

6.3 状态必须同时对视觉和语义可见

错误提示不应只依赖红色:

<input
  :aria-invalid="hasError || undefined"
  aria-describedby="email-error"
/>
<p id="email-error" v-if="hasError" role="alert">
  请输入有效的邮箱地址
</p>

原因是:

  • 色觉差异用户可能无法识别红色;
  • aria-invalid 表达控件状态;
  • aria-describedby 将错误文本关联到输入框;
  • role="alert" 让动态出现的错误有机会被辅助技术播报。

role="alert" 不应滥用于所有状态变化,否则屏幕阅读器会产生过多打断。加载状态、成功提示和错误提示应根据信息紧急程度分别选择 aria-live="polite"role="status"role="alert"

6.4 动效必须允许减少

.ds-modal {
  transition: opacity 160ms ease, transform 160ms ease;
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    scroll-behavior: auto !important;
    transition-duration: 0.01ms !important;
  }
}

减少动效不是简单地把所有过渡删除。需要确保:

  • 状态变化仍然可理解;
  • 不依赖动画方向表达成功或失败;
  • 自动轮播可以暂停;
  • 页面不会因为持续运动干扰阅读。

七、文档是组件 API 的可执行契约

组件文档至少要说明五类信息:

  1. 用途与边界:何时使用,何时使用原生元素或其他组件;
  2. 属性和事件:类型、默认值、取值约束、副作用;
  3. 插槽和暴露方法:内容分发结构和调用条件;
  4. 状态示例:默认、悬停、聚焦、禁用、加载、错误、空数据、长文本;
  5. 无障碍行为:语义、键盘、焦点、ARIA、动效策略。

只展示一个“漂亮的正常状态”不能证明组件可用。例如表格至少应展示:

  • 加载中;
  • 空数据;
  • 服务端错误;
  • 长内容截断或换行;
  • 键盘操作;
  • 移动端布局。

7.1 用 VitePress 或 Storybook 的边界

VitePress 适合维护以 Markdown 为主的设计规范、指南和 API 文档;Storybook 适合以组件状态为中心的交互展示、视觉回归和隔离开发。它们是常见实现,不是 Vue 本身提供的规范能力,具体配置随版本变化。

无论采用哪种工具,示例都应尽可能使用真实组件入口,而不是复制一份与生产实现不同的演示代码。否则文档通过而实际组件失败,会形成“双重真相”。

可以把每个组件的示例组织为状态矩阵:

export const buttonCases = [
  { name: '默认', props: { variant: 'primary' }, slot: '保存' },
  { name: '禁用', props: { disabled: true }, slot: '保存' },
  { name: '加载', props: { loading: true }, slot: '保存' },
  { name: '危险操作', props: { variant: 'danger' }, slot: '删除' },
]

测试、文档和视觉回归可以共享这组案例,从而降低“文档状态”和“测试状态”漂移的概率。

7.2 文档中的示例必须解释失败路径

以异步组件为例,文档不能只展示:

const UserPanel = defineAsyncComponent(
  () => import('./UserPanel.vue'),
)

还应说明:

  • 网络失败时显示什么;
  • 重试入口在哪里;
  • 加载组件是否会导致布局跳动;
  • 组件卸载后异步结果如何处理;
  • 错误是否被上报;
  • 服务端渲染和客户端懒加载是否一致。

Vue 的 defineAsyncComponent 可以接收加载器和加载状态相关配置,但具体参数应以当前 Vue 3 版本 API 为准,组件库不应自行声称不存在的配置。异步加载失败也不应只显示空白区域。


八、测试应覆盖契约,而不是只覆盖快照

组件库至少需要四类测试。

8.1 类型和构建测试

验证:

  • Props、事件、插槽类型;
  • 公共入口是否可被消费者导入;
  • ESM/CJS 或目标模块格式是否符合发布约定;
  • CSS 和字体资源是否正确打包。

8.2 行为测试

例如按钮:

it('loading 时不应再次触发 click', async () => {
  const wrapper = mount(Button, {
    props: { loading: true },
    slots: { default: '保存' },
  })

  await wrapper.get('button').trigger('click')

  expect(wrapper.emitted('click')).toBeUndefined()
})

这个测试验证的是状态机规则,而不是某个 class 名称。实际项目可使用 Vitest、Vue Test Utils 等常见工具,但工具版本和配置应由项目锁定。

8.3 无障碍测试

自动化规则可以发现部分问题,例如缺少名称、非法 ARIA 属性和颜色对比度风险,但无法完全验证:

  • 键盘操作是否自然;
  • 焦点顺序是否合理;
  • 动态播报是否过多;
  • 屏幕阅读器用户是否理解上下文。

因此需要自动化检查、键盘人工测试和目标屏幕阅读器测试共同完成验证。

8.4 视觉回归测试

视觉快照适合发现:

  • Token 意外变化;
  • 间距和尺寸变化;
  • 不同主题下的颜色错误;
  • 文本换行和溢出;
  • hover、focus、disabled 等状态丢失。

但快照差异不是自动等于缺陷。Token 的有意修改也会产生大量差异,因此视觉测试必须与变更记录关联,并说明差异是预期还是回归。


九、组件库的包边界决定升级风险

推荐把公共入口与内部文件分开:

// src/index.ts
export { default as Button } from './components/Button.vue'
export { default as Dialog } from './components/Dialog.vue'
export { useRequest } from './composables/useRequest'
export type { ButtonProps } from './components/Button.types'

消费者应使用:

import { Button } from '@example/design-system'

而不是:

import Button from '@example/design-system/src/components/Button.vue'

深层导入会暴露目录结构。即使组件功能没有变化,重命名文件或调整目录也会破坏消费者构建。

还应明确哪些内容属于公共 API:

  • 组件名;
  • Props 和事件;
  • 插槽名及作用域参数;
  • 暴露的方法;
  • CSS 类名是否承诺稳定;
  • Token 名称;
  • 包入口和导出路径。

一个常见误解是“CSS 不是 API”。如果下游项目依赖 .ds-button__label 覆盖样式,那么这个类名事实上已经成为隐式 API。治理上要么明确支持它,要么通过 CSS 变量、组件属性和插槽提供正式扩展点,并迁移下游依赖。


十、版本治理:代码、视觉和 Token 都可能破坏兼容性

10.1 SemVer 适用于已定义的公共契约

语义化版本通常约定:

  • MAJOR:不兼容变更;
  • MINOR:向后兼容地增加能力;
  • PATCH:向后兼容的问题修复。

但“向后兼容”必须基于契约定义。以下变化可能是破坏性的:

删除 Button 的 variant="secondary"
修改默认 size 导致布局改变
删除 slot="footer"
修改事件 payload 结构
移除公共 CSS 变量
改变 Dialog 的焦点行为
提高最低 Node/Vue 版本

视觉变化也可能是破坏性的,即使 TypeScript 编译完全通过。例如按钮高度从 40px 变为 48px,可能使业务页面溢出;文字颜色改变可能使品牌审批或无障碍验收失败。

因此可以把变更影响拆成三类:

Impact=(API, Visual, Runtime)Impact = (API,\ Visual,\ Runtime)

  • API:类型、导出、属性、事件和插槽;
  • Visual:颜色、尺寸、间距、字体和布局;
  • Runtime:焦点、网络、异步、性能和错误行为。

发布说明应分别描述这三类影响,而不是只写“修复样式问题”。

10.2 Token 版本也需要治理

Token 名称的修改:

--color-text-primary → --color-content-primary

对下游而言等同于 API 删除。安全的迁移过程通常是:

  1. 新增新名称;
  2. 保留旧名称一段兼容周期;
  3. 旧名称通过新名称映射;
  4. 在开发环境或文档中发出弃用提示;
  5. 提供批量替换规则;
  6. 在主版本中删除旧名称。

兼容映射示例:

:root {
  --color-content-primary: #0f172a;

  /* 兼容旧名称,后续主版本移除 */
  --color-text-primary: var(--color-content-primary);
}

不能只修改设计工具中的 Token 名称,却不更新 CSS、TypeScript、文档和下游项目,否则会出现设计稿与运行时脱节。

10.3 变更记录必须描述迁移动作

一条有效的变更记录应回答:

变更:Dialog 的 close 事件不再携带原生 Event
影响:依赖 event 参数的消费者需要调整
迁移前:@close="handleClose($event)"
迁移后:@close="handleClose"
版本:下一个 MAJOR
验证:类型检查、键盘测试、焦点恢复测试

自动生成 Changelog 可以减少遗漏,但不能替代人工判断。工具只能知道文件发生变化,不一定知道变化是否破坏了无障碍行为或视觉契约。


十一、发布流程、故障路径和恢复

一个可靠的发布流程可抽象为:

提交变更
  ↓
类型检查、单元测试、无障碍检查
  ↓
构建包和文档
  ↓
视觉回归与变更评审
  ↓
生成版本和 Changelog
  ↓
发布预览包
  ↓
消费者验证
  ↓
正式发布

11.1 发布前验证

典型命令可能是:

npm ci
npm run typecheck
npm run test:unit
npm run build
npm run docs:build
npm run test:a11y
npm run test:visual

这些命令的前提是项目在 package.json 中定义了对应脚本,不能假设所有 Vue 项目天然拥有这些命令。预期结果是每个命令以退出码 0 完成;失败时应阻止发布。

不同失败的含义不同:

  • typecheck 失败:公共类型或内部类型不一致;
  • test:unit 失败:行为契约被破坏;
  • build 失败:入口、依赖或资源产物错误;
  • docs:build 失败:文档示例或站点构建错误;
  • test:a11y 失败:至少有自动化可检测的语义问题;
  • test:visual 失败:需要判断是预期视觉变更还是回归。

11.2 预览版本比直接发布更安全

可以先发布:

1.8.0-beta.1

让一个真实消费者安装预览版本,验证:

  • Vite 构建;
  • TypeScript 解析;
  • 主题加载;
  • SSR 或路由环境;
  • 页面布局;
  • 键盘和辅助技术行为。

预览版本不能替代自动化测试,因为它只能扩大环境覆盖,不能证明所有状态正确。

11.3 错误版本的恢复

如果版本已经公开,通常不应重复使用同一版本号覆盖内容。可选恢复路径是:

  1. 立即停止继续推广该版本;
  2. 在 Changelog 和包管理渠道标记问题;
  3. 发布修复版本;
  4. 对破坏性错误提供临时兼容层;
  5. 对受影响消费者给出明确回滚版本。

例如 2.1.0 删除了旧 Token,而下游尚未迁移,修复版本 2.1.1 可以重新提供兼容别名;如果行为本身无法兼容,则应发布迁移说明和新的主版本策略,而不是悄悄改变已经发布的 2.1.0


十二、Monorepo 和多包协作中的边界

组件库经常拆分为:

packages/
  tokens/
  vue/
  icons/
  docs/

依赖方向应尽量单向:

tokens → vue
tokens → docs
vue    → docs

tokens 不应依赖 Vue;基础组件不应反向依赖文档站点。否则构建顺序、循环依赖和发布版本会变得难以推断。

可以把版本关系写成:

VuePackageVersionTokensRequiredVersionVuePackageVersion \geq TokensRequiredVersion

当 Vue 组件包引用某个 Token 名称时,发布系统必须保证对应 Token 包版本已经包含该名称。如果 Token 包先删除旧变量,而 Vue 包仍然引用旧变量,就会出现构建成功但运行时样式丢失的故障。

在 Vite 工具链中,还要区分:

  • 开发环境的源码别名;
  • 发布后的包入口;
  • CSS 资源的导出路径;
  • peerDependencies 中的 Vue 版本;
  • 是否把 Vue 打进组件库产物。

通常组件库会把 Vue 声明为 peer dependency,避免应用中出现两份 Vue 运行时,但最终选择必须由打包格式和目标消费者决定,并通过真实安装测试验证。


十三、常见失败模式和诊断方法

13.1 组件有 disabled 外观但仍可操作

失败表现:

<div class="is-disabled" @click="submit">
  提交
</div>

诊断步骤:

  1. 用键盘 Tab 检查它是否进入焦点;
  2. 用 Enter 和 Space 检查是否触发;
  3. 用屏幕阅读器确认是否被读为按钮;
  4. 检查是否存在异步请求重复触发;
  5. 查看是否只是 CSS 透明度变化,没有行为保护。

修复优先级是改为原生 button,而不是继续给 div 增加 ARIA。

13.2 主题切换后部分组件不变

诊断可以搜索:

grep -R "#[0-9a-fA-F]\{6\}" src/components
grep -R "rgb(" src/components

这只能发现一部分硬编码值,还需要检查:

  • 内联 style
  • SVG fillstroke
  • 第三方组件;
  • 伪元素;
  • 图片和图标;
  • 阴影和焦点环。

如果组件仍写死颜色,说明 Token 消费边界没有建立,而不是主题切换 API 本身失败。

13.3 文档通过但真实项目失败

常见原因是文档使用了别名或全局注册,而消费者没有这些条件。例如文档中可以直接写:

<Button />

但真实包没有导出 Button,或者未加载组件 CSS。

诊断方法是建立一个最小消费者项目,仅通过发布包安装和导入:

import { Button } from '@example/design-system'
import '@example/design-system/style.css'

然后使用与业务相同的 Vite、TypeScript 和 Vue 版本进行构建。文档示例必须尽量通过同样的公共入口。

13.4 视觉回归大量失败

不能立即把所有差异都更新为新基线。应先分类:

Token 有意变更       → 记录设计决策,更新基线
字体加载失败         → 修复测试环境或资源路径
异步内容不稳定       → 等待稳定状态再截图
浏览器版本变化       → 固定或重新评估测试环境
组件布局回归         → 修复代码,不能更新基线

截图是证据,不是结论。更新基线前必须知道差异的原因。


十四、推荐的最小公共契约

一个可持续维护的 Vue 组件,至少应明确以下契约:

interface ComponentContract {
  purpose: string
  props: Record<string, unknown>
  emits: Record<string, unknown>
  slots: Record<string, unknown>
  states: string[]
  keyboard: string[]
  focus: string
  aria: string[]
  tokens: string[]
  errors: string[]
  compatibility: string
}

这不是要求项目真的使用这个接口,而是提醒设计、开发、测试和文档必须描述同一组事实。

Dialog 为例,契约应包括:

  • open 如何受控;
  • update:open 何时发出;
  • 标题如何提供;
  • Escape 是否关闭;
  • 点击遮罩是否关闭;
  • 打开和关闭时焦点去哪里;
  • 内容溢出时如何滚动;
  • 异步内容失败如何展示;
  • 是否支持嵌套;
  • 哪些 Token 可定制;
  • 哪个版本开始支持该行为。

当这些内容没有被定义时,组件行为会由实现细节决定;一旦实现重构,消费者就会发现自己依赖了未经声明的规则。


结语:把设计决策当作长期 API

Token 使视觉决策可命名、可映射、可测试;主题系统使同一套组件在运行时适应不同环境;无障碍要求组件的语义、键盘、焦点和动态反馈真正可用;文档把 API 和状态变成可验证的契约;版本治理则保证这些契约能够在多人、多项目和多次发布中持续演进。

它们之间不是并列的工具:

Token 决定组件消费什么视觉语义
组件状态决定需要哪些视觉和行为分支
无障碍决定这些分支如何被所有用户感知
文档记录可观察契约
测试验证契约
版本治理保护契约的演进

最终,一个成熟的 Vue 组件库不只是“能被 import 的 .vue 文件”,而是一套有明确语义、状态、失败路径、验证方式和迁移策略的公共基础设施。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。