组件库设计
前言
组件库是前端工程化的核心产物之一。一套设计良好的组件库能够显著提升团队开发效率、保证产品视觉一致性、降低维护成本。本文从工程化全流程出发,系统梳理搭建企业级组件库所需的关键技术决策与最佳实践,涵盖从目录结构设计、构建配置、按需加载、主题定制、文档站点、API 设计到质量保障的完整链路。
从 0 到 1 搭建
目录结构
合理的目录结构是组件库可维护性的基石。以下是一个典型 Vue 3 组件库的项目结构:
ui-lib/
├── packages/
│ ├── components/ # 组件源码
│ │ ├── button/
│ │ │ ├── src/
│ │ │ │ ├── Button.vue
│ │ │ │ └── useButton.ts
│ │ │ ├── style/
│ │ │ │ └── index.scss
│ │ │ ├── __tests__/
│ │ │ └── index.ts # 组件入口
│ │ ├── input/
│ │ ├── icon/
│ │ └── ...
│ ├── theme/ # 主题系统
│ │ ├── src/
│ │ │ ├── variables.scss
│ │ │ └── mixins.scss
│ │ └── index.ts
│ ├── hooks/ # 公共组合式函数
│ ├── utils/ # 工具函数
│ └── shared/ # 类型定义与常量
├── docs/ # 文档站点
├── play/ # 本地开发调试
├── internal/ # 构建与工具脚本
├── examples/ # 业务示例项目
├── package.json
├── tsconfig.json
├── vite.config.ts
└── pnpm-workspace.yaml组件分类
组件可按功能粒度分为三个层次:
- 基础组件:Button、Input、Icon、Tag、Badge 等原子组件,复用范围最广,设计约束最强。
- 复合组件:Form、Table、TreeSelect、DatePicker 等组合型组件,内部由多个基础组件协作完成复杂交互。
- 业务组件:PageHeader、SearchForm、DetailCard 等面向具体业务场景的组件,通常沉淀自业务项目中的重复模式。
开发规范
统一的开发规范是多人协作的前提。核心规范包括:
- 组件命名采用 PascalCase(如
Button、DatePicker),文件命名采用 kebab-case(如date-picker.vue)。 - 每个组件必须包含
index.ts入口文件,负责注册组件并导出类型。 - 样式采用 BEM(Block Element Modifier)命名规范,避免样式污染。
- 所有组件均需以
L或Ui等统一前缀开头(如LButton、UiButton),避免与原生 HTML 标签及第三方库冲突。 - 每个组件必须包含 Vitest 单元测试,覆盖核心功能与边界情况。
构建配置
推荐使用 Vite 作为构建工具,通过 vite build 输出多种格式:
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
export default defineConfig({
build: {
lib: {
entry: 'packages/components/index.ts',
name: 'UiLib',
formats: ['es', 'cjs', 'umd'],
fileName: format => `ui-lib.${format}.js`,
},
rollupOptions: {
external: ['vue'],
output: {
globals: { vue: 'Vue' },
},
},
},
plugins: [vue(), dts()],
})测试
测试覆盖是组件库质量的保证线。建议采用分层测试策略:
- 单元测试:使用 Vitest 或 Jest 测试组件渲染逻辑、事件触发、Props 响应等。
- 快照测试:自动记录组件输出结构,在代码变更时提示人工审查变更合理性。
- E2E 测试:使用 Playwright 或 Cypress 模拟用户交互流程,验证组件在实际浏览器中的行为。
- 视觉回归测试:使用 Storybook 搭配 Chromatic 或 Percy 进行像素级对比。
- 兼容性测试:在多个浏览器版本(Chrome、Firefox、Safari、Edge)上验证。
发布
{
"name": "@company/ui-lib",
"version": "1.0.0",
"main": "dist/ui-lib.cjs.js",
"module": "dist/ui-lib.es.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"scripts": {
"release": "bumpp && pnpm build && pnpm publish"
}
}版本管理
遵循语义化版本(SemVer)规范:
- MAJOR:不兼容的 API 变更。
- MINOR:向下兼容的新功能。
- PATCH:向下兼容的 Bug 修复。
推荐使用 bumpp 或 standard-version 工具自动化版本 bump 和 changelog 生成。在 Monorepo 中结合 Changesets 管理跨包版本依赖。
按需加载
组件库的按需加载是控制应用产物体积的关键手段。大型组件库若全量导入,可能引入大量未使用的组件代码,拖慢页面加载速度。以下介绍几种主流的按需加载方案。
ES Module 构建与 Tree-shaking
现代打包工具(Vite、Webpack 5、Rollup)天然支持 ES Module 的 Tree-shaking 能力。组件库只需输出 ES Module 格式,并确保:
- 每个组件的副作用(side effects)标记为
false,或精确声明副作用文件。 - 组件通过具名导出(named export)暴露。
- 样式文件不与其他逻辑代码混杂,避免打包工具因无法分析副作用而保留未使用的样式。
{
"sideEffects": ["**/*.css", "**/*.scss"]
}在 package.json 中配置 "sideEffects: false" 即告知打包工具整个包无副作用,可以安全地摇掉未使用的导出。
手动引入
最简单直接的按需加载方式是手动引入单个组件及其样式:
import LButton from '@company/ui-lib/button'
import '@company/ui-lib/button/style'这要求组件库在每个组件目录下提供独立的子入口。可通过构建脚本为每个组件生成独立的 package.json 入口文件:
packages/button/
├── package.json # { "main": "index.js", "module": "index.mjs" }
├── index.js
└── index.mjsbabel-plugin-import
babel-plugin-import 是 Ant Design 时代流行的按需加载方案。它在编译阶段将全量导入语句自动转换为按需导入形式:
// 源代码
import { Button, Input } from '@company/ui-lib'
// 编译后
import Button from '@company/ui-lib/button'
import Input from '@company/ui-lib/input'使用该插件需要在组件库中为每个组件提供独立的子包路径。
unplugin-vue-components
unplugin-vue-components 是目前推荐的最现代化方案。它通过编译期 AST 分析自动识别模板中使用到的组件,并自动注入导入语句。开发者只需在模板中直接使用组件,无需手动 import:
<template>
<l-button type="primary">提交</l-button>
</template>在 Vite 配置中启用:
import Components from 'unplugin-vue-components/vite'
export default {
plugins: [
Components({
resolvers: [
(name) => {
if (name.startsWith('L')) {
return { name, from: `@company/ui-lib/${name.slice(1).toLowerCase()}` }
}
},
],
}),
],
}这种方式开发者体验最佳——零手动导入、零配置记忆、编译时自动完成。
自动引入与按需加载对比
| 方案 | 配置复杂度 | 开发体验 | 产物体积 | 适用场景 |
|---|---|---|---|---|
| 全量导入 | 最低 | 最佳 | 最大 | 小型应用、内部工具 |
| 手动引入 | 中等 | 较差 | 最小 | 对体积敏感的项目 |
| babel-plugin-import | 较高 | 良好 | 较小 | 遗留 Webpack 项目 |
| unplugin-vue-components | 低 | 最佳 | 较小 | 新项目、Vite 项目 |
主题定制
主题定制是组件库面向多品牌、多产品场景的必备能力。一套完善的主题系统应支持从运行时到编译时的多层级定制。
CSS 变量方案
CSS 自定义属性(Custom Properties)是运行时可定制的基础设施。组件库在根元素声明一组 CSS 变量,组件内部通过 var() 函数引用:
:root {
--l-color-primary: #409eff;
--l-color-success: #67c23a;
--l-font-size-base: 14px;
--l-border-radius: 4px;
--l-spacing-base: 8px;
}
.l-button {
background-color: var(--l-color-primary);
font-size: var(--l-font-size-base);
border-radius: var(--l-border-radius);
}用户只需覆盖变量即可改变主题,无需深入组件样式细节。
Design Token
Design Token 是主题系统的核心抽象。它将视觉设计属性(颜色、间距、字号、阴影等)抽象为语义化的 Token 值,分为三个层级:
- Global Token:原始设计值,如
blue-500: #409eff、spacing-4: 16px。 - Alias Token:语义别名,如
color-primary: blue-500、spacing-md: spacing-4。 - Component Token:组件级覆写,如
button-bg: color-primary。
Token 可用 JSON 格式管理,通过构建工具自动生成 CSS 变量:
{
"global": {
"blue-500": { "value": "#409eff" },
"gray-100": { "value": "#f5f7fa" }
},
"alias": {
"color-primary": { "value": "{blue-500}" },
"bg-base": { "value": "{gray-100}" }
},
"component": {
"button-bg": { "value": "{color-primary}" },
"button-radius": { "value": "{border-radius-base}" }
}
}使用 Style Dictionary 或 Design Token Transpiler 可将 Token JSON 编译为 CSS / SCSS / JS 等多种格式。
主题包
主题包是"换肤"的分发载体。组件库可提供多套官方主题包(如默认主题、暗色主题、CBD 主题),每套主题包仅包含变量覆写,不包含组件样式逻辑:
themes/
├── default/
│ └── variables.css
├── dark/
│ └── variables.css
└── cbd/
└── variables.css用户按需引入主题包即可完成换肤:
import '@company/ui-lib/themes/dark.css'暗色模式
暗色模式已成为现代 Web 应用的标配。组件库应系统性地支持暗色适配,具体措施包括:
- 定义两套 CSS 变量(light 和 dark),通过
[data-theme="dark"]选择器切换。 - 颜色 Token 满足 WCAG AA 级对比度标准(4.5:1)。
- 暗色下避免纯黑背景(建议
#1a1a2e至#16213e区间),降低视觉疲劳。 - 阴影和光晕效果做反向处理。
:root {
--l-bg-color: #ffffff;
--l-text-color: #303133;
}
[data-theme='dark'] {
--l-bg-color: #1a1a2e;
--l-text-color: #e5eaf3;
}运行时切换
运行时主题切换通过 JavaScript 动态修改 CSS 变量值实现,适合用户主动切换主题的场景:
function setThemeVariable(name: string, value: string) {
document.documentElement.style.setProperty(name, value)
}
// 一键切换主色
setThemeVariable('--l-color-primary', '#7c3aed')也可使用 CSSStyleDeclaration.setProperty 批量更新,或通过切换 data-theme 属性整体切换主题包。
编译时定制
对于构建时确定的主题(如企业级定制),使用 SCSS 变量覆盖是更经济的选择。组件库内部使用 SCSS 变量作为设计 Token 的源头,构建时通过 Vite 的 additionalData 注入定制变量:
// vite.config.ts
export default defineConfig({
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "@company/custom-theme/variables.scss";`,
},
},
},
})组件库的 SCSS 变量应使用 !default 标记,允许用户在引入之前通过变量覆盖自定义:
// 组件库默认变量
$l-color-primary: #409eff !default;
$l-border-radius: 4px !default;
// 用户覆盖
$l-color-primary: #7c3aed;
// 使用
.button {
background: $l-color-primary;
}主题定制方案对比
| 方案 | 定制时机 | 能力范围 | 实现复杂度 | 性能 |
|---|---|---|---|---|
| CSS 变量 | 运行时 | 有限(仅变量) | 低 | 优 |
| SCSS 覆盖 | 编译时 | 完整 | 中 | 优 |
| Design Token | 构建时 | 完整 | 高 | 优 |
| CSS-in-JS | 运行时 | 完整 | 中 | 较差 |
文档站点
文档是组件库与开发者之间的桥梁。良好的文档体验能够显著降低组件的学习和使用成本。
Storybook
Storybook 是业界最流行的组件开发与文档工具。它提供:
- 隔离开发:每个组件在独立沙箱中开发和测试。
- 交互式文档:通过 Controls 插件实时调整 Props 并预览效果。
- 自动生成:基于组件 Props 类型自动生成文档表格。
- 视觉测试:集成 Chromatic 进行视觉回归测试。
- 插件生态:Accessibility、Actions、Viewport 等丰富插件。
// button.stories.ts
import type { Meta, StoryObj } from '@storybook/vue3'
import LButton from './Button.vue'
const meta: Meta<typeof LButton> = {
title: '基础/Button',
component: LButton,
argTypes: {
type: { control: 'select', options: ['default', 'primary', 'danger'] },
size: { control: 'select', options: ['small', 'medium', 'large'] },
},
}
export default meta
type Story = StoryObj<typeof LButton>
export const Primary: Story = {
args: { type: 'primary', label: '主要按钮' },
}VitePress
对于以文档为主的站点,VitePress 是一个轻量高效的方案。它基于 Vite 和 Markdown,天然支持 Vue 组件渲染:
- 使用 Markdown 编写文档,支持 Vue 组件内嵌。
- 自定义主题和布局,与组件库视觉风格统一。
- 内置搜索、国际化、导航等开箱即用能力。
在 VitePress 中使用组件库示例:
<LButton type="primary">主要按钮</LButton>
<LButton>默认按钮</LButton>需要在 docs/.vitepress/theme/index.ts 中全局注册组件。
风格指南
风格指南页面定义组件库的设计语言,包括:
- 色彩体系(主色、中性色、功能色、数据色)。
- 字体层级(标题、正文、辅助文字的字号与行高)。
- 间距与栅格系统(8px 为基础网格单元)。
- 图标系统与使用规范。
- 动效设计(过渡时长、缓动函数)。
组件示例与 Props 文档
每个组件页面应包含:
- 基础用法:展示组件最常见的使用场景。
- API 表格:列出所有 Props、Events、Slots、Methods 及其类型和默认值。
- 代码展示:右侧或底部显示对应的源代码,支持一键复制。
- 源码展示:链接到 GitHub 上的组件源码。
Playground
Playground(在线演练场)是文档站点中最具互动性的部分。它允许开发者在不搭建本地环境的情况下直接调试组件。Playground 的核心功能包括:
- 实时编辑 Props 并预览效果。
- 编辑模板代码并即时渲染。
- 切换主题(亮色 / 暗色)。
- 复制当前配置的代码。
组件库文档站点的 Playground 可通过 Vue 的响应式系统 + defineComponent + compile 动态编译实现,也可使用 CodeSandbox 或 StackBlitz 的嵌入式方案。
组件 API 设计
组件 API 是组件库与使用者之间的契约接口。合理的设计能够提升易用性,降低误用概率。
Props
Props 是组件对外最核心的数据接口。设计 Props 时应遵循以下原则:
- 使用明确的语义化命名,避免含糊缩写。例如
disabled而非dis,readonly而非ro。 - 提供合理的默认值,使组件在无参数时也能正常工作。
- 对复杂类型使用 TypeScript 类型约束,利用 IDE 自动补全提升开发体验。
- 布尔类型 Props 命名采用形容词或过去分词形式(如
disabled、loading、bordered)。
interface ButtonProps {
type?: 'default' | 'primary' | 'success' | 'warning' | 'danger'
size?: 'small' | 'medium' | 'large'
disabled?: boolean
loading?: boolean
icon?: string
nativeType?: 'button' | 'submit' | 'reset'
}Events
Vue 3 中推荐使用 defineEmits 声明事件,确保事件名称遵循 kebab-case 规范:
const emit = defineEmits<{
click: [event: MouseEvent]
'update:modelValue': [value: string]
change: [value: string]
}>()事件参数应保持简洁。对于需要传递多个数据的情况,使用对象传递而非多参数。
Slots
Slots(插槽)为组件提供了内容分发能力。插槽设计的关键原则:
- 默认插槽:组件主体的内容注入点。
- 具名插槽:组件特定区域的定制入口,如
prefix、suffix、header、footer。 - 作用域插槽:将组件内部状态暴露给父组件,实现更灵活的渲染控制。
<template>
<div class="l-select">
<slot name="prefix" />
<input v-bind="$attrs" />
<slot name="suffix" />
<slot name="option" :item="selectedItem">
{{ selectedItem.label }}
</slot>
</div>
</template>Slots vs Props
Slots 和 Props 都能实现组件定制,但适用场景不同:
- Props 适合简单数据传递和开关式配置。例如设置按钮文本、控制是否禁用等。
- Slots 适合复杂内容注入和布局定制。例如在弹窗头部嵌入自定义标题,或在下拉选项中渲染多行内容。
- 当扩展方向不明确时,先使用 Props 保持简洁,后续需要灵活渲染时再过渡到 Slots。
组件组合模式
组件组合是 Vue 3 和 React 的核心设计思想。常用的组合模式包括:
- 直接组合:父组件直接使用子组件,通过 Props 和 Events 通信。
- 依赖注入:使用
provide/inject跨层级传递数据,适用于 Form、Table 等复合组件。 - Renderless 组件:组件只管理逻辑状态,将渲染完全委托给作用域插槽。
- 高阶组件(HOC):函数接收组件,返回增强后的新组件,在 Vue 3 中可通过
h函数实现。 - Composable(组合式函数):将组件逻辑提取到独立的函数中,供多个组件共享。
受控与非受控
受控模式和非受控模式是表单类组件的核心设计选择:
- 非受控模式:组件内部维护自身状态,使用者通过默认值初始化状态。适合简单场景。
- 受控模式:状态由父组件管理,组件仅负责触发事件通知变更。适合需要精确状态控制的场景。
Vue 中典型的受控模式通过 v-model 实现:
<!-- 受控 -->
<LInput v-model="name" />
<!-- 非受控 -->
<LInput :default-value="'初始值'" />组件库应同时支持受控和非受控两种模式,让使用者根据场景灵活选择。实现方式为:组件内部检测是否有 modelValue 传入,有则表现为受控组件,无则使用内部状态。
单向数据流
Vue 和 React 均遵循单向数据流原则:数据从父组件流向子组件,子组件通过事件向父组件通信,不可直接修改 Props。
在组件内部,对于非受控模式下的状态管理,可以使用 computed 同步 Props 变更:
const props = defineProps<{ modelValue?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
// 内部状态:优先使用 Props,无 Props 时使用内部状态
const internalValue = ref('')
const currentValue = computed({
get: () => props.modelValue ?? internalValue.value,
set: (val: string) => {
internalValue.value = val
emit('update:modelValue', val)
},
})组件库工程化
Monorepo
Monorepo 是大型组件库推荐的项目组织方式。它将组件库、主题包、工具函数、文档站点、示例项目统一放在一个仓库中,便于跨包共享代码和统一版本管理。
推荐使用 pnpm workspace 作为包管理工具:
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'docs'
- 'examples/*'Monorepo 的优势:
- 统一的构建链和依赖管理。
- 跨包代码共享无需发布 npm 包。
- 原子化提交(atomic commit),关联变更一步到位。
- 使用 Changesets 或 Lerna 管理跨包版本发布。
构建链
组件库构建链的典型流程:
- 类型检查:
tsc --noEmit - 代码检查:ESLint + Prettier
- 单元测试:Vitest
- 样式编译:SCSS / PostCSS → CSS
- 组件构建:Vite lib mode 输出 ES / CJS / UMD 格式
- 类型导出:vite-plugin-dts 生成
.d.ts声明文件 - 产物压缩与复制
构建脚本可使用 build:components、build:theme、build:all 分层组织。
类型导出
TypeScript 类型定义的质量直接影响组件库的 IDE 使用体验。关键要点:
- 所有 Props 接口均以
Props后缀命名并导出。 - 组件实例类型使用
defineComponent的返回类型自动推断。 - 在
package.json中通过types或typings字段指向入口类型文件。
// 组件类型导出
export type ButtonProps = InstanceType<typeof LButton>['$props']
export type ButtonInstance = InstanceType<typeof LButton>
// 全局导出类型
export type { ButtonProps, ButtonInstance } from './button'package.json 配置
组件库的 package.json 需要精确声明各入口:
{
"name": "@company/ui-lib",
"version": "1.0.0",
"type": "module",
"main": "dist/ui-lib.cjs.js",
"module": "dist/ui-lib.es.js",
"unpkg": "dist/ui-lib.umd.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/ui-lib.es.js",
"require": "./dist/ui-lib.cjs.js"
},
"./button": {
"types": "./dist/button/index.d.ts",
"import": "./dist/button/index.mjs",
"require": "./dist/button/index.cjs"
},
"./styles": "./dist/styles/index.css"
},
"sideEffects": ["**/*.css"],
"files": ["dist"]
}多入口导出
为支持按需加载,组件库需要为每个组件提供独立的导出入口。可以使用构建工具自动扫描组件目录并生成多入口配置:
// scripts/generate-exports.mjs
import fs from 'node:fs'
import path from 'node:path'
const componentsDir = 'packages/components'
const entries = {}
fs.readdirSync(componentsDir).forEach(dir => {
const fullPath = path.join(componentsDir, dir, 'index.ts')
if (fs.existsSync(fullPath)) {
entries[dir] = fullPath
}
})
export default entries每个组件入口导出组件本身及其类型:
// packages/button/index.ts
import LButton from './src/Button.vue'
import type { ButtonProps } from './src/types'
export { ButtonProps }
export default LButton质量保障
单元测试
单元测试是组件库质量保障的第一道防线。推荐使用 Vitest + @vue/test-utils 进行组件级测试。
测试覆盖的核心维度:
import { mount } from '@vue/test-utils'
import LButton from '../src/Button.vue'
describe('LButton', () => {
it('renders default slot content', () => {
const wrapper = mount(LButton, {
slots: { default: '提交' },
})
expect(wrapper.text()).toBe('提交')
})
it('emits click event', async () => {
const wrapper = mount(LButton)
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
})
it('does not emit click when disabled', async () => {
const wrapper = mount(LButton, { props: { disabled: true } })
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeUndefined()
})
})快照测试
快照测试通过对比组件渲染输出的预期结构来捕获非预期变更:
it('matches snapshot', () => {
const wrapper = mount(LButton, { props: { type: 'primary' } })
expect(wrapper.html()).toMatchSnapshot()
})快照测试的要点:
- 每次更新需要人工审查快照变更的合理性。
- 快照文件应纳入版本控制。
- 避免生成过大的快照(超过 100 行),拆分为多个精细测试。
E2E 测试
E2E 测试验证组件在实际浏览器中的完整交互流程。Playwright 是当前推荐的选择:
import { test, expect } from '@playwright/test'
test('DatePicker should select date correctly', async ({ page }) => {
await page.goto('/components/date-picker')
await page.click('.l-date-picker__input')
await page.click('text=15')
await expect(page.locator('.l-date-picker__input')).toHaveValue('2026-07-15')
})E2E 测试适合验证以下场景:
- 组件跨浏览器兼容性。
- 复杂交互流程(如拖拽排序、级联选择)。
- 组件与外部环境(如 Vue Router、Pinia)集成。
视觉回归测试
视觉回归测试在像素级别检测 UI 差异。通常与 Storybook 配合,使用 Chromatic 或 Percy 服务:
npx chromatic --project-token=CHROMATIC_PROJECT_TOKEN每次 CI 构建时,Chromatic 会捕获所有 Story 的截图并与基线对比,标记差异部分供人工审查。
兼容性测试
兼容性测试确保组件在不同环境中的稳定表现:
- 浏览器兼容:Chrome(最新 2 个大版本)、Firefox(最新 2 个大版本)、Safari(最新 2 个大版本)、Edge(最新)。
- 框架版本兼容:Vue 3.x、React 18.x 等。
- 屏幕尺寸兼容:桌面端(1920px、1440px、1280px)、平板端(768px)、移动端(375px)。
- 无障碍兼容:屏幕阅读器(NVDA、VoiceOver)、键盘导航、WCAG 2.1 AA 标准。
使用 BrowserStack 或 Sauce Labs 进行跨浏览器云测试,在 CI 中集成 Playwright 的移动端模拟设备。
结语
组件库建设是一个持续演进的过程,而非一次性工程。本文从工程化、按需加载、主题定制、文档站点、API 设计和质量保障六个维度系统梳理了企业级组件库的关键技术方案。实际的组件库建设应根据团队规模和业务需求进行取舍——小型团队可从基础规范入手,逐步完善;大型团队则建议一次性搭建完整的工程体系,为后续上百个组件的管理工作奠定基础。