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 设计系统不是组件目录
设计系统是一组可共享的界面决策及其约束。它至少回答四个问题:
- 视觉决策:颜色、字体、间距、圆角、阴影如何定义;
- 行为决策:按钮何时可点击,弹窗如何关闭,表单错误何时出现;
- 语义决策:什么内容使用标题、列表、按钮、链接或状态提示;
- 交付决策:如何记录、测试、发布和迁移这些规则。
组件库是设计系统的一种工程化载体。它通常包含:
- Vue 组件;
- TypeScript 类型;
- 样式和 Token;
- 组件间组合规则;
- 文档和示例;
- 测试与构建产物。
组件库可以没有完整设计系统,但一个成熟的设计系统通常需要某种组件库来落地。反过来,如果组件库只提供 API,不规定交互语义和视觉来源,它更像“可复用代码集合”,而不是完整设计系统。
1.2 Token 是可命名的设计决策
Design Token 可以定义为:
一个具有稳定名称、明确语义和可转换值的设计决策。
例如:
{
"color": {
"blue": {
"500": "#2563eb"
}
},
"space": {
"4": "1rem"
}
}
这里的 color.blue.500 和 space.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 必须满足映射完整性
设组件状态集合为:
设每个状态需要的视觉属性为:
一个完整的组件主题映射至少应满足:
其中 是产品真正支持的状态集合, 是状态 所需的属性集合。
例如,按钮的 disabled 状态可能不需要焦点环,但 focus-visible 状态必须有可见的焦点样式。如果只定义默认背景和文字颜色,而没有定义禁用文字色,组件可能会继承普通文字颜色,导致禁用状态对比度过高或过低。
2.3 对比度不是“看起来差不多”
对于普通文本,WCAG 2.x 的相对亮度对比度计算可表示为:
其中 和 分别是前景色和背景色的相对亮度。亮度计算需要先将 sRGB 通道转换为线性 RGB:
再计算:
常见 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 系统主题与用户选择存在优先级
常见优先级可以定义为:
对应实现:
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>
运行过程是:
- 初始渲染
OverviewPanel; - 切换到
analytics时,异步加载其代码块; - 加载期间可以通过
defineAsyncComponent的配置提供加载组件和错误组件; KeepAlive会缓存已切换出去的组件实例,而不是销毁它;- 再次切回时,组件可能触发
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
可以形式化为:
例如:
(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 }
}
这里同时处理了三种故障路径:
- 新请求开始时取消旧请求;
- 旧请求即使未能真正取消,返回结果也因
sequence不匹配而被丢弃; - 组件卸载时取消请求,避免无意义的网络和状态更新。
需要注意,AbortController 只能要求支持取消的异步操作停止;服务器端已经处理的请求无法被客户端撤回。因此如果操作具有写入副作用,还需要服务端幂等键或请求版本控制。
六、无障碍不是给元素补几个 ARIA 属性
6.1 先选择正确的原生语义
无障碍实现的优先顺序通常是:
- 使用合适的原生 HTML;
- 保留原生键盘和表单行为;
- 只有在原生元素无法表达时才使用 ARIA;
- 使用 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 的可执行契约
组件文档至少要说明五类信息:
- 用途与边界:何时使用,何时使用原生元素或其他组件;
- 属性和事件:类型、默认值、取值约束、副作用;
- 插槽和暴露方法:内容分发结构和调用条件;
- 状态示例:默认、悬停、聚焦、禁用、加载、错误、空数据、长文本;
- 无障碍行为:语义、键盘、焦点、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,可能使业务页面溢出;文字颜色改变可能使品牌审批或无障碍验收失败。
因此可以把变更影响拆成三类:
API:类型、导出、属性、事件和插槽;Visual:颜色、尺寸、间距、字体和布局;Runtime:焦点、网络、异步、性能和错误行为。
发布说明应分别描述这三类影响,而不是只写“修复样式问题”。
10.2 Token 版本也需要治理
Token 名称的修改:
--color-text-primary → --color-content-primary
对下游而言等同于 API 删除。安全的迁移过程通常是:
- 新增新名称;
- 保留旧名称一段兼容周期;
- 旧名称通过新名称映射;
- 在开发环境或文档中发出弃用提示;
- 提供批量替换规则;
- 在主版本中删除旧名称。
兼容映射示例:
: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 错误版本的恢复
如果版本已经公开,通常不应重复使用同一版本号覆盖内容。可选恢复路径是:
- 立即停止继续推广该版本;
- 在 Changelog 和包管理渠道标记问题;
- 发布修复版本;
- 对破坏性错误提供临时兼容层;
- 对受影响消费者给出明确回滚版本。
例如 2.1.0 删除了旧 Token,而下游尚未迁移,修复版本 2.1.1 可以重新提供兼容别名;如果行为本身无法兼容,则应发布迁移说明和新的主版本策略,而不是悄悄改变已经发布的 2.1.0。
十二、Monorepo 和多包协作中的边界
组件库经常拆分为:
packages/
tokens/
vue/
icons/
docs/
依赖方向应尽量单向:
tokens → vue
tokens → docs
vue → docs
tokens 不应依赖 Vue;基础组件不应反向依赖文档站点。否则构建顺序、循环依赖和发布版本会变得难以推断。
可以把版本关系写成:
当 Vue 组件包引用某个 Token 名称时,发布系统必须保证对应 Token 包版本已经包含该名称。如果 Token 包先删除旧变量,而 Vue 包仍然引用旧变量,就会出现构建成功但运行时样式丢失的故障。
在 Vite 工具链中,还要区分:
- 开发环境的源码别名;
- 发布后的包入口;
- CSS 资源的导出路径;
peerDependencies中的 Vue 版本;- 是否把 Vue 打进组件库产物。
通常组件库会把 Vue 声明为 peer dependency,避免应用中出现两份 Vue 运行时,但最终选择必须由打包格式和目标消费者决定,并通过真实安装测试验证。
十三、常见失败模式和诊断方法
13.1 组件有 disabled 外观但仍可操作
失败表现:
<div class="is-disabled" @click="submit">
提交
</div>
诊断步骤:
- 用键盘 Tab 检查它是否进入焦点;
- 用 Enter 和 Space 检查是否触发;
- 用屏幕阅读器确认是否被读为按钮;
- 检查是否存在异步请求重复触发;
- 查看是否只是 CSS 透明度变化,没有行为保护。
修复优先级是改为原生 button,而不是继续给 div 增加 ARIA。
13.2 主题切换后部分组件不变
诊断可以搜索:
grep -R "#[0-9a-fA-F]\{6\}" src/components
grep -R "rgb(" src/components
这只能发现一部分硬编码值,还需要检查:
- 内联
style; - SVG
fill和stroke; - 第三方组件;
- 伪元素;
- 图片和图标;
- 阴影和焦点环。
如果组件仍写死颜色,说明 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 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 错误处理与可观测性:Error Boundary、日志、性能和发布诊断
- 下一篇:Vue SSR 与 Nuxt:水合、数据获取、缓存、SEO 和部署边界
- 延伸:Vue Slots 与动态组件:内容分发、KeepAlive、Teleport 和异步组件
- 延伸:Vue 可访问性与交互质量:语义、键盘、焦点、ARIA 和动效
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论