第 129 章:组件库设计
学习目标
- 掌握组件库的设计原则与 API 规范
- 学会封装可复用的基础组件
- 理解主题系统、样式隔离与按需加载
- 能从零搭建一个组件库脚手架
一、什么是组件库
一组通用、可复用、有设计规范的 UI 组件集合,为多个项目提供一致的视觉与交互。
二、组件库设计原则
2.1 单一职责
typescript
// ✅ 每个组件只做一件事
MyButton // 按钮
MyInput // 输入
MyModal // 弹窗2.2 命名规范
| 类型 | 规范 | 示例 |
|---|---|---|
| 组件 | PascalCase + 前缀 | MyButton |
| 文件 | .vue | MyButton.vue |
| 目录 | kebab-case | components/button/ |
| Props | camelCase | modelValue |
| 事件 | kebab-case | update:modelValue |
2.3 目录结构
my-ui/
├── packages/
│ ├── components/
│ │ ├── button/
│ │ │ ├── Button.vue
│ │ │ ├── button.css
│ │ │ └── index.ts
│ │ ├── input/
│ │ └── index.ts
│ ├── styles/
│ │ ├── base.css
│ │ └── theme.css
│ └── index.ts
├── play/ # 测试/演示
│ └── App.vue
└── package.json三、组件 API 设计
3.1 Button 示例
vue
<!-- Button.vue -->
<script setup lang="ts">
interface Props {
type?: 'primary' | 'success' | 'warning' | 'danger';
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
loading?: boolean;
block?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
type: 'primary',
size: 'medium',
disabled: false,
loading: false,
block: false
});
const emit = defineEmits<{
click: [event: MouseEvent];
}>();
const handleClick = (e: MouseEvent) => {
if (!props.disabled && !props.loading) {
emit('click', e);
}
};
</script>
<template>
<button
:class="[
'my-button',
`my-button--${type}`,
`my-button--${size}`,
{ 'is-disabled': disabled, 'is-loading': loading, 'is-block': block }
]"
:disabled="disabled || loading"
@click="handleClick"
>
<span v-if="loading" class="my-button__loader" />
<slot />
</button>
</template>3.2 双向绑定(v-model)
vue
<!-- Input.vue -->
<script setup lang="ts">
interface Props {
modelValue: string;
type?: string;
placeholder?: string;
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
type: 'text',
disabled: false
});
const emit = defineEmits<{
'update:modelValue': [value: string];
change: [value: string];
}>();
const handleInput = (e: Event) => {
const val = (e.target as HTMLInputElement).value;
emit('update:modelValue', val);
};
</script>
<template>
<input
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
class="my-input"
@input="handleInput"
/>
</template>四、样式隔离
4.1 Scoped CSS
vue
<style scoped>
.my-button {
/* 只对当前组件生效 */
padding: 8px 16px;
}
</style>4.2 CSS 变量主题
css
/* theme.css */
:root {
--my-primary: #42b883;
--my-danger: #f56c6c;
--my-border-radius: 4px;
--my-font-size: 14px;
}
/* 暗色主题 */
[data-theme="dark"] {
--my-primary: #5fb889;
}vue
<style scoped>
.my-button {
background: var(--my-primary);
border-radius: var(--my-border-radius);
}
</style>4.3 BEM 命名
vue
<template>
<div class="my-modal">
<header class="my-modal__header">{{ title }}</header>
<div class="my-modal__body">
<slot />
</div>
<footer class="my-modal__footer">
<slot name="footer" />
</footer>
</div>
</template>五、组件分类
5.1 基础组件
typescript
// Button, Input, Select, Checkbox, Radio, Switch, Slider5.2 反馈组件
typescript
// Toast, Modal, Drawer, Alert, Notification, Message5.3 导航组件
typescript
// Menu, Tabs, Breadcrumb, Pagination, Steps5.4 数据展示
typescript
// Table, List, Card, Tag, Avatar, Badge5.5 业务组件
typescript
// UserCard, SearchBar, FileUploader, RichEditor六、组件通讯
6.1 props/emits
vue
<script setup lang="ts">
const props = defineProps<{ items: Item[] }>();
const emit = defineEmits<{ select: [item: Item] }>();
</script>6.2 v-model
vue
<!-- 支持多个 v-model -->
defineProps<{ modelValue: string; visible: boolean }>();
defineEmits<{
'update:modelValue': [v: string];
'update:visible': [v: boolean];
}>();6.3 provide/inject
typescript
// Carousel 组件向 Slide 注入
provide('carousel', {
current: currentIndex,
goto: (i: number) => { /* ... */ }
});七、插槽设计
7.1 默认插槽
vue
<template>
<div class="my-card">
<slot />
</div>
</template>7.2 具名插槽
vue
<template>
<div class="my-modal">
<header><slot name="header" /></header>
<main><slot /></main>
<footer><slot name="footer" /></footer>
</div>
</template>7.3 作用域插槽
vue
<script setup lang="ts">
interface Item { id: number; name: string }
defineProps<{ items: Item[] }>();
</script>
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot :item="item" :index="item.id" />
</li>
</ul>
</template>vue
<MyList :items="users">
<template #default="{ item, index }">
{{ index }} - {{ item.name }}
</template>
</MyList>八、组件库入口
8.1 统一导出
typescript
// packages/index.ts
import type { App } from 'vue';
import MyButton from './components/button';
import MyInput from './components/input';
import MyModal from './components/modal';
const components = [MyButton, MyInput, MyModal];
export {
MyButton,
MyInput,
MyModal
};
export default {
install(app: App) {
components.forEach((comp) => {
app.use(comp);
});
}
};8.2 单组件注册
typescript
// components/button/index.ts
import Button from './Button.vue';
Button.install = (app: App) => {
app.component('MyButton', Button);
};
export default Button;8.3 类型导出
typescript
// types.ts
export interface ButtonProps {
type?: 'primary' | 'success' | 'warning' | 'danger';
size?: 'small' | 'medium' | 'large';
}
export type ButtonInstance = InstanceType<typeof import('./Button.vue').default>;九、Monorepo 搭建
9.1 pnpm workspace
yaml
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'play'9.2 项目结构
my-ui/
├── packages/
│ ├── components/
│ ├── theme/
│ └── utils/
├── play/
├── docs/
├── pnpm-workspace.yaml
└── package.json9.3 内部依赖
json
{
"dependencies": {
"@my-ui/components": "workspace:*",
"@my-ui/theme": "workspace:*"
}
}十、按需加载
10.1 tree-shaking
typescript
// ✅ ES Module,自动按需
import { MyButton } from 'my-ui';10.2 unplugin-vue-components
bash
pnpm add -D unplugin-vue-componentstypescript
// vite.config.ts
import Components from 'unplugin-vue-components/vite';
import { MyUiResolver } from 'unplugin-vue-components/resolvers';
export default defineConfig({
plugins: [
Components({
resolvers: [MyUiResolver()]
})
]
});10.3 手动按需
typescript
// 注册全部
import MyUI from 'my-ui';
app.use(MyUI);
// 按需
import { MyButton, MyInput } from 'my-ui';
app.component('MyButton', MyButton);十一、文档与示例
11.1 VitePress 文档
markdown
# Button 按钮
## 基础用法
<demo-block>
<my-button>默认</my-button>
<my-button type="primary">主要</my-button>
</demo-block>
## API
| 参数 | 说明 | 类型 | 默认值 |
|------|------|------|--------|
| type | 类型 | string | primary |11.2 自动生成文档
typescript
// vite-plugin-md
import Markdown from 'unplugin-vue-markdown/vite';
export default defineConfig({
plugins: [
Markdown({
headmatter: {
title: 'title'
}
})
]
});十二、版本管理
12.1 语义化版本
主版本.次版本.修订号
↓ ↓ ↓
1 . 2 . 3- 主版本:不兼容 API 变更
- 次版本:向后兼容的功能新增
- 修订号:向后兼容的 bug 修复
12.2 Changesets
bash
pnpm add -D @changesets/climarkdown
# .changeset/xxx.md
---
'my-ui': patch
---
修复 Button 在 disabled 状态下的样式问题十三、测试
13.1 单元测试
typescript
import { mount } from '@vue/test-utils';
import MyButton from './Button.vue';
describe('MyButton', () => {
it('renders', () => {
const wrapper = mount(MyButton, {
props: { type: 'primary' },
slots: { default: 'Click' }
});
expect(wrapper.text()).toBe('Click');
});
it('emits click', async () => {
const wrapper = mount(MyButton);
await wrapper.trigger('click');
expect(wrapper.emitted('click')).toBeTruthy();
});
it('disabled', async () => {
const wrapper = mount(MyButton, { props: { disabled: true } });
await wrapper.trigger('click');
expect(wrapper.emitted('click')).toBeFalsy();
});
});13.2 视觉测试
typescript
// chromatic / Percy
pnpm add -D chromatic十四、推荐组件库
| 库 | 特点 |
|---|---|
| Element Plus | 桌面端,功能丰富 |
| Ant Design Vue | 阿里出品,企业级 |
| Naive UI | 简洁,Tailwind 风格 |
| Vuetify | Material Design |
| PrimeVue | 主题丰富 |
| Quasar | 多端 |
十五、本章小结
| 维度 | 关键 |
|---|---|
| 命名 | PascalCase + 统一前缀 |
| API | props + emits + slots |
| 样式 | Scoped + CSS 变量 |
| 主题 | CSS 变量 + 暗色模式 |
| 目录 | components/ + 单组件目录 |
| 注册 | install + 全局注册 |
| 文档 | VitePress + 演示 |
| 版本 | 语义化 + Changesets |
动手练习
- Button 组件:实现支持 type/size/loading/disabled 的按钮
- Modal 组件:实现支持 v-model 控制的弹窗
- Table 组件:实现支持列定义和数据分页的表格
- 主题系统:实现 CSS 变量切换深浅色
- 文档站:用 VitePress 搭建组件库文档
推荐阅读
- 📖 Element Plus 组件库 — 优秀实现参考
- 📖 Naive UI 源码 — 简洁设计
- 📖 Vue 组件库设计 — 模式指南
- 🌐 VitePress 文档 — 文档框架
下一章:第 130 章:动画与过渡 →