一、为什么要造轮子:组件库开发的意义

在电商后台管理系统的开发中,我们团队曾经为了统一不同项目的UI风格,尝试过使用Element UI和Ant Design Vue。但随着业务场景的复杂化,现有组件库的扩展成本越来越高。比如需要给所有按钮加上动态波纹效果时,需要逐个修改node_modules里的源码,这促使我们决定自研组件库。

自研组件库的主要优势:

  1. 品牌风格定制:完全掌控视觉规范
  2. 功能深度定制:根据业务需求添加特定逻辑
  3. 性能优化空间:按需打包、精简依赖
  4. 统一开发规范:强制约束代码风格和API设计

二、技术选型与项目搭建

我们选择的技术栈组合:

  • Vue 3.2+(组合式API)
  • TypeScript 4.7+
  • Vite 4.0+(构建工具)
  • Sass(样式预处理)
  • Vitest(单元测试)

初始化项目结构:

# 示例目录结构说明
├── packages            # 组件源码
│   └── button
│       ├── src         # 组件源代码
│       ├── __tests__   # 单元测试
│       └── index.ts    # 组件入口
├── docs                # 文档站点
├── play                # 开发调试环境
└── package.json        # 项目配置

配置基础构建文件(vite.config.ts):

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue({
    reactivityTransform: true // 启用响应式语法糖
  })],
  build: {
    lib: {
      entry: 'packages/index.ts',
      name: 'MyComponentLibrary',
      fileName: 'my-lib'
    },
    rollupOptions: {
      external: ['vue'],
      output: {
        globals: {
          vue: 'Vue'
        }
      }
    }
  }
})

三、开发第一个原子组件(Button)

<!-- packages/button/src/Button.vue -->
<template>
  <button 
    :class="[
      'my-button',
      `my-button--${type}`,
      { 'is-disabled': disabled }
    ]"
    :disabled="disabled"
    @click="handleClick"
  >
    <!-- 插槽内容 -->
    <slot />
  </button>
</template>

<script lang="ts" setup>
import { withDefaults } from 'vue'

interface Props {
  type?: 'primary' | 'success' | 'warning' | 'danger'
  disabled?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  type: 'primary',
  disabled: false
})

const emit = defineEmits(['click'])

const handleClick = (event: MouseEvent) => {
  if (!props.disabled) {
    emit('click', event)
  }
}
</script>

<style lang="scss" scoped>
.my-button {
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.3s;

  &--primary {
    background: #409eff;
    color: white;
  }

  &.is-disabled {
    opacity: 0.6;
    cursor: not-allowed;
  }
}
</style>

组件测试用例(Vitest示例):

// packages/button/__tests__/Button.test.ts
import { mount } from '@vue/test-utils'
import Button from '../src/Button.vue'

describe('Button Component', () => {
  it('renders default type correctly', () => {
    const wrapper = mount(Button)
    expect(wrapper.classes()).toContain('my-button--primary')
  })

  it('emits click event when not disabled', async () => {
    const wrapper = mount(Button)
    await wrapper.trigger('click')
    expect(wrapper.emitted()).toHaveProperty('click')
  })

  it('prevents click when disabled', async () => {
    const wrapper = mount(Button, {
      props: { disabled: true }
    })
    await wrapper.trigger('click')
    expect(wrapper.emitted('click')).toBeUndefined()
  })
})

四、复杂组件开发实战(图标选择器)

<!-- packages/icon-picker/src/IconPicker.vue -->
<template>
  <div class="icon-picker">
    <div 
      v-for="icon in filteredIcons" 
      :key="icon.name"
      :class="['icon-item', { 'is-selected': selected === icon.name }]"
      @click="selectIcon(icon.name)"
    >
      <component :is="icon.component" />
      <span class="icon-name">{{ icon.name }}</span>
    </div>
    <input 
      v-model="searchText" 
      placeholder="搜索图标..." 
      class="search-input"
    />
  </div>
</template>

<script lang="ts" setup>
import { ref, computed } from 'vue'
import * as icons from '@vicons/ionicons5' // 第三方图标库

interface IconItem {
  name: string
  component: any
}

const props = defineProps<{
  selected?: string
}>()

const emit = defineEmits(['update:selected'])

const searchText = ref('')

const allIcons = Object.entries(icons).map(([name, component]) => ({
  name: name.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`),
  component
})) as IconItem[]

const filteredIcons = computed(() => 
  allIcons.filter(icon => 
    icon.name.toLowerCase().includes(searchText.value.toLowerCase())
  )
)

const selectIcon = (name: string) => {
  emit('update:selected', name)
}
</script>

五、文档系统建设(VitePress集成)

创建文档配置文件(.vitepress/config.js):

export default {
  title: 'My Component Library',
  themeConfig: {
    sidebar: [
      {
        text: '指南',
        items: [
          { text: '快速开始', link: '/guide/' },
          { text: '主题定制', link: '/guide/theming' }
        ]
      },
      {
        text: '组件',
        items: [
          { text: 'Button 按钮', link: '/components/button' },
          { text: 'IconPicker 图标选择器', link: '/components/icon-picker' }
        ]
      }
    ]
  }
}

六、构建优化与npm发布

package.json关键配置:

{
  "name": "@yourname/my-component-library",
  "version": "1.0.0",
  "files": ["dist"],
  "main": "dist/my-lib.umd.cjs",
  "module": "dist/my-lib.es.js",
  "exports": {
    ".": {
      "import": "./dist/my-lib.es.js",
      "require": "./dist/my-lib.umd.cjs"
    }
  },
  "scripts": {
    "build": "vite build",
    "prepublishOnly": "npm run build"
  }
}

发布流程:

npm login
npm publish --access public

七、应用场景与技术选型

典型应用场景

  • 中后台管理系统(需要统一的交互规范)
  • 多团队协作开发(强制统一代码风格)
  • 跨平台应用(封装通用业务逻辑)

技术对比

方案 维护成本 定制能力 学习成本
自研组件库 100% 中等
二次开发现有库 70%
纯手工开发 0%

八、开发陷阱与避坑指南

  1. 全局样式污染:使用scoped样式或CSS Module
  2. 版本锁定问题:通过peerDependencies声明Vue版本
  3. 类型声明丢失:导出类型定义文件(.d.ts)
  4. 浏览器兼容性:配置合适的polyfill
  5. 文档同步延迟:自动化文档生成(通过TS类型提取)

九、演进路线与未来展望

当组件库发展到50+组件时,建议采用这些优化策略:

  1. 引入按需加载(unplugin-vue-components)
  2. 开发可视化配置平台
  3. 集成自动化截图测试
  4. 构建主题配置系统
  5. 支持Web Components导出

十、文章总结

开发企业级Vue组件库需要完整的工程化思维,从原型设计到持续交付的每个环节都充满细节挑战。通过本文的实战演示,我们完成了从零到发布的全流程实践,重点攻克了类型安全、样式隔离、文档同步等常见痛点。在具体实践中,建议采用渐进式迭代策略,初期聚焦核心组件,逐步构建完整生态。