为什么要搞自己的组件库?
先说说一些开发的名场面,看看你有没有中招:
- 新开一个项目,复制粘贴上个项目的
components文件夹,改改名字接着用; - 三个项目里同一套「表格 + 表单」各写各的,改一处要改三处;
- 用现成 UI 库吧,按需加载配到怀疑人生,不用吧,自己从零写又太慢;
- 产品说「这里要跟 XX 项目长得一样」,你发现那边早改版了,根本对不齐。
把共用的表格、表单、图表、布局、指令、工具函数收拢成一个库,统一维护、按需引用,多项目复用同一套体验和逻辑——这就是自研或二次封装组件库要解决的事。
一、先搭骨架:工程长什么样?
组件库不是 SPA,而是一个「被别的项目 import 的包」。举个实际项目为例:
my-ui-kit/
├── src/
│ ├── main.ts # 库的唯一起点:样式、组件、指令、Composables 都从这里出去
│ ├── components/ # 按业务或功能分子目录Form、Table、Menu…
│ ├── directives/ # 自定义指令:复制、防抖、长按、拖拽等
│ ├── modules/ # 公共模块,比如 i18n、主题
│ ├── utils/ # 纯工具函数
│ └── vite-plugin.ts # 可选:给使用方用的「按需解析」插件
├── playground/ # 本地调试用的示例项目,改完库立刻能看效果
├── dist/ # 构建产物,不提交,发布时只发这个
├── locales/ # 若有多语言,可随库一起发布
├── vite.config.ts
├── package.json
└── tsconfig.json
入口文件 main.ts 干三件事:
- 样式:引入 Reset、UnoCSS/Tailwind 等,保证打出来的
dist/style.css一份就够。 - 统一导出:组件、指令、Composables(如
useForm、useMenu)全部export,方便 使用方 按需 import。 - 插件形态:导出一个带
install的对象,使用方可以app.use(MyUiKit)一把梭全局注册。
入口示例:
// 样式最先
import '@unocss/reset/tailwind-compat.css'
import 'virtual:uno.css'
// 公共模块:i18n、指令安装函数等
import * as I18n from './modules/i18n'
export { I18n as I18nModule }
import { setupDirectives } from './directives'
export { setupDirectives }
// 组件:表格、表单、图表、菜单等
import Table from './components/Table/Table.vue'
import Form from './components/Form/Form.vue'
// ... 其余组件
// 指令
import XxxDirectiveCopy from './directives/modules/copy'
import XxxDirectiveDebounce from './directives/modules/debounce'
// ...
// 全局注册插件
export const globalPlugin = {
install(app) {
app.component('XxxTable', Table)
app.component('XxxForm', Form)
// ...
}
}
// 具名导出:按需引用 + 友好 tree-shaking
export default globalPlugin
export { Table as XxxTable, Form as XxxForm, useForm, useMenu /* ... */ }
export { XxxDirectiveCopy, XxxDirectiveDebounce, /* ... */ }
这样使用方既可以「全量 + 全局注册」,也可以「只 import 用到的组件和 hooks」。
二、配 Vite 库模式:打出「可被 import 的包」
骨架有了,下一步是让 Vite 把项目打成库,而不是打成一个能跑的网页。
2.1 基础 lib 配置
在 vite.config.ts 里加上 build.lib:
import { resolve } from 'path'
import pkg from './package.json'
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/main.ts'),
name: pkg.name, // UMD 时挂到 window 上的名字
formats: ['es', 'umd'],
fileName: (format) => `${pkg.name}.${format}.js`,
},
},
})
- es:给 Vite/Webpack/Rollup 用,支持 tree-shaking;
- umd:给 script 标签或老环境兜底。
一执行 vite build,dist/ 里就会出现 my-ui-kit.es.js、my-ui-kit.umd.js,别的项目就能 import 了。
2.2 依赖别打包进来:external 是亲兄弟
组件库会用到 Vue、Element Plus、ECharts、vue-router 等——这些必须由使用方项目提供,不能打进你的 bundle,否则会出现「两份 Vue」「包体积爆炸」等问题。
在 rollupOptions 里把它们 external 掉,并告诉 UMD:「这些模块对应的是哪个全局变量」:
rollupOptions: {
external: [
'vue',
'element-plus',
'vue-router',
'echarts',
'vue-echarts',
'@vueuse/core',
'vue-i18n',
// 若库里会 import 自己的子路径(如 locales),也要写进来,避免被打进 bundle
'my-ui-kit/locales/zh-cn.json',
'my-ui-kit/locales/en.json',
],
output: {
globals: {
vue: 'Vue',
'element-plus': 'ElementPlus',
'vue-router': 'VueRouter',
echarts: 'echarts',
'vue-echarts': 'VueEcharts',
'vue-i18n': 'VueI18n',
},
exports: 'named', // 具名导出,方便 tree-shaking
},
}
这样打出来的库又小又干净,运行时和业务项目共用同一套依赖,快去试试吧!
三、配 package.json:告诉 npm「入口在哪、发布什么」
3.1 主入口与类型
{
"name": "my-ui-kit",
"version": "1.0.0",
"private": false,
"main": "./dist/my-ui-kit.es.js",
"module": "./dist/my-ui-kit.es.js",
"types": "./dist/index.d.ts",
"type": "module"
}
- main / module:分别给 require 和 import 用(若只发 ESM,可都指到 es 产物)。
- types:TS 声明入口。
3.2 使用 exports 一把梭
用 exports 可以精细控制「主入口、样式、多语言、Vite 插件」等子路径:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/my-ui-kit.es.js",
"require": "./dist/my-ui-kit.umd.js"
},
"./style": "./dist/style.css",
"./dist/style.css": "./dist/style.css",
"./locales/*": "./dist/locales/*",
"./vite": {
"types": "./dist/vite-plugin.d.ts",
"import": "./dist/vite-plugin.js",
"require": "./dist/vite-plugin.cjs"
}
}
}
这样使用方可以:
import X from 'my-ui-kit'→ 主包;import 'my-ui-kit/style'→ 样式;import zh from 'my-ui-kit/locales/zh-cn.json'→ 多语言;import { XxxComponentsResolver } from 'my-ui-kit/vite'→ 按需解析插件(若你提供了)。
3.3 只发 dist,依赖交给使用方
{
"files": ["dist"],
"peerDependencies": {
"vue": "^3.3.11",
"element-plus": "^2.4.4"
}
}
- files:只把
dist发上去,源码、playground、测试都不发。 - peerDependencies:声明「我依赖这些,但请您自己装嘞」,避免重复安装、版本打架等问题。
四、TypeScript 类型:让用你库的人也有提示
源码是 .ts / .vue,构建出来是 .js,使用方在 TS 项目里要类型提示和类型检查,就得有一份 .d.ts。vite-plugin-dts 会在 build 时根据源码自动生成声明文件:
import dts from 'vite-plugin-dts'
export default defineConfig({
plugins: [
dts({
rollupTypes: true, // 把零散的 .d.ts 滚成少量文件,发布更清爽
}),
],
build: { /* ... */ },
})
构建完 dist/ 里会有 index.d.ts 等,使用方装你的包就能自动获得类型补全。
五、样式与静态资源:别漏了 CSS 和 locales
- 样式:在
main.ts最上面引入 UnoCSS/Reset 等,构建后会生成dist/style.css。在package.json的exports里暴露./style,使用方import 'my-ui-kit/style'即可。 - 多语言 / 静态资源:若库内用到了
locales/zh-cn.json等,构建时要把它们拷到dist,否则发布后引用会 404。用 rollup-plugin-copy 在writeBundle阶段拷贝:
import copy from 'rollup-plugin-copy'
plugins: [
copy({
targets: [{ src: './locales', dest: 'dist/' }],
hook: 'writeBundle',
}),
]
记得在 exports 里加上 "./locales/*": "./dist/locales/*",使用方才能正确引用。
六、构建脚本与体积分析
构建和包信息都齐了,接下来把日常用的脚本配好,顺带加个体积分析,避免悄悄打进不该打的东西。
- 先类型检查再构建:避免带着类型错误发布。例如
"build": "run-p type-check \"build-only\" --",build-only里只跑vite build。 - 体积分析:可以用 rollup-plugin-visualizer,构建时生成依赖占比图:
import { visualizer } from 'rollup-plugin-visualizer'
const isAnalysis = process.env.ANALYSIS === 'true'
plugins: [
visualizer({ open: isAnalysis }),
]
脚本里加一条:"analysis": "cross-env ANALYSIS=true npm run build-only",需要时跑一下,可以心里有数。
七、本地联调:不发布也能在业务项目里用
在真正发 npm 之前,先用 link 在业务项目里跑一跑,确保装包、引用、样式、类型都没问题。
在组件库目录:
pnpm build
pnpm link --global
在业务项目目录:
pnpm link --global my-ui-kit
之后业务项目里的 import ... from 'my-ui-kit' 会直接指向你本地的 dist。改完库再执行一次 pnpm build,刷新页面就能看到效果——再也不用手动拷 dist 了,但是吧,这个地方还是会有缓存问题,没辙。
八、按需引入:既省心又省体积
全量 app.use(MyUiKit) 会把所有组件都打进 bundle;更推荐按需引用,让打包器帮你 tree-shake 掉没用的。
8.1 使用方自己按需 import
import { XxxTable, XxxForm, useForm } from 'my-ui-kit'
import 'my-ui-kit/style'
只要库是 ES Module + 具名导出,未用到的组件会被自然摇掉。
8.2 自动按需:unplugin-vue-components + 自定义 Resolver
如果希望使用方不用手写 import,直接在模板里写 <XxxTable /> 就自动从库里拉对应组件,可以给 unplugin-vue-components 提供一个自定义 Resolver。做法是:在库里导出一份「组件名 → 从哪个包、用什么名字引入」的规则。
例如在库的 src/vite-plugin.ts 里(包名、前缀已泛化):
import { ComponentResolver } from 'unplugin-vue-components/types'
// 可选:Composables 自动从库里引入,避免使用方到处写 import
export const XxxAutoImports = {
'my-ui-kit': ['useForm', 'useMenu', 'useDrag']
}
export const XxxComponentsResolver = [
{
type: 'component',
resolve: (componentName) => {
if (componentName.startsWith('Xxx'))
return { name: componentName, from: 'my-ui-kit' }
},
},
{
type: 'directive',
resolve: (name) => {
const map = {
Copy: { importName: 'XxxDirectiveCopy' },
Debounce: { importName: 'XxxDirectiveDebounce' },
Draggable: { importName: 'XxxDirectiveDraggable' },
WaterMarker: { importName: 'XxxDirectiveWaterMarker' },
// ...
}
const item = map[name]
if (!item) return
return { name: item.importName, from: 'my-ui-kit' }
},
},
]
使用方在 vite.config.ts 里:
import Components from 'unplugin-vue-components/vite'
import { XxxComponentsResolver } from 'my-ui-kit/vite'
export default defineConfig({
plugins: [
Components({
resolvers: [XxxComponentsResolver],
}),
],
})
这样模板里用到的 Xxx* 组件和指令都会自动按需从 my-ui-kit 引入。注意:样式通常还是整份引入一次 my-ui-kit/style 即可。
九、发布到 npm
类型、构建、package.json、本地 link 都验证过了,就可以发版了:
- 版本号:
npm version patch(或改package.json里的version)。 - 登录:
npm login(私有 registry 就按你们流程来)。 - 发布:
npm publish。若是 scoped 包且首次发,记得npm publish --access public。
发完,别人就能 pnpm add my-ui-kit ,然后愉快地使用啦。
总结:
把业务中积累的「components」 抽成公共包,从「复制粘贴」升级成「库模式构建 + 类型 + 规范发布 + 按需使用」,在多项目里复用同一套组件和指令就会稳很多。组件再也不是拖累,而是资源宝库!
后面还可以加上单元测试、文档站(如 VitePress)、Changelog 和语义化版本等等,一步步做成团队级的组件库产品。有坑一起踩,与掘友们共勉!