这类工具最值得先看的不是功能列表,而是能不能在普通环境里稳定跑起来。Vue 3 组件库开发,核心解决的是如何在现代前端项目中,高效、规范地复用 UI 元素和交互逻辑。它适合已经熟悉 Vue 3 基础语法,希望提升工程化能力、参与团队协作或构建自己产品体系的中高级开发者。最关键的价值在于,通过组件库的封装,你能把零散的样式和逻辑沉淀为可维护、可测试、可文档化的资产,而不是每次新项目都从零开始复制粘贴。
很多人一上来就研究复杂的打包配置和 Monorepo,结果连一个最简单的 Button 组件都跑不通。我更建议把第一次尝试拆成三步:搭建最小化的开发环境、实现一个具备完整生命周期的组件、最后再考虑如何打包发布给别人用。下面按实际落地顺序拆一遍。
1. 先搭建一个能跑组件源码的开发环境
开发组件库和开发普通 Vue 应用的环境需求有本质区别。普通应用关心的是最终打包出来的dist能不能在浏览器里跑;而组件库开发关心的是源码如何被编译、如何被测试、以及如何被其他项目作为依赖引入。环境没搭对,后面所有步骤都会卡住。
1.1 选择构建工具:Vite 还是 Vue CLI?
对于 2025 年的新项目,Vite 是默认且唯一推荐的选择。Vue CLI 虽然稳定,但其基于 Webpack 的架构在开发组件库时,热更新速度和构建配置的灵活性上已经落后。Vite 的即时编译和原生 ES 模块支持,能让组件的开发调试体验接近“所见即所得”。
创建一个用于开发组件库的 Vite 项目:
npm create vue@latest my-component-library在创建向导中,你需要做出几个关键选择:
- TypeScript: 必须选择Yes。组件库的 Props、Events 等接口定义是核心契约,TypeScript 能提供最可靠的类型安全和开发提示。
- JSX: 建议选择Yes。虽然在
.vue单文件组件里写模板也很方便,但在开发底层、逻辑复杂的组件(如表单、表格)时,JSX 的灵活性和编程能力是模板语法难以比拟的。你可以混用。 - Vue Router / Pinia: 选择No。组件库项目本身通常不需要路由和状态管理库,它们应该由使用组件库的应用来决定。
- 测试工具: 至少选择Vitest。组件单元测试是保证质量的基础,Vitest 与 Vite 生态契合度最高。E2E 测试可根据需要选择。
项目创建后,进入目录并安装依赖。这个环境将同时承担组件开发演示和库构建两个角色。
1.2 调整项目结构:区分源码、示例和打包输出
默认的 Vue 项目结构是为单页应用设计的,我们需要改造它以适应库开发模式。
my-component-library/ ├── packages/ # 核心:组件源码 │ └── button/ │ ├── src/ │ │ ├── Button.vue # 组件实现 │ │ └── index.ts # 组件导出 │ └── index.ts # 包入口 ├── play/ # 重要:开发调试与文档演示 │ ├── src/ │ │ ├── App.vue │ │ ├── main.ts │ │ └── views/ │ │ └── ButtonExample.vue # 组件的使用示例 │ └── vite.config.ts # 独立配置,仅用于开发服务器 ├── build/ # 构建脚本 │ └── vite.config.lib.ts # 库构建专用配置 ├── dist/ # 构建输出目录(由构建脚本生成) ├── node_modules/ └── package.json关键点解析:
packages/:每个组件一个独立文件夹,便于单独维护和按需引入。这是Monorepo思想的简化实践。play/:这是你的“沙盒”或“文档站”。在开发时,你通过npm run dev启动的是这个play项目,在这里导入并调试packages/下的组件。它完全独立,可以使用所有 Vue 生态工具(如 Vue Devtools)来调试组件。build/:存放构建库产物的 Vite 配置。这个配置的目标是将packages/下的源码,打包成多种格式(如ESM,CommonJS)的库文件,输出到dist/。
注意:不要试图用一个 Vite 配置同时服务
play开发服务器和库构建。它们的构建目标、入口、外部化依赖处理方式完全不同。分开配置更清晰、更不容易出错。
1.3 配置双份的package.json与构建脚本
根目录的package.json主要管理项目依赖和脚本。
{ "name": "my-component-library", "private": true, "type": "module", "scripts": { "dev": "vite --config play/vite.config.ts", "build:lib": "vue-tsc --noEmit && vite build --config build/vite.config.lib.ts", "build:play": "vite build --config play/vite.config.ts", "preview": "vite preview --config play/vite.config.ts", "test:unit": "vitest" }, "dependencies": { // 组件库运行时依赖,如 vue }, "devDependencies": { // 开发工具链依赖,如 @vitejs/plugin-vue, typescript, vitest 等 } }而packages/button/目录下,可以有一个简化的package.json,描述这个组件包的信息,这在未来单独发布某个组件时有用。
{ "name": "@my-lib/button", "version": "0.0.1", "main": "./dist/button.umd.cjs", "module": "./dist/button.js", "types": "./dist/index.d.ts", "exports": { ".": { "import": "./dist/button.js", "require": "./dist/button.umd.cjs" }, "./style.css": "./dist/style.css" }, "files": ["dist"], "peerDependencies": { "vue": ">=3.0.0" } }重点在于peerDependencies:它声明了该组件库需要宿主环境提供的依赖,避免将 Vue 打包进你的库中,导致版本冲突。
2. 实现第一个具备生产级考量的 Button 组件
有了环境,我们来实战一个 Button。这不仅是写个.vue文件,而是要思考一个组件如何被设计、使用和维护。
2.1 定义清晰的组件契约:Props、Slots、Events
在packages/button/src/Button.vue中,我们使用TypeScript和Composition API。
<template> <button :class="[ 'my-button', `my-button--${type}`, `my-button--${size}`, { 'is-plain': plain, 'is-round': round, 'is-circle': circle, 'is-disabled': disabled || loading, 'is-loading': loading } ]" :disabled="disabled || loading" :autofocus="autofocus" :type="nativeType" @click="handleClick" > <span v-if="loading" class="my-button__loading-indicator"></span> <slot v-if="$slots.icon" name="icon" /> <span class="my-button__content"> <slot /> </span> </button> </template> <script setup lang="ts"> import { computed } from 'vue' // 1. 定义 Props interface ButtonProps { type?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'default' size?: 'large' | 'default' | 'small' plain?: boolean round?: boolean circle?: boolean disabled?: boolean loading?: boolean autofocus?: boolean nativeType?: 'button' | 'submit' | 'reset' } const props = withDefaults(defineProps<ButtonProps>(), { type: 'default', size: 'default', plain: false, round: false, circle: false, disabled: false, loading: false, autofocus: false, nativeType: 'button' }) // 2. 定义 Emits const emit = defineEmits<{ click: [event: MouseEvent] }>() // 3. 事件处理 const handleClick = (event: MouseEvent) => { if (props.disabled || props.loading) { event.preventDefault() return } emit('click', event) } </script> <style scoped> .my-button { /* 基础样式 */ } .my-button--primary { /* 类型样式 */ } .my-button.is-disabled { /* 状态样式 */ } .my-button__loading-indicator { /* 加载动画 */ } </style>为什么这么设计?
- 类型安全:
interface ButtonProps和defineEmits的泛型提供了完美的类型推导和校验。 - 默认值:
withDefaults确保使用者不传参时组件行为稳定。 - 样式组织:BEM 命名规范(如
my-button__content)能有效避免样式冲突,即使未来开启scoped样式,也保持了良好的可读性。 - 原生属性支持:暴露
nativeType和autofocus,是对 HTML 原生<button>能力的继承,让组件更灵活。
2.2 编写单元测试:确保组件行为可靠
在packages/button/__tests__/Button.spec.ts中,使用Vitest和@vue/test-utils。
import { mount } from '@vue/test-utils' import Button from '../src/Button.vue' import { describe, it, expect } from 'vitest' describe('Button.vue', () => { it('renders default slot content', () => { const wrapper = mount(Button, { slots: { default: 'Click Me' } }) expect(wrapper.text()).toContain('Click Me') }) it('emits click event when clicked and not disabled', async () => { const wrapper = mount(Button) await wrapper.trigger('click') expect(wrapper.emitted()).toHaveProperty('click') }) it('does not emit click event when disabled', async () => { const wrapper = mount(Button, { props: { disabled: true } }) await wrapper.trigger('click') expect(wrapper.emitted('click')).toBeUndefined() }) it('applies correct CSS class for type', () => { const wrapper = mount(Button, { props: { type: 'primary' } }) expect(wrapper.classes()).toContain('my-button--primary') }) })测试的价值:单元测试不是走过场。当你修改组件逻辑、重构代码或升级依赖时,一套完整的测试用例能给你“安全网”,快速发现回归错误。对于 Button 这样的基础组件,至少要覆盖:渲染内容、Props 响应、事件触发、样式类名、禁用状态。
2.3 在 Playground 中实时调试
在play/src/views/ButtonExample.vue中,像真正的用户一样使用你的组件。
<template> <div class="demo-block"> <h3>基础用法</h3> <MyButton @click="handleClick">默认按钮</MyButton> <MyButton type="primary">主要按钮</MyButton> <MyButton type="success" :loading="loading" @click="startLoading"> {{ loading ? '加载中...' : '成功按钮' }} </MyButton> <h3>禁用状态</h3> <MyButton disabled>禁用按钮</MyButton> <MyButton type="primary" disabled>禁用主要按钮</MyButton> <h3>图标按钮</h3> <MyButton type="info"> <template #icon> <SearchIcon /> </template> 搜索 </MyButton> </div> </template> <script setup lang="ts"> import { MyButton } from '../../../packages/button' // 源码直接引用 import { ref } from 'vue' import SearchIcon from './icons/SearchIcon.vue' const loading = ref(false) const startLoading = () => { loading.value = true setTimeout(() => { loading.value = false }, 2000) } const handleClick = (evt: MouseEvent) => { console.log('Button clicked:', evt) } </script>通过npm run dev启动开发服务器,你就能在浏览器中实时看到组件效果,并用 Vue Devtools 检查组件的 Props、Events 和 DOM 结构。这是组件库开发中最高效的反馈循环。
3. 配置构建流程,生成可发布的库文件
组件在开发环境跑通了,接下来要把它打包成能被npm install或import的格式。这是普通应用开发和库开发差异最大的地方。
3.1 编写库构建的 Vite 配置
在build/vite.config.lib.ts中:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { resolve } from 'path' import dts from 'vite-plugin-dts' // https://vitejs.dev/config/ export default defineConfig({ plugins: [ vue(), dts({ tsconfigPath: resolve(__dirname, '../tsconfig.json'), outDir: resolve(__dirname, '../dist'), insertTypesEntry: true, // 生成类型声明入口 }), ], build: { outDir: resolve(__dirname, '../dist'), lib: { // 入口文件,这里导出所有组件 entry: resolve(__dirname, '../packages/index.ts'), name: 'MyComponentLibrary', fileName: (format) => `my-lib.${format}.js` }, rollupOptions: { // 确保外部化处理那些你不想打包进库的依赖 external: ['vue'], output: { // 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量 globals: { vue: 'Vue' }, exports: 'named' // 指定导出模式 } }, // 生成 sourcemap,便于调试 sourcemap: true, // 压缩代码 minify: 'terser' } })关键配置解析:
lib.entry:指定库的入口文件。通常是一个packages/index.ts,负责集中导出所有组件。external: [‘vue’]:这是最重要的配置之一。它告诉 Rollup (Vite 的构建引擎):“不要把 Vue 的代码打包进我的库文件里”。使用者项目里已经有 Vue 了,打包进去会导致代码冗余和潜在的版本冲突。dts插件:自动生成.d.ts类型声明文件。没有它,TypeScript 项目在使用你的库时将无法获得类型提示。output.exports:设置为’named’确保组件被正确导出,避免在 CommonJS 环境下出现default导出问题。
3.2 创建统一的入口文件
在packages/index.ts中:
// 集中导出所有组件 export { default as MyButton } from './button' // 可以选择导出一个 install 函数,用于 Vue.use() 全局注册 import type { App } from 'vue' import * as components from './components' const install = (app: App) => { for (const [key, component] of Object.entries(components)) { app.component(key, component) } } export default install // 同时导出所有组件,支持按需引入 export * from './components'在packages/components.ts中汇总所有组件,便于管理。
3.3 执行构建并检查产物
运行npm run build:lib,完成后检查dist/目录:
dist/ ├── my-lib.es.js # ES Module 格式,现代打包器和原生 ES 模块使用 ├── my-lib.umd.cjs # UMD 格式,兼容 CommonJS 和浏览器全局变量 ├── my-lib.umd.cjs.map # Sourcemap 文件 ├── index.d.ts # 自动生成的类型声明入口 └── button/ └── style.css # 提取的样式文件(如果配置了样式提取)验证产物是否健康:
- 用
node快速检查 CommonJS 导入:node -e “console.log(require(‘./dist/my-lib.umd.cjs’))”,应该能看到导出的对象。 - 创建一个干净的测试项目,通过
npm link或file:../path-to-lib的方式安装你的库,尝试import { MyButton } from ‘my-lib’,看是否能正常使用并获得类型提示。
4. 处理样式:从基础到主题化
样式是组件库用户体验的核心。处理不当会导致样式污染、打包体积过大或难以定制。
4.1 样式方案选择:CSS-in-JS、Scoped CSS 还是独立样式文件?
- Scoped CSS (推荐用于组件库):在
.vue文件的<style scoped>中编写。Vue 会自动为元素添加>:root { /* 颜色 */ --my-color-primary: #409eff; --my-color-success: #67c23a; --my-color-warning: #e6a23c; --my-color-danger: #f56c6c; --my-color-info: #909399; /* 字体 */ --my-font-size-base: 14px; --my-font-size-small: 12px; --my-font-size-large: 16px; /* 尺寸 */ --my-border-radius-base: 4px; --my-border-radius-round: 20px; }然后,在组件样式中使用这些变量:
.my-button--primary { background-color: var(--my-color-primary); border-color: var(--my-color-primary); color: #fff; } .my-button { font-size: var(--my-font-size-base); border-radius: var(--my-border-radius-base); }主题切换的实现:使用者可以通过在更高层级的元素(如
body)上覆盖这些 CSS 变量值,来实现全局主题切换。你也可以提供一个主题生成器工具,帮助用户生成一套覆盖变量值的样式表。4.3 样式按需引入与 Tree-shaking
如果你的样式是独立文件,需要配置构建工具,确保样式能被正确提取并与 JS 代码关联。在
vite.config.lib.ts中,确保 CSS 代码分割配置正确,避免将所有样式打包进一个巨大的style.css。同时,在
package.json中通过sideEffects字段告知打包器(如 Webpack、Rollup)哪些文件具有副作用(如样式文件),避免在 Tree-shaking 时被误删。{ "sideEffects": [ "**/*.css" ] }5. 文档、发布与持续集成
一个没有文档的组件库几乎不可用。一个不能一键发布的流程会极大降低迭代效率。
5.1 使用 Vitepress 构建组件文档站
Vitepress 基于 Vite 和 Vue 3,是编写 Vue 组件库文档的绝佳选择。它在
play目录的基础上,增加了路由、Markdown 解析和搜索等能力。- 在项目根目录安装 Vitepress:
npm add -D vitepress - 初始化文档结构,将
play中的示例迁移到.md文件中。 - 在
.md文件中,你可以直接编写 Vue 组件示例,Vitepress 会实时渲染。
# Button 按钮 常用的操作按钮。 ## 基础用法 使用 `type`、`size`、`disabled` 等属性来定义按钮的样式。 ::: demo <template> <MyButton>默认按钮</MyButton> <MyButton type="primary">主要按钮</MyButton> </template> ::: ## API ### Props | 参数 | 说明 | 类型 | 可选值 | 默认值 | |------|------|------|--------|--------| | type | 类型 | string | primary / success / warning / danger / info / default | default | | size | 尺寸 | string | large / default / small | default | | disabled | 是否禁用 | boolean | - | false |文档站应该独立构建和部署(例如到 GitHub Pages 或 Vercel),与你的 NPM 包同步更新。
5.2 配置自动化发布流程
手动更新版本号、打 Tag、构建、发布到 NPM 很容易出错。使用
release-it或changesets这类工具可以自动化这个过程。以
release-it为例:npm install -D release-it- 在
package.json中配置脚本:”release”: “release-it” - 创建
.release-it.json配置文件,关联 CHANGELOG 生成、GitHub Release 创建和 NPM 发布。 - 运行
npm run release,工具会引导你选择版本号、生成变更日志、提交代码、打 Tag、执行构建命令(npm run build:lib)、最后发布到 NPM。
5.3 集成 CI/CD (如 GitHub Actions)
在
.github/workflows/release.yml中配置自动化工作流,实现代码推送后自动运行测试、构建文档站、并在合并到主分支时触发发布流程。name: Release on: push: branches: [ main ] jobs: test-and-build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: { node-version: 18 } - run: npm ci - run: npm run test:unit - run: npm run build:lib - run: npm run build:docs # 后续可以添加部署文档站到 GitHub Pages 的步骤6. 进阶:处理复杂组件与生态集成
当基础组件稳定后,你会遇到更复杂的场景。
6.1 表单类组件:实现双向绑定与验证集成
对于 Input、Select、DatePicker 等表单组件,核心是优雅地实现
v-model支持。Vue 3 中,这通过modelValueProp 和update:modelValueEvent 实现。<script setup lang="ts"> import { computed } from 'vue' const props = defineProps<{ modelValue: string }>() const emit = defineEmits<{ 'update:modelValue': [value: string] }>() const value = computed({ get: () => props.modelValue, set: (val) => emit('update:modelValue', val) }) </script> <template> <input v-model="value" /> </template>更进一步,可以考虑与流行表单验证库(如
vee-validate、async-validator)集成,通过provide/inject将表单实例的验证状态、错误信息传递给子组件,实现联动验证。6.2 虚拟滚动与性能优化
对于表格、列表等需要渲染大量数据的组件,虚拟滚动是必备技能。核心原理是只渲染可视区域及缓冲区的 DOM 元素。你可以直接使用成熟的库如
vue-virtual-scroller,或者基于@vueuse/core的useVirtualList组合式函数来封装自己的虚拟滚动组件。关键在于精确计算滚动位置、项目高度和渲染范围。6.3 支持 Nuxt 等 SSR 框架
如果你的组件库需要在 Nuxt 等服务器端渲染框架中使用,需要特别注意:
- 避免浏览器全局 API:如
window、document、localStorage的访问,必须在生命周期钩子onMounted或beforeMount中进行,或使用import.meta.client进行环境判断。 - 样式处理:确保样式在服务器端也能被正确收集和注入,避免客户端闪屏。
- 自动导入:可以编写一个 Nuxt 模块,实现组件的自动按需导入,极大提升开发体验。
6.4 按需导入与自动导入
对于使用者来说,全量导入 (
import MyLib from ‘my-lib’) 会增加打包体积。更优的方案是支持按需导入。除了手动按文件导入,可以借助unplugin-vue-components这类插件,实现自动导入。使用者只需在项目中配置该插件,就可以在模板中直接使用<MyButton>,插件会在编译时自动引入对应的组件和样式。这需要在你的组件库中提供对应的解析器,通常是通过一个
components.d.ts文件来声明所有组件的类型。7. 避坑指南与经验总结
踩过几次之后我发现,很多问题不是工具能力不够,而是前置环境和输入材料没有处理干净。
7.1 类型声明文件生成失败或不全
问题:使用者安装库后,VSCode 没有类型提示,或者提示找不到模块。排查:
- 首先检查
dist/目录下是否有*.d.ts文件。 - 检查
vite-plugin-dts插件的tsconfigPath配置是否正确指向项目的tsconfig.json。 - 检查
package.json中的types字段是否指向正确的声明文件入口(如”types”: “./dist/index.d.ts")。 - 确保组件源码中的
defineProps、defineEmits等使用了 TypeScript 泛型,因为插件依赖这些信息生成类型。
7.2 样式丢失或冲突
问题:组件功能正常,但没有样式,或者样式被项目中的其他 CSS 覆盖。排查:
- 未引入样式:确认使用者是否手动引入了 CSS 文件(如
import ‘my-lib/dist/style.css’)。如果配置了自动导入,检查插件是否配置了样式自动导入。 - Scoped 样式选择器过深:检查构建后的 CSS 文件,如果选择器层级过深(如
.my-button[data-v-xxx] .content[data-v-xxx]),可能会被使用者项目中的更高优先级样式覆盖。考虑简化 CSS 选择器层级,或提供 CSS 变量让使用者覆盖。 - CSS 变量未定义:如果使用了 CSS 变量,确保在文档中说明,并提供一个基础的主题 CSS 文件供使用者引入。
7.3 构建产物在旧环境或打包工具中报错
问题:在 Webpack 4 项目或低版本 Node 环境中无法使用。排查:
- 语法兼容性:Vite 默认构建产物是 ES2020+ 语法。如果需要支持更旧的环境,需要在
vite.config.lib.ts的build.target中设置,例如target: [‘es2015’, ‘chrome58’]。 - 包导出格式:确保
package.json中正确配置了main(CommonJS)、module(ESM) 和exports字段,以兼容不同的模块化方案。 - 外部依赖:反复确认
external配置是否正确,确保vue等依赖没有被错误打包进去。
7.4 组件在 HMR 时状态丢失
问题:在开发
play项目时,修改组件代码,热更新后组件的内部状态(如输入框的值)被重置了。原因与解决:这通常是因为.vue文件被完全重新加载。Vue 的默认 HMR 行为会尝试保持组件状态,但对于复杂的组件可能失效。可以尝试:- 将状态提升到父组件(示例页面)中。
- 使用
@vue/runtime-core的hot.acceptAPI 进行自定义 HMR 处理(较复杂,通常不需要)。
我个人更建议先把单任务跑稳,再考虑批量和接口。对于 Vue 3 组件库开发,最该盯住的不是一开始就设计几十个组件,而是把一个基础组件(如 Button)的完整生命周期——从开发、测试、文档、构建、发布到问题排查——彻底跑通。这个闭环打通后,后续增加组件只是模式和体量的复制,不会再遇到颠覆性的环境或工具问题。如果只是学习,用 Vite + Scoped CSS + Vitepress 这个组合默认配置就够用;如果要长期维护并给团队或社区使用,那么类型安全、自动化流程、清晰的文档和可定制的主题就必须从第一天开始规划。
- 在项目根目录安装 Vitepress: