详细参考 从-0到1开发一个组件库专题(AI组件库)https://www.yuque.com/sohucw/lhibze/gr4iyi8arsr2q60g
简历项目经验描述
版本1 - 适合初中级
参与公司组件库开发,提升组件复用率
- 基于 Vue3 开发通用业务组件 20+,组件复用率提升 40%
- 使用 VitePress 搭建组件文档站点,支持在线预览和代码演示
- 实现按需加载方案,通过 unplugin-vue-components 自动导入,打包体积减少 30%
版本2 - 适合高级
主导企业级组件库建设,支撑多个业务线
- 设计并实现组件库架构,制定组件设计原则和开发规范,代码质量评分 A+
- 建立组件文档自动生成系统(Storybook + 自研脚本),文档编写效率提升 5 倍
- 开发主题定制系统,支持 CSS 变量动态切换,满足 5+ 业务线的品牌需求
- 实现按需加载和 Tree Shaking,首屏加载时间减少 50%,打包体积减少 60%
版本3 - 适合架构方向
主导组件库生态建设,建立标准化的组件开发体系
- 设计组件分层架构(基础层/业务层/场景层),支撑 50+ 组件,可扩展性强
- 建立组件测试体系,单元测试覆盖率 95%+,E2E 测试覆盖核心交互场景
- 搭建组件 CI/CD 流程,实现自动发布、版本管理、Breaking Change 检测
- 制定组件贡献指南和 Code Review 标准,外部贡献 PR 30+,社区活跃度高
面试标准回答话术
Q1: 组件库的设计原则是什么?
标准回答
"组件库设计要遵循一些基本原则,保证组件质量和可维护性。
我们团队遵循的设计原则
1. 单一职责原则
每个组件只做一件事,功能要聚焦:
<!-- ❌ 不好:一个组件做太多事 -->
<template>
<div class="user-card">
<img :src="avatar" />
<h3>{{ name }}</h3>
<p>{{ bio }}</p>
<button @click="follow">关注</button>
<button @click="message">私信</button>
<!-- 还包含编辑、删除等功能... -->
</div>
</template>
<!-- ✅ 好:拆分成多个组件 -->
<UserCard>
<UserAvatar :src="avatar" />
<UserInfo :name="name" :bio="bio" />
<UserActions>
<FollowButton @click="follow" />
<MessageButton @click="message" />
</UserActions>
</UserCard>
2. 可组合性
组件要能灵活组合,不要做成巨石组件:
<!-- 基础组件 -->
<template>
<div class="card">
<slot name="header"></slot>
<slot></slot>
<slot name="footer"></slot>
</div>
</template>
<!-- 使用时可以灵活组合 -->
<Card>
<template #header>
<h3>标题</h3>
</template>
<p>内容</p>
<template #footer>
<Button>确定</Button>
</template>
</Card>
3. 可配置性
通过 props 控制组件行为,而不是写死:
<script setup>
const props = defineProps({
// 尺寸
size: {
type: String,
default: 'medium',
validator: (value) => ['small', 'medium', 'large'].includes(value)
},
// 类型
type: {
type: String,
default: 'default',
validator: (value) => ['default', 'primary', 'success', 'warning', 'danger'].includes(value)
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 是否加载中
loading: {
type: Boolean,
default: false
}
})
</script>
4. 可扩展性
通过插槽、作用域插槽提供扩展点:
<!-- 组件内部 -->
<template>
<div class="table">
<table>
<thead>
<tr>
<th v-for="col in columns" :key="col.key">
<!-- 表头插槽 -->
<slot :name="`header-${col.key}`" :column="col">
{{ col.title }}
</slot>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in data" :key="row.id">
<td v-for="col in columns" :key="col.key">
<!-- 单元格插槽 -->
<slot :name="`cell-${col.key}`" :row="row" :column="col">
{{ row[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<!-- 使用时可以自定义 -->
<MyTable :columns="columns" :data="data">
<!-- 自定义表头 -->
<template #header-name="{ column }">
<strong>{{ column.title }}</strong>
</template>
<!-- 自定义单元格 -->
<template #cell-status="{ row }">
<Tag :color="row.status === 'active' ? 'green' : 'red'">
{{ row.status }}
</Tag>
</template>
</MyTable>
5. 无副作用
组件不应该修改外部状态,所有状态变化通过事件通知:
<script setup>
// ❌ 不好:直接修改 prop
const props = defineProps(['value'])
function handleChange(newValue) {
props.value = newValue // 错误!
}
// ✅ 好:通过事件通知
const emit = defineEmits(['update:modelValue'])
function handleChange(newValue) {
emit('update:modelValue', newValue)
}
</script>
6. 易用性
提供合理的默认值,减少必填 props:
<script setup>
const props = defineProps({
// 有默认值,用户不传也能用
pageSize: {
type: Number,
default: 10
},
// 提供常用值
layout: {
type: String,
default: 'total, sizes, prev, pager, next, jumper'
}
})
</script>
7. 一致性
统一命名、统一 API 风格:
<!-- ❌ 不好:命名不一致 -->
<Button onClick="handleClick" />
<Input onChange="handleChange" />
<!-- ✅ 好:统一用 on 开头 -->
<Button @click="handleClick" />
<Input @change="handleChange" />
8. 可访问性(a11y)
支持键盘操作、屏幕阅读器:
<template>
<button
class="button"
:disabled="disabled"
:aria-label="ariaLabel"
:aria-disabled="disabled"
@click="handleClick"
@keydown.enter="handleClick"
@keydown.space.prevent="handleClick"
>
<slot></slot>
</button>
</template>
这些原则在我们组件库里严格执行,保证了组件的高质量。"
Q2: 如何搭建组件文档系统?
标准回答
"组件文档很重要,让使用者知道怎么用。我们用 VitePress 搭建文档站点。
VitePress 搭建步骤
1. 安装和初始化
npm install -D vitepress
# 初始化
npx vitepress init
# 选择配置
Where should VitePress initialize the config?
> ./docs
Theme:
> Default Theme
Use TypeScript for config and theme files?
> No
Add VitePress npm scripts to package.json?
> Yes
2. 目录结构
docs/
├── .vitepress/
│ ├── config.js # 配置文件
│ └── theme/ # 主题定制
├── components/ # 组件文档
│ ├── button.md
│ ├── input.md
│ └── table.md
├── guide/ # 使用指南
│ ├── installation.md
│ └── quick-start.md
└── index.md # 首页
3. 配置文件
// docs/.vitepress/config.js
export default {
title: 'My Component Library',
description: '企业级 Vue 组件库',
themeConfig: {
logo: '/logo.svg',
nav: [
{ text: '指南', link: '/guide/installation' },
{ text: '组件', link: '/components/button' },
{ text: 'GitHub', link: 'https://github.com/user/repo' }
],
sidebar: {
'/guide/': [
{
text: '开始',
items: [
{ text: '安装', link: '/guide/installation' },
{ text: '快速开始', link: '/guide/quick-start' }
]
}
],
'/components/': [
{
text: '基础组件',
items: [
{ text: 'Button 按钮', link: '/components/button' },
{ text: 'Input 输入框', link: '/components/input' }
]
},
{
text: '数据展示',
items: [
{ text: 'Table 表格', link: '/components/table' },
{ text: 'Card 卡片', link: '/components/card' }
]
}
]
},
socialLinks: [
{ icon: 'github', link: 'https://github.com/user/repo' }
]
}
}
4. 组件文档模板
<!-- docs/components/button.md -->
# Button 按钮
常用的操作按钮。
## 基础用法
最简单的用法。
<demo>
<Button>默认按钮</Button>
<Button type="primary">主要按钮</Button>
<Button type="success">成功按钮</Button>
<Button type="warning">警告按钮</Button>
<Button type="danger">危险按钮</Button>
</demo>
<<< @/demos/button/basic.vue
## 禁用状态
按钮不可用状态。
<demo>
<Button disabled>默认按钮</Button>
<Button type="primary" disabled>主要按钮</Button>
</demo>
<<< @/demos/button/disabled.vue
## 加载状态
点击按钮后进行数据加载操作,在按钮上显示加载状态。
<demo>
<Button loading>加载中</Button>
<Button type="primary" loading>加载中</Button>
</demo>
<<< @/demos/button/loading.vue
## API
### Props
| 参数 | 说明 | 类型 | 默认值 |
| --- | --- | --- | --- |
| type | 类型 | `'default' \| 'primary' \| 'success' \| 'warning' \| 'danger'` | `'default'` |
| size | 尺寸 | `'small' \| 'medium' \| 'large'` | `'medium'` |
| disabled | 是否禁用 | `boolean` | `false` |
| loading | 是否加载中 | `boolean` | `false` |
### Events
| 事件名 | 说明 | 参数 |
| --- | --- | --- |
| click | 点击按钮时触发 | `(event: MouseEvent)` |
### Slots
| 插槽名 | 说明 |
| --- | --- |
| default | 按钮内容 |
5. 注册组件(全局可用)
// docs/.vitepress/theme/index.js
import DefaultTheme from 'vitepress/theme'
import Demo from './components/Demo.vue'
import Button from '../../../src/button/Button.vue'
import Input from '../../../src/input/Input.vue'
export default {
...DefaultTheme,
enhanceApp({ app }) {
app.component('Demo', Demo)
app.component('Button', Button)
app.component('Input', Input)
}
}
6. Demo 组件(展示效果和代码)
<!-- docs/.vitepress/theme/components/Demo.vue -->
<template>
<div class="demo">
<div class="demo-preview">
<slot></slot>
</div>
<div v-if="showCode" class="demo-code">
<slot name="code"></slot>
</div>
<div class="demo-actions">
<button @click="showCode = !showCode">
{{ showCode ? '隐藏代码' : '显示代码' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const showCode = ref(false)
</script>
<style scoped>
.demo {
border: 1px solid #ebeef5;
border-radius: 4px;
margin: 20px 0;
}
.demo-preview {
padding: 20px;
border-bottom: 1px solid #ebeef5;
}
.demo-code {
padding: 20px;
background: #fafafa;
}
.demo-actions {
padding: 10px;
text-align: center;
border-top: 1px solid #ebeef5;
}
</style>
7. 自动生成 API 文档(高级)
// scripts/generate-docs.js
const fs = require('fs')
const path = require('path')
// 读取组件源码
function parseComponent(filePath) {
const content = fs.readFileSync(filePath, 'utf-8')
// 解析 props
const propsMatch = content.match(/defineProps\({([^}]+)}\)/)
const props = propsMatch ? parseProps(propsMatch[1]) : []
// 解析 emits
const emitsMatch = content.match(/defineEmits\(\[([^\]]+)\]\)/)
const emits = emitsMatch ? parseEmits(emitsMatch[1]) : []
return { props, emits }
}
// 生成文档
function generateDocs(componentName) {
const componentPath = path.join(__dirname, `../src/${componentName}`)
const api = parseComponent(`${componentPath}/index.vue`)
const doc = `
# ${componentName}
## API
### Props
| 参数 | 说明 | 类型 | 默认值 |
| --- | --- | --- | --- |
${api.props.map(p => `| ${p.name} | ${p.desc} | ${p.type} | ${p.default} |`).join('\n')}
### Events
| 事件名 | 说明 | 参数 |
| --- | --- | --- |
${api.emits.map(e => `| ${e.name} | ${e.desc} | ${e.params} |`).join('\n')}
`
fs.writeFileSync(
path.join(__dirname, `../docs/components/${componentName}.md`),
doc
)
}
8. 部署文档
# 构建
npm run docs:build
# 部署到 GitHub Pages
# 使用 GitHub Actions
name: Deploy Docs
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm install
- name: Build docs
run: npm run docs:build
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: docs/.vitepress/dist
我们的文档站点上线后,使用者反馈很好,能快速上手组件。"
Q3: 主题定制系统怎么实现?
标准回答
"主题定制就是让用户自定义组件的颜色、字体等样式。我们用 CSS 变量实现。
实现方案
1. 定义 CSS 变量
/* src/styles/theme.css */
:root {
/* 主色 */
--primary-color: #1890ff;
--primary-color-light: #40a9ff;
--primary-color-dark: #096dd9;
/* 成功色 */
--success-color: #52c41a;
--success-color-light: #73d13d;
--success-color-dark: #389e0d;
/* 警告色 */
--warning-color: #faad14;
--warning-color-light: #ffc53d;
--warning-color-dark: #d48806;
/* 危险色 */
--danger-color: #ff4d4f;
--danger-color-light: #ff7875;
--danger-color-dark: #cf1322;
/* 文字颜色 */
--text-color: #333;
--text-color-secondary: #666;
--text-color-disabled: #999;
/* 边框颜色 */
--border-color: #d9d9d9;
--border-color-light: #e8e8e8;
--border-color-dark: #bfbfbf;
/* 背景色 */
--background-color: #fff;
--background-color-light: #fafafa;
--background-color-dark: #f5f5f5;
/* 字体 */
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-size-base: 14px;
--font-size-small: 12px;
--font-size-large: 16px;
/* 圆角 */
--border-radius-base: 4px;
--border-radius-small: 2px;
--border-radius-large: 8px;
/* 间距 */
--spacing-base: 8px;
--spacing-small: 4px;
--spacing-large: 16px;
/* 阴影 */
--box-shadow-base: 0 2px 8px rgba(0, 0, 0, 0.15);
--box-shadow-light: 0 1px 4px rgba(0, 0, 0, 0.1);
}
2. 组件中使用变量
<!-- src/button/Button.vue -->
<template>
<button :class="['my-button', `my-button--${type}`, `my-button--${size}`]">
<slot></slot>
</button>
</template>
<script setup>
defineProps({
type: {
type: String,
default: 'default'
},
size: {
type: String,
default: 'medium'
}
})
</script>
<style scoped>
.my-button {
padding: var(--spacing-base) var(--spacing-large);
font-size: var(--font-size-base);
font-family: var(--font-family);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
background-color: var(--background-color);
color: var(--text-color);
cursor: pointer;
transition: all 0.3s;
}
.my-button--primary {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
.my-button--primary:hover {
background-color: var(--primary-color-light);
border-color: var(--primary-color-light);
}
.my-button--success {
background-color: var(--success-color);
border-color: var(--success-color);
color: #fff;
}
.my-button--small {
padding: var(--spacing-small) var(--spacing-base);
font-size: var(--font-size-small);
}
.my-button--large {
padding: var(--spacing-large) calc(var(--spacing-large) * 2);
font-size: var(--font-size-large);
}
</style>
3. 主题切换
// src/composables/useTheme.js
import { ref } from 'vue'
const currentTheme = ref('default')
// 预设主题
const themes = {
default: {
'primary-color': '#1890ff',
'success-color': '#52c41a',
'warning-color': '#faad14',
'danger-color': '#ff4d4f'
},
dark: {
'primary-color': '#177ddc',
'success-color': '#49aa19',
'warning-color': '#d89614',
'danger-color': '#d32029',
'text-color': '#fff',
'background-color': '#141414',
'border-color': '#434343'
},
green: {
'primary-color': '#52c41a',
'success-color': '#73d13d',
'warning-color': '#fadb14',
'danger-color': '#ff4d4f'
}
}
export function useTheme() {
// 切换主题
function setTheme(themeName) {
const theme = themes[themeName]
if (!theme) return
const root = document.documentElement
Object.entries(theme).forEach(([key, value]) => {
root.style.setProperty(`--${key}`, value)
})
currentTheme.value = themeName
localStorage.setItem('theme', themeName)
}
// 自定义主题
function setCustomTheme(customTheme) {
const root = document.documentElement
Object.entries(customTheme).forEach(([key, value]) => {
root.style.setProperty(`--${key}`, value)
})
currentTheme.value = 'custom'
localStorage.setItem('custom-theme', JSON.stringify(customTheme))
}
// 获取当前主题
function getCurrentTheme() {
return currentTheme.value
}
// 初始化主题
function initTheme() {
const savedTheme = localStorage.getItem('theme')
if (savedTheme && themes[savedTheme]) {
setTheme(savedTheme)
} else if (savedTheme === 'custom') {
const customTheme = JSON.parse(localStorage.getItem('custom-theme'))
setCustomTheme(customTheme)
}
}
return {
currentTheme,
setTheme,
setCustomTheme,
getCurrentTheme,
initTheme
}
}
4. 主题切换器组件
<!-- src/theme-switcher/ThemeSwitcher.vue -->
<template>
<div class="theme-switcher">
<select v-model="selectedTheme" @change="handleThemeChange">
<option value="default">默认主题</option>
<option value="dark">暗黑主题</option>
<option value="green">绿色主题</option>
<option value="custom">自定义</option>
</select>
<div v-if="selectedTheme === 'custom'" class="custom-theme">
<h4>自定义主题</h4>
<div v-for="(value, key) in customTheme" :key="key" class="theme-item">
<label>{{ key }}</label>
<input type="color" v-model="customTheme[key]" @change="applyCustomTheme" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useTheme } from '../composables/useTheme'
const { currentTheme, setTheme, setCustomTheme, initTheme } = useTheme()
const selectedTheme = ref('default')
const customTheme = ref({
'primary-color': '#1890ff',
'success-color': '#52c41a',
'warning-color': '#faad14',
'danger-color': '#ff4d4f'
})
function handleThemeChange() {
if (selectedTheme.value !== 'custom') {
setTheme(selectedTheme.value)
}
}
function applyCustomTheme() {
setCustomTheme(customTheme.value)
}
onMounted(() => {
initTheme()
selectedTheme.value = currentTheme.value
})
</script>
<style scoped>
.theme-switcher {
padding: 20px;
}
.custom-theme {
margin-top: 20px;
}
.theme-item {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
</style>
5. 颜色计算(自动生成浅色和深色)
// src/utils/color.js
export function lighten(color, amount) {
const hex = color.replace('#', '')
const num = parseInt(hex, 16)
let r = (num >> 16) + amount
let g = ((num >> 8) & 0x00FF) + amount
let b = (num & 0x0000FF) + amount
r = Math.min(255, Math.max(0, r))
g = Math.min(255, Math.max(0, g))
b = Math.min(255, Math.max(0, b))
return `#${(r << 16 | g << 8 | b).toString(16).padStart(6, '0')}`
}
export function darken(color, amount) {
return lighten(color, -amount)
}
// 自动生成主题色阶
export function generateColorPalette(baseColor) {
return {
base: baseColor,
light: lighten(baseColor, 40),
lighter: lighten(baseColor, 80),
dark: darken(baseColor, 40),
darker: darken(baseColor, 80)
}
}
6. LESS/SCSS 变量同步
如果用 LESS/SCSS,需要同步变量:
// scripts/sync-theme-vars.js
const fs = require('fs')
const cssVars = `
:root {
--primary-color: #1890ff;
--success-color: #52c41a;
}
`
const lessVars = `
@primary-color: var(--primary-color);
@success-color: var(--success-color);
`
fs.writeFileSync('src/styles/theme.less', lessVars)
通过这套主题系统,我们支持了 5 个业务线的不同品牌需求,每个业务都能用自己的主题色。"
Q4: 按需加载是怎么实现的?
标准回答
"按需加载就是只打包用到的组件,不用的不打包,减小包体积。
实现方案
方案1: 手动导入(基础)
用户手动导入需要的组件:
// main.js
import { createApp } from 'vue'
import App from './App.vue'
// 只导入用到的组件
import { Button, Input, Table } from 'my-component-library'
const app = createApp(App)
app.component('MyButton', Button)
app.component('MyInput', Input)
app.component('MyTable', Table)
app.mount('#app')
组件库导出:
// src/index.js
export { default as Button } from './button'
export { default as Input } from './input'
export { default as Table } from './table'
// ... 其他组件
方案2: 自动导入(推荐)
使用 unplugin-vue-components 自动导入:
npm install -D unplugin-vue-components
配置:
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
export default defineConfig({
plugins: [
vue(),
Components({
// 自动导入的组件库
resolvers: [
// 自定义解析器
(componentName) => {
if (componentName.startsWith('My')) {
return {
name: componentName.slice(2),
from: 'my-component-library'
}
}
}
],
// 或使用预设的解析器
// resolvers: [AntDesignVueResolver()]
})
]
})
使用:
<template>
<!-- 直接用,不需要 import -->
<MyButton>按钮</MyButton>
<MyInput v-model="value" />
<MyTable :data="data" />
</template>
<script setup>
// 不需要手动导入,插件自动处理
import { ref } from 'vue'
const value = ref('')
const data = ref([])
</script>
方案3: Tree Shaking
确保组件库支持 Tree Shaking:
// package.json
{
"name": "my-component-library",
"version": "1.0.0",
"main": "dist/index.cjs.js", // CommonJS (不支持 Tree Shaking)
"module": "dist/index.esm.js", // ES Module (支持 Tree Shaking)
"sideEffects": false, // 声明没有副作用
"exports": {
".": {
"import": "./dist/index.esm.js",
"require": "./dist/index.cjs.js"
},
"./dist/style.css": "./dist/style.css"
}
}
打包配置(Vite):
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: resolve(__dirname, 'src/index.js'),
name: 'MyComponentLibrary',
formats: ['es', 'cjs'],
fileName: (format) => `index.${format === 'es' ? 'esm' : 'cjs'}.js`
},
rollupOptions: {
external: ['vue'],
output: {
globals: {
vue: 'Vue'
},
// 保留原始文件结构,支持按需导入
preserveModules: true,
preserveModulesRoot: 'src'
}
}
}
})
方案4: 样式按需加载
使用 vite-plugin-style-import:
npm install -D vite-plugin-style-import
配置:
// vite.config.js
import { createStyleImportPlugin } from 'vite-plugin-style-import'
export default {
plugins: [
createStyleImportPlugin({
libs: [
{
libraryName: 'my-component-library',
esModule: true,
resolveStyle: (name) => {
return `my-component-library/dist/${name}/style.css`
}
}
]
})
]
}
组件库样式组织:
dist/
├── button/
│ ├── index.js
│ └── style.css
├── input/
│ ├── index.js
│ └── style.css
└── table/
├── index.js
└── style.css
效果对比
全量导入:
import MyUI from 'my-component-library'
import 'my-component-library/dist/style.css'
// 打包结果: 500KB (包含所有 50 个组件)
按需导入:
import { Button, Input } from 'my-component-library'
// 打包结果: 50KB (只有 2 个组件)
减少了 90% 的体积!
我们的组件库实施按需加载后,用户的打包体积平均减少了 60%,首屏加载快了很多。"
核心难点与解决方案
难点1: 组件的版本兼容性管理
问题描述: 组件库升级后,旧版本的使用方式不兼容,导致业务方代码报错,影响线上系统。
解决方案
"我建立了完整的版本管理和兼容性保障体系:
1. 语义化版本(SemVer)
版本号: MAJOR.MINOR.PATCH
1.0.0 -> 1.0.1 PATCH: 修复 bug,向后兼容
1.0.1 -> 1.1.0 MINOR: 新增功能,向后兼容
1.1.0 -> 2.0.0 MAJOR: 破坏性变更,不兼容
2. 废弃(Deprecate)而不是删除
<script setup>
import { computed } from 'vue'
const props = defineProps({
// 旧 prop,标记为废弃
color: {
type: String,
validator(value) {
console.warn('[Deprecated] prop "color" is deprecated, use "type" instead')
return true
}
},
// 新 prop
type: {
type: String,
default: 'default'
}
})
// 兼容旧 prop
const actualType = computed(() => {
return props.type || props.color || 'default'
})
</script>
3. 渐进式迁移
提供 codemod 脚本自动迁移:
// scripts/codemod-v2.js
const fs = require('fs')
const path = require('path')
function migrateCode(code) {
// 替换旧的 prop 名称
code = code.replace(/color="(\w+)"/g, 'type="$1"')
// 替换旧的事件名
code = code.replace(/@click-btn/g, '@click')
return code
}
function migrateFile(filePath) {
const code = fs.readFileSync(filePath, 'utf-8')
const newCode = migrateCode(code)
if (code !== newCode) {
fs.writeFileSync(filePath, newCode)
console.log(`✓ Migrated: ${filePath}`)
}
}
// 遍历所有 .vue 文件
function migrateProject(dir) {
const files = fs.readdirSync(dir)
files.forEach(file => {
const filePath = path.join(dir, file)
const stat = fs.statSync(filePath)
if (stat.isDirectory()) {
migrateProject(filePath)
} else if (file.endsWith('.vue')) {
migrateFile(filePath)
}
})
}
migrateProject('./src')
4. 版本检测和提示
// src/utils/version-check.js
const CURRENT_VERSION = '2.0.0'
const MIN_VUE_VERSION = '3.3.0'
export function checkVersion() {
// 检查 Vue 版本
const vueVersion = Vue.version
if (compareVersion(vueVersion, MIN_VUE_VERSION) < 0) {
console.error(
`[MyUI] Vue version ${MIN_VUE_VERSION}+ is required, but current version is ${vueVersion}`
)
}
// 检查已安装的版本
const installedVersion = localStorage.getItem('my-ui-version')
if (installedVersion && compareVersion(installedVersion, CURRENT_VERSION) < 0) {
console.warn(
`[MyUI] Detected upgrade from ${installedVersion} to ${CURRENT_VERSION}. ` +
`Please check the migration guide: https://example.com/migration`
)
}
localStorage.setItem('my-ui-version', CURRENT_VERSION)
}
function compareVersion(v1, v2) {
const arr1 = v1.split('.').map(Number)
const arr2 = v2.split('.').map(Number)
for (let i = 0; i < 3; i++) {
if (arr1[i] > arr2[i]) return 1
if (arr1[i] < arr2[i]) return -1
}
return 0
}
5. Breaking Change 文档
# v2.0.0 迁移指南
## 破坏性变更
### Button 组件
**改变的 prop:**
- `color` 重命名为 `type`
```diff
- <Button color="primary">按钮</Button>
+ <Button type="primary">按钮</Button>
```text
**改变的事件:**
- `click-btn` 重命名为 `click`
```diff
- <Button @click-btn="handleClick">按钮</Button>
+ <Button @click="handleClick">按钮</Button>
```text
### Table 组件
**移除的 prop:**
- 移除 `stripe` prop,请使用 `row-class-name`
```diff
- <Table :data="data" stripe />
+ <Table :data="data" :row-class-name="({ index }) => index % 2 === 0 ? 'even-row' : 'odd-row'" />
```text
## 自动迁移
我们提供了自动迁移脚本:
```bash
npx my-ui-codemod v2
```text
## 渐进式升级
如果无法立即升级,可以使用兼容模式:
```javascript
import { createApp } from 'vue'
import MyUI from 'my-ui'
const app = createApp(App)
app.use(MyUI, {
compatibility: true // 开启兼容模式
})
```text
注意:兼容模式将在 v3.0.0 移除。
```plain
通过这套版本管理体系,我们的组件库升级从来没有导致线上故障。"
### 难点2: 组件的性能优化和体积控制
#### 问题描述:
组件库越做越大,打包后体积 2MB+,加载慢,影响用户体验。
##### 解决方案:
"我从多个方面优化组件库性能:
###### 1. 代码拆分:
每个组件独立打包:
```javascript
// vite.config.js
export default {
build: {
lib: {
entry: {
'button': 'src/button/index.js',
'input': 'src/input/index.js',
'table': 'src/table/index.js'
}
},
rollupOptions: {
output: {
// 每个组件单独一个文件
chunkFileNames: '[name]/index.js',
entryFileNames: '[name]/index.js'
}
}
}
}
2. Tree Shaking
移除未使用的代码:
// package.json
{
"sideEffects": [
"*.css",
"*.scss"
]
}
标记纯函数:
// utils/format.js
/*#__PURE__*/
export function formatDate(date) {
return new Date(date).toLocaleDateString()
}
3. 外部化依赖
不打包 Vue 等运行时依赖:
// vite.config.js
export default {
build: {
rollupOptions: {
external: ['vue', 'vue-router', 'pinia'],
output: {
globals: {
vue: 'Vue',
'vue-router': 'VueRouter',
pinia: 'Pinia'
}
}
}
}
}
4. 压缩优化
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer'
export default {
build: {
minify: 'terser',
terserOptions: {
compress: {
drop_console: true, // 删除 console
drop_debugger: true, // 删除 debugger
pure_funcs: ['console.log'] // 删除特定函数调用
}
},
rollupOptions: {
plugins: [
// 分析打包体积
visualizer({
open: true,
filename: 'dist/stats.html'
})
]
}
}
}
5. 动态导入
按需加载大组件:
// src/index.js
export const Button = () => import('./button')
export const Input = () => import('./input')
// 小组件可以直接导出
export { default as Icon } from './icon'
6. CSS 优化
// vite.config.js
import { createStyleImportPlugin } from 'vite-plugin-style-import'
export default {
plugins: [
createStyleImportPlugin({
libs: [
{
libraryName: 'my-ui',
resolveStyle: (name) => {
// CSS 按需加载
return `my-ui/dist/${name}/style.css`
}
}
]
})
],
build: {
cssCodeSplit: true // CSS 代码分割
}
}
7. 图标优化
使用 SVG 图标,按需导入:
// src/icon/index.js
export { default as IconArrowUp } from './svg/arrow-up.svg?component'
export { default as IconArrowDown } from './svg/arrow-down.svg?component'
// 使用
import { IconArrowUp } from 'my-ui/icon'
8. 懒加载和虚拟化
大列表使用虚拟滚动:
<!-- src/table/Table.vue -->
<script setup>
import { VirtualScroller } from '../virtual-scroller'
const props = defineProps({
data: Array,
virtual: Boolean // 是否启用虚拟滚动
})
</script>
<template>
<VirtualScroller v-if="virtual" :items="data" />
<NormalTable v-else :data="data" />
</template>
优化效果
优化前:
- 总体积: 2MB
- gzip 后: 600KB
- 首屏加载: 3s
优化后:
- 总体积: 500KB (减少 75%)
- gzip 后: 150KB (减少 75%)
- 首屏加载: 800ms (减少 73%)
按需导入 3 个组件:
- 打包体积: 50KB (减少 97.5%)"
难点3: 组件的单元测试和 E2E 测试
问题描述: 组件越来越多,手动测试费时费力,而且容易漏测,导致线上出 bug。
解决方案
"我建立了完整的测试体系:
1. 单元测试(Vitest)
npm install -D vitest @vue/test-utils jsdom
配置:
// vitest.config.js
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
coverage: {
reporter: ['text', 'html', 'lcov'],
exclude: ['node_modules/', 'dist/', '**/*.spec.js']
}
}
})
测试示例:
// src/button/Button.spec.js
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import Button from './Button.vue'
describe('Button', () => {
it('renders correctly', () => {
const wrapper = mount(Button, {
slots: {
default: '按钮文本'
}
})
expect(wrapper.text()).toBe('按钮文本')
expect(wrapper.classes()).toContain('my-button')
})
it('renders different types', () => {
const types = ['default', 'primary', 'success', 'warning', 'danger']
types.forEach(type => {
const wrapper = mount(Button, {
props: { type }
})
expect(wrapper.classes()).toContain(`my-button--${type}`)
})
})
it('emits click event', async () => {
const wrapper = mount(Button)
await wrapper.trigger('click')
expect(wrapper.emitted()).toHaveProperty('click')
expect(wrapper.emitted('click')).toHaveLength(1)
})
it('disabled state', async () => {
const wrapper = mount(Button, {
props: { disabled: true }
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeUndefined()
expect(wrapper.attributes('disabled')).toBeDefined()
})
it('loading state', () => {
const wrapper = mount(Button, {
props: { loading: true }
})
expect(wrapper.find('.my-button__loading').exists()).toBe(true)
})
})
2. E2E 测试(Playwright)
npm install -D @playwright/test
npx playwright install
测试示例:
// e2e/button.spec.js
import { test, expect } from '@playwright/test'
test.describe('Button Component', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:5173/components/button')
})
test('should render button', async ({ page }) => {
const button = page.locator('.my-button').first()
await expect(button).toBeVisible()
await expect(button).toHaveText('默认按钮')
})
test('should trigger click', async ({ page }) => {
const button = page.locator('.my-button--primary')
const counter = page.locator('#click-count')
await button.click()
await expect(counter).toHaveText('1')
await button.click()
await expect(counter).toHaveText('2')
})
test('disabled button should not trigger click', async ({ page }) => {
const button = page.locator('.my-button[disabled]')
const counter = page.locator('#click-count')
await button.click({ force: true })
await expect(counter).toHaveText('0')
})
})
3. 视觉回归测试
// e2e/visual.spec.js
import { test, expect } from '@playwright/test'
test('button visual regression', async ({ page }) => {
await page.goto('http://localhost:5173/components/button')
// 截图对比
await expect(page).toHaveScreenshot('button-page.png')
})
4. 自动化测试流程
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm install
- name: Run unit tests
run: npm run test:unit
- name: Run E2E tests
run: npm run test:e2e
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
通过这套测试体系,我们的组件库测试覆盖率达到 95%+,线上 bug 率降低了 80%。"
(由于篇幅限制,完整技术实现和项目经验总结请见输出文档)