返回笔记首页

组件库建设 - 深度剖析

主题配置

详细参考 从-0到1开发一个组件库专题(AI组件库)https://www.yuque.com/sohucw/lhibze/gr4iyi8arsr2q60g

简历项目经验描述

版本1 - 适合初中级

plain
参与公司组件库开发,提升组件复用率
- 基于 Vue3 开发通用业务组件 20+,组件复用率提升 40%
- 使用 VitePress 搭建组件文档站点,支持在线预览和代码演示
- 实现按需加载方案,通过 unplugin-vue-components 自动导入,打包体积减少 30%

版本2 - 适合高级

plain
主导企业级组件库建设,支撑多个业务线
- 设计并实现组件库架构,制定组件设计原则和开发规范,代码质量评分 A+
- 建立组件文档自动生成系统(Storybook + 自研脚本),文档编写效率提升 5 倍
- 开发主题定制系统,支持 CSS 变量动态切换,满足 5+ 业务线的品牌需求
- 实现按需加载和 Tree Shaking,首屏加载时间减少 50%,打包体积减少 60%

版本3 - 适合架构方向

plain
主导组件库生态建设,建立标准化的组件开发体系
- 设计组件分层架构(基础层/业务层/场景层),支撑 50+ 组件,可扩展性强
- 建立组件测试体系,单元测试覆盖率 95%+,E2E 测试覆盖核心交互场景
- 搭建组件 CI/CD 流程,实现自动发布、版本管理、Breaking Change 检测
- 制定组件贡献指南和 Code Review 标准,外部贡献 PR 30+,社区活跃度高

面试标准回答话术

Q1: 组件库的设计原则是什么?

标准回答

"组件库设计要遵循一些基本原则,保证组件质量和可维护性。

我们团队遵循的设计原则
1. 单一职责原则

每个组件只做一件事,功能要聚焦:

vue
<!-- ❌ 不好:一个组件做太多事 -->
<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. 可组合性

组件要能灵活组合,不要做成巨石组件:

vue
<!-- 基础组件 -->
<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 控制组件行为,而不是写死:

vue
<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. 可扩展性

通过插槽、作用域插槽提供扩展点:

vue
<!-- 组件内部 -->
<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. 无副作用

组件不应该修改外部状态,所有状态变化通过事件通知:

vue
<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:

vue
<script setup>
const props = defineProps({
  // 有默认值,用户不传也能用
  pageSize: {
    type: Number,
    default: 10
  },

  // 提供常用值
  layout: {
    type: String,
    default: 'total, sizes, prev, pager, next, jumper'
  }
})
</script>
7. 一致性

统一命名、统一 API 风格:

vue
<!-- ❌ 不好:命名不一致 -->
<Button onClick="handleClick" />
<Input onChange="handleChange" />

<!-- ✅ 好:统一用 on 开头 -->
<Button @click="handleClick" />
<Input @change="handleChange" />
8. 可访问性(a11y)

支持键盘操作、屏幕阅读器:

vue
<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. 安装和初始化
bash
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. 目录结构
plain
docs/
├── .vitepress/
│   ├── config.js          # 配置文件
│   └── theme/             # 主题定制
├── components/            # 组件文档
│   ├── button.md
│   ├── input.md
│   └── table.md
├── guide/                 # 使用指南
│   ├── installation.md
│   └── quick-start.md
└── index.md              # 首页
3. 配置文件
javascript
// 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. 组件文档模板
markdown
<!-- 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. 注册组件(全局可用)
javascript
// 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 组件(展示效果和代码)
vue
<!-- 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 文档(高级)
javascript
// 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. 部署文档
bash
# 构建
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 变量
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. 组件中使用变量
vue
<!-- 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. 主题切换
javascript
// 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. 主题切换器组件
vue
<!-- 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. 颜色计算(自动生成浅色和深色)
javascript
// 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,需要同步变量:

javascript
// 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: 手动导入(基础)

用户手动导入需要的组件:

javascript
// 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')

组件库导出:

javascript
// 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 自动导入:

bash
npm install -D unplugin-vue-components

配置:

javascript
// 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()]
    })
  ]
})

使用:

vue
<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:

javascript
// 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):

javascript
// 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:

bash
npm install -D vite-plugin-style-import

配置:

javascript
// 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`
          }
        }
      ]
    })
  ]
}

组件库样式组织:

plain
dist/
├── button/
│   ├── index.js
│   └── style.css
├── input/
│   ├── index.js
│   └── style.css
└── table/
    ├── index.js
    └── style.css
效果对比

全量导入:

javascript
import MyUI from 'my-component-library'
import 'my-component-library/dist/style.css'

// 打包结果: 500KB (包含所有 50 个组件)

按需导入:

javascript
import { Button, Input } from 'my-component-library'

// 打包结果: 50KB (只有 2 个组件)

减少了 90% 的体积!

我们的组件库实施按需加载后,用户的打包体积平均减少了 60%,首屏加载快了很多。"


核心难点与解决方案

难点1: 组件的版本兼容性管理

问题描述: 组件库升级后,旧版本的使用方式不兼容,导致业务方代码报错,影响线上系统。

解决方案

"我建立了完整的版本管理和兼容性保障体系:

1. 语义化版本(SemVer)
plain
版本号: 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)而不是删除
vue
<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 脚本自动迁移:

javascript
// 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. 版本检测和提示
javascript
// 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 文档
markdown
# 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

移除未使用的代码:

javascript
// package.json
{
  "sideEffects": [
    "*.css",
    "*.scss"
  ]
}

标记纯函数:

javascript
// utils/format.js
/*#__PURE__*/
export function formatDate(date) {
  return new Date(date).toLocaleDateString()
}
3. 外部化依赖

不打包 Vue 等运行时依赖:

javascript
// vite.config.js
export default {
  build: {
    rollupOptions: {
      external: ['vue', 'vue-router', 'pinia'],

      output: {
        globals: {
          vue: 'Vue',
          'vue-router': 'VueRouter',
          pinia: 'Pinia'
        }
      }
    }
  }
}
4. 压缩优化
javascript
// 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. 动态导入

按需加载大组件:

javascript
// src/index.js
export const Button = () => import('./button')
export const Input = () => import('./input')

// 小组件可以直接导出
export { default as Icon } from './icon'
6. CSS 优化
javascript
// 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 图标,按需导入:

javascript
// 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. 懒加载和虚拟化

大列表使用虚拟滚动:

vue
<!-- 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)
bash
npm install -D vitest @vue/test-utils jsdom

配置:

javascript
// 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']
    }
  }
})

测试示例:

javascript
// 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)
bash
npm install -D @playwright/test
npx playwright install

测试示例:

javascript
// 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. 视觉回归测试
javascript
// 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. 自动化测试流程
yaml
# .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%。"


(由于篇幅限制,完整技术实现和项目经验总结请见输出文档)