
在实际前端工程中UI组件库是提升开发效率、保证产品一致性的核心基础设施。无论是内部业务系统还是对外开源项目一个设计良好、易于维护的组件库都能显著降低团队的重复劳动。Vue.js 3 凭借其组合式 API、更好的 TypeScript 支持以及性能优化为构建现代化、类型安全的组件库提供了强大的基础。本文将围绕如何从零开始使用 Vue.js 3 开发一个可用于生产环境的 UI 组件库展开涵盖从项目初始化、组件设计与开发、打包构建、文档与演示到最终发布的全链路实践。文章面向有一定 Vue.js 基础希望深入理解组件库开发原理和工程化实践的开发者。1. 理解现代 UI 组件库的核心构成与目标在动手编码之前明确一个现代 UI 组件库的构成和目标至关重要。这不仅仅是写几个.vue文件那么简单它涉及到架构设计、工程规范、开发者体验和最终用户的使用便利性。1.1 组件库的四个核心层次一个完整的组件库通常包含以下四个层次组件层这是最核心的部分包含按钮、输入框、弹窗、表格等具体的 UI 组件。每个组件需要具备高内聚、低耦合的特性对外提供清晰的 Props、Events 和 Slots 接口。样式层负责组件的视觉呈现。现代组件库通常采用 CSS-in-JS、CSS Modules 或预处理器如 Sass/Less配合设计令牌Design Tokens来管理样式确保主题定制和样式隔离。工具层包括构建工具链Vite/Rollup、类型定义生成TypeScript、代码规范工具ESLint, Prettier、单元测试框架Vitest/Jest等保障代码质量和开发体验。生态层包含文档站点展示组件 API 和示例、Playground在线编辑和预览、国际化方案、图标库、工具函数等配套设施。1.2 Vue.js 3 带来的开发范式转变Vue.js 3 的组合式 API 彻底改变了组件的逻辑组织方式这对于组件库开发影响深远逻辑复用可以将组件的复杂逻辑如表单验证、异步数据加载抽取为可复用的组合式函数Composables使组件代码更清晰也方便用户按需使用底层逻辑。更好的 TypeScript 集成Vue 3 对 TypeScript 的支持是原生的。使用script setup lang“ts”语法糖可以轻松地为组件的 Props、Emits 提供完整的类型推断极大提升开发体验和代码健壮性。更灵活的组件设计通过defineExpose可以精确控制组件对外暴露的实例属性useSlots和useAttrs提供了更细粒度的插槽和属性处理能力。基于这些理解我们的开发目标应该是构建一个类型安全、易于使用、支持按需引入、样式可定制、拥有完整配套工具的 Vue 3 组件库。2. 项目初始化与工程化配置一个规范的工程结构是成功的第一步。我们将使用 Vite 作为构建工具因为它对 Vue 3 和 TypeScript 的支持最好且拥有极快的热更新速度。2.1 创建项目与基础结构首先使用官方脚手架创建项目。这里我们选择构建一个库Library而非应用App。# 使用 npm create 命令选择 Vue 和 TypeScript npm create vuelatest my-ui-library # 根据提示选择TypeScript, JSX, Vue Router 等按需选择对于组件库Router 和 Pinia 通常不需要。 # 进入项目目录 cd my-ui-library # 安装依赖 npm install创建完成后调整目录结构以适应库的发布模式。一个典型的组件库目录如下my-ui-library/ ├── packages/ # 核心包目录 │ ├── components/ # 所有组件源码 │ │ ├── Button/ │ │ │ ├── src/ │ │ │ │ └── Button.vue │ │ │ ├── index.ts # 组件出口文件 │ │ │ └── __tests__/ # 组件测试 │ │ ├── Input/ │ │ └── index.ts # 全量导出入口 │ ├── theme/ # 样式主题相关 │ │ ├── src/ │ │ │ ├── tokens.scss # 设计令牌颜色、间距等变量 │ │ │ └── index.scss # 全局样式入口 │ │ └── index.ts │ └── utils/ # 工具函数 ├── docs/ # 文档站点源码 ├── playground/ # 开发调试用的示例项目 ├── build/ # 构建脚本和配置 ├── dist/ # 构建输出目录 ├── package.json ├── vite.config.ts # 主构建配置 └── tsconfig.json # TypeScript 配置2.2 关键配置文件详解1. 修改package.json库的package.json与普通应用不同需要指定入口文件、发布文件以及依赖。{ name: my-ui-library, version: 0.1.0, description: A Vue 3 UI Component Library, type: module, main: ./dist/my-ui-library.umd.cjs, module: ./dist/my-ui-library.es.js, types: ./dist/index.d.ts, exports: { .: { import: ./dist/my-ui-library.es.js, require: ./dist/my-ui-library.umd.cjs, types: ./dist/index.d.ts }, ./dist/style.css: ./dist/style.css }, files: [dist], scripts: { dev: vite, build: run-p build:components build:types, build:components: vite build, build:types: vue-tsc --declaration --emitDeclarationOnly --outDir dist, preview: vite preview, test: vitest }, peerDependencies: { vue: ^3.3.0 }, devDependencies: { vitejs/plugin-vue: ^5.0.0, vue/test-utils: ^2.4.0, npm-run-all: ^4.1.5, sass: ^1.69.0, typescript: ~5.2.0, vite: ^5.0.0, vitest: ^1.0.0, vue-tsc: ^1.8.0 } }main和module分别对应 CommonJS 和 ES Module 的入口适配不同的打包环境。typesTypeScript 类型声明文件入口。exports更精细地控制导出路径方便用户按需导入样式。files发布到 npm 时包含的文件通常只包含dist目录。peerDependencies声明宿主环境必须提供的依赖如 Vue避免版本冲突。2. 配置vite.config.tsVite 配置需要从构建应用模式切换到构建库模式。import { defineConfig } from vite import vue from vitejs/plugin-vue import { resolve } from path import { libInjectCss } from vite-plugin-lib-inject-css // https://vitejs.dev/config/ export default defineConfig({ plugins: [ vue(), libInjectCss(), // 将 CSS 注入到 JS 中支持按需引入样式 ], build: { lib: { // 库的入口文件 entry: resolve(__dirname, packages/components/index.ts), name: MyUILibrary, fileName: (format) my-ui-library.${format}.js }, rollupOptions: { // 确保外部化处理那些你不想打包进库的依赖 external: [vue], output: { // 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量 globals: { vue: Vue } } }, // 构建后清空输出目录 emptyOutDir: true, } })3. 配置tsconfig.json确保 TypeScript 能正确识别 Vue 文件和生成声明文件。{ extends: vue/tsconfig/tsconfig.dom.json, include: [env.d.ts, packages/**/*, packages/**/*.vue], exclude: [dist, node_modules], compilerOptions: { composite: true, baseUrl: ., paths: { /*: [./packages/*] // 配置别名方便引用 }, outDir: ./dist, // 类型声明输出目录 declaration: true, // 生成 .d.ts 文件 declarationMap: true // 生成 .d.ts.map 文件 } }3. 开发第一个组件按钮 (Button)我们从最基础的 Button 组件开始实践组件设计、样式处理、类型定义和单元测试的全流程。3.1 组件设计与实现在packages/components/Button/src/Button.vue中创建组件。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 } ] :disableddisabled || loading :autofocusautofocus :typenativeType clickhandleClick !-- 加载状态 -- span v-ifloading classmy-button__loading svg classcircular viewBox25 25 50 50 circle classpath cx50 cy50 r20 fillnone/ /svg /span !-- 图标 -- i v-ificon !loading :classicon/i !-- 默认插槽 -- span v-if$slots.default classmy-button__text slot / /span /button /template script setup langts import { withDefaults } from vue // 定义 Props 类型 interface ButtonProps { type?: primary | success | warning | danger | info | text size?: large | default | small icon?: string nativeType?: button | submit | reset loading?: boolean disabled?: boolean plain?: boolean round?: boolean circle?: boolean autofocus?: boolean } // 定义 Emits 类型 interface ButtonEmits { (e: click, val: MouseEvent): void } // 使用 withDefaults 提供默认值 const props withDefaults(definePropsButtonProps(), { type: default, size: default, nativeType: button, loading: false, disabled: false, plain: false, round: false, circle: false, autofocus: false, }) const emit defineEmitsButtonEmits() const handleClick (evt: MouseEvent) { if (props.loading || props.disabled) return emit(click, evt) } /script style langscss scoped // 引入设计令牌 import ../../theme/src/tokens.scss; .my-button { display: inline-flex; align-items: center; justify-content: center; line-height: 1; height: 32px; padding: 8px 15px; white-space: nowrap; cursor: pointer; color: $text-color-primary; background-color: $bg-color; border: 1px solid $border-color; border-radius: $border-radius-base; outline: none; font-size: $font-size-base; transition: all .3s cubic-bezier(.645, .045, .355, 1); user-select: none; --primary { color: $color-white; background-color: $color-primary; border-color: $color-primary; } // ... 其他 type 样式 --large { height: 40px; padding: 10px 19px; font-size: $font-size-large; } --small { height: 24px; padding: 5px 11px; font-size: $font-size-small; } .is-plain { /* ... */ } .is-round { border-radius: 20px; } .is-circle { width: 32px; height: 32px; padding: 8px; border-radius: 50%; } .is-disabled { cursor: not-allowed; opacity: 0.6; } .is-loading { pointer-events: none; } __loading { display: inline-flex; margin-right: 6px; .circular { /* 旋转动画样式 */ } } __text { margin: 0 4px; } :hover, :focus { opacity: 0.8; } :active { opacity: 1; } } /style3.2 组件导出与样式管理创建packages/components/Button/index.ts作为组件的出口。import Button from ./src/Button.vue import { withInstall } from ../../utils/with-install // 通过 withInstall 方法给 Button 添加 install 方法 const MyButton withInstall(Button) export default MyButton // 导出类型 export * from ./src/Button.vuewith-install是一个工具函数让组件支持app.use(MyButton)的全局注册方式。// packages/utils/with-install.ts import type { App, Plugin } from vue export const withInstall T(component: T) { const comp component as any comp.install (app: App) { app.component(comp.name || comp.displayName, component) } return component as T Plugin }创建packages/theme/src/tokens.scss来管理设计变量。// 颜色 $color-primary: #409eff; $color-success: #67c23a; $color-warning: #e6a23c; $color-danger: #f56c6c; $color-info: #909399; $color-white: #ffffff; $color-black: #000000; $text-color-primary: #303133; $text-color-regular: #606266; $border-color: #dcdfe6; $bg-color: #ffffff; // 尺寸 $border-radius-base: 4px; $font-size-base: 14px; $font-size-large: 16px; $font-size-small: 12px;创建全量导出入口packages/components/index.ts。export { default as MyButton } from ./Button // 未来导出其他组件 // export { default as MyInput } from ./Input import * as components from ./components import type { App } from vue // 全量注册的 install 方法 const install (app: App): void { Object.values(components).forEach(component { if (component.install) { app.use(component) } }) } export default { install, ...components }3.3 编写单元测试使用 Vitest 和 Vue Test Utils 为 Button 组件编写测试。在packages/components/Button/__tests__/Button.spec.ts中import { describe, it, expect } from vitest import { mount } from vue/test-utils import MyButton from ../src/Button.vue describe(MyButton.vue, () { it(renders default button, () { const wrapper mount(MyButton, { slots: { default: Click me } }) expect(wrapper.text()).toBe(Click me) expect(wrapper.classes()).toContain(my-button) expect(wrapper.classes()).toContain(my-button--default) }) it(emits click event when clicked, async () { const wrapper mount(MyButton) await wrapper.trigger(click) expect(wrapper.emitted()).toHaveProperty(click) }) it(does not emit click when disabled, async () { const wrapper mount(MyButton, { props: { disabled: true } }) await wrapper.trigger(click) expect(wrapper.emitted(click)).toBeUndefined() }) it(applies primary type class, () { const wrapper mount(MyButton, { props: { type: primary } }) expect(wrapper.classes()).toContain(my-button--primary) }) })在package.json中添加测试脚本并运行npm run test来验证。4. 构建、打包与按需引入组件开发完成后需要将其打包成适合分发的格式。4.1 构建与类型生成运行我们之前配置的构建命令npm run build这个命令会并行执行build:components和build:types。build:components使用 Vite 将源代码打包成dist目录下的es(ES Module)、umd(Universal Module Definition) 等格式的文件。build:types使用vue-tsc基于源码生成对应的 TypeScript 声明文件 (*.d.ts)这对于 TypeScript 用户至关重要。构建完成后dist目录应包含dist/ ├── my-ui-library.es.js # ES Module 格式 ├── my-ui-library.umd.cjs # UMD 格式 (CommonJS) ├── index.d.ts # 类型声明入口 ├── components/ # 各组件类型声明 └── style.css # 全量样式文件4.2 实现按需引入全量引入会增加最终打包体积。按需引入是组件库的必备特性。主流方案是配合 unplugin-vue-components 这类自动导入插件或提供 ES Module 的树摇Tree-shaking支持。我们的构建配置 (lib模式) 已经生成了 ES Module 格式的文件这天然支持支持 Tree-shaking。用户可以通过以下方式按需引入// 在用户项目中 import { MyButton } from my-ui-library import my-ui-library/dist/style.css // 手动引入样式 // 或者如果配置了自动导入插件可以完全省略 import 语句为了让样式也能按需加载我们需要为每个组件单独生成 CSS 文件。可以修改 Vite 配置或使用额外的构建插件如vite-plugin-libcss来实现。一种更常见的做法是在组件入口 (index.ts) 中导入其自身的样式并依靠构建工具如 Vite 的libInjectCss将样式注入到 JS 中或单独提取。4.3 创建 Playground 进行本地调试在根目录下创建playground目录初始化一个简单的 Vue 应用来实时测试组件。# 在 playground 目录内 npm create vuelatest . # 选择最简配置然后在playground/vite.config.ts中将组件库作为本地依赖引入import { defineConfig } from vite import vue from vitejs/plugin-vue import path from path export default defineConfig({ plugins: [vue()], resolve: { alias: { my-ui-library: path.resolve(__dirname, ../packages/components/index.ts) } } })在playground/src/App.vue中使用组件template div MyButton typeprimary clickhandleClickPrimary Button/MyButton MyButton :loadingtrueLoading/MyButton MyButton typesuccess roundRound Success/MyButton /div /template script setup langts import { MyButton } from my-ui-library // 注意如果样式未自动注入可能需要手动引入 // import my-ui-library/dist/style.css const handleClick (evt: MouseEvent) { console.log(Button clicked, evt) } /script在根目录的package.json中添加脚本方便同时启动库的构建监听和 Playground。{ scripts: { dev: run-p dev:components dev:playground, dev:components: vite build --watch --mode development, dev:playground: vite --config playground/vite.config.ts playground } }5. 文档、发布与持续集成5.1 使用 Vitepress 构建文档站点文档是组件库的门面。Vitepress 基于 Vite 和 Vue 3非常适合为 Vue 组件库构建文档。# 在项目根目录 npm add -D vitepress # 初始化 npx vitepress init docs在docs/.vitepress/config.ts中配置主题和导航。在docs/index.md或组件专属的.md文件中你可以直接展示 Vue 组件这需要配置 Vitepress 的 Vue 插件。// docs/.vitepress/config.ts import { defineConfig } from vitepress import { demoBlockPlugin } from vitepress-theme-demoblock export default defineConfig({ title: My UI Library, themeConfig: { nav: [{ text: 指南, link: /guide/ }], sidebar: [ { text: 组件, items: [ { text: Button 按钮, link: /components/button/ } ] } ] }, markdown: { config(md) { // 使用 demoblock 插件在 markdown 中展示代码示例和预览 md.use(demoBlockPlugin) } } })在docs/components/button/index.md中# Button 按钮 常用的操作按钮。 ## 基础用法 基础的按钮用法。 :::demo 使用 type、size、disabled 等属性来定义按钮。 vue template MyButton typeprimary主要按钮/MyButton MyButton typesuccess成功按钮/MyButton MyButton typewarning警告按钮/MyButton /template ::: ## API ### Props | 属性名 | 说明 | 类型 | 可选值 | 默认值 | |--------|------|------|--------|--------| | type | 类型 | string | primary / success / warning / danger / info / text | default | | size | 尺寸 | string | large / default / small | default | | disabled | 是否禁用 | boolean | — | false | | loading | 是否加载中 | boolean | — | false |5.2 发布到 npm首先确保package.json中的name是唯一的或在你的 npm 组织下。登录 npm 并发布# 登录 npm login # 构建最新版本 npm run build # 发布如果是首次发布使用 npm publish --access public npm publish发布后其他开发者可以通过npm install my-ui-library来使用。5.3 配置持续集成 (CI)使用 GitHub Actions 可以自动化测试、构建和发布流程。在.github/workflows/ci.yml中name: CI on: push: branches: [ main ] pull_request: branches: [ main ] jobs: test-and-build: runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 - uses: actions/setup-nodev3 with: node-version: 18 cache: npm - run: npm ci - run: npm run test - run: npm run build publish: needs: test-and-build if: github.event_name push github.ref refs/heads/main runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 - uses: actions/setup-nodev3 with: node-version: 18 registry-url: https://registry.npmjs.org/ - run: npm ci - run: npm run build - run: npm publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}需要在 GitHub 仓库的 Secrets 中配置NPM_TOKEN。6. 常见问题与排查路径在开发和使用组件库的过程中会遇到一些典型问题。6.1 样式丢失或混乱问题现象可能原因检查方式处理建议组件没有样式1. 未引入全局样式文件。2. 按需引入时样式未自动导入。3. 构建时 CSS 提取失败。1. 检查main.js或入口文件是否import xxx/dist/style.css。2. 检查vite.config.ts中libInjectCss插件是否配置。3. 查看dist目录下是否有.css文件。1. 全量引入时手动引入 CSS。2. 配置unplugin-vue-components等插件实现样式自动导入。3. 检查 Vite 构建配置确保 CSS 处理正确。样式被用户项目覆盖用户项目 CSS 优先级更高。在浏览器开发者工具中检查组件样式查看哪些规则被覆盖。1. 提高组件库样式权重如使用scoped或 CSS Modules。2. 使用 BEM 等命名规范减少冲突。3. 在文档中说明样式重置建议。字体图标不显示图标字体文件路径错误或未加载。检查网络请求看图标字体文件是否 404。1. 将图标字体打包进组件库或提供 CDN 链接。2. 使用 SVG 图标代替字体图标。6.2 TypeScript 类型错误问题现象可能原因检查方式处理建议Cannot find module ‘my-ui-library’1. 未安装依赖。2.tsconfig.json路径别名未配置。3. 类型声明文件未生成。1. 检查node_modules。2. 检查tsconfig.json的paths和types。3. 检查dist目录下是否有.d.ts文件。1. 运行npm install。2. 在tsconfig.json中配置types: [my-ui-library/dist]。3. 确保vue-tsc成功执行并生成声明文件。组件 Props 无类型提示组件未正确导出类型。在组件源码中检查是否使用definePropsType()并导出类型。在组件出口文件 (index.ts) 中使用export * from ./src/Button.vue导出所有类型。6.3 构建与发布问题问题现象可能原因检查方式处理建议npm run build失败提示 Vue 相关错误1. Vue 未外部化被打包进库中。2. 存在语法错误。1. 检查vite.config.ts的rollupOptions.external是否包含vue。2. 查看具体错误信息。1. 确保external配置正确。2. 修复源码错误。发布到 npm 后安装的包缺少文件package.json中的files字段配置错误。检查发布的 tarball 内容npm pack --dry-run。确保files字段包含所有需要发布的目录如[dist]。Playground 中热更新不生效文件监听或别名配置问题。检查 Playground 的 Vite 配置中别名路径是否正确指向源码。确保别名路径 (path.resolve) 指向的是组件的源码目录而不是构建后的dist目录。7. 最佳实践与扩展方向7.1 组件设计最佳实践单一职责一个组件只做一件事。如果组件变得复杂考虑将其拆分为多个子组件或使用组合式函数抽取逻辑。清晰的接口通过defineProps、defineEmits和defineSlots提供完整且文档化的 API。使用 TypeScript 接口定义复杂的 Prop 类型。受控与非受控对于表单类组件考虑同时支持受控模式通过v-model和非受控模式内部状态。可访问性 (A11y)为交互式组件添加适当的 ARIA 属性如aria-label,aria-disabled确保键盘导航Tab, Enter, Space。国际化 (i18n)将文本内容通过 Prop 或 Slot 暴露避免硬编码为支持多语言留出空间。样式作用域使用scopedCSS 或 CSS Modules 避免样式污染。对于需要覆盖的样式提供 CSS 变量Custom Properties或设计令牌。7.2 工程化与维护建议版本管理遵循语义化版本 (SemVer)。破坏性更新升主版本号新增功能升次版本号问题修复修订号。变更日志 (CHANGELOG)使用conventional-changelog工具自动生成变更日志清晰记录每个版本的改动。代码质量集成 ESLint、Prettier、Stylelint 并统一配置。在 CI 流程中强制执行代码检查和测试。自动化文档考虑使用vue-docgen-api等工具从组件源码的 JSDoc 注释中自动生成 API 表格减少文档维护成本。多包管理 (Monorepo)当组件库规模扩大包含工具包、主题包、图标库等多个独立包时考虑使用 pnpm Workspaces、Lerna 或 Nx 来管理 Monorepo。7.3 扩展方向主题系统开发一套完整的设计令牌系统并支持运行时动态切换主题如暗黑模式。图标组件封装一个 SVG 图标组件支持通过名称或 URL 使用图标并允许自定义颜色和大小。工具函数库将与 UI 强相关的工具函数如日期格式化、深度合并、防抖节流抽离成独立的工具包。VSCode 插件开发代码片段插件提升团队内组件使用效率。适配更多框架使用 Web Components 标准重构核心组件使其能在 React、Solid 等非 Vue 生态中使用。开发一个成熟的 UI 组件库是一个持续迭代的过程关键在于建立清晰的架构、严格的工程规范和以开发者体验为中心的设计思路。从最小的 Button 组件开始逐步完善基础设施和周边生态最终能构建出支撑大型前端项目的高质量组件体系。