前端组件库开发实践:从零到发布

8 阅读8分钟

为什么要搞自己的组件库?

先说说一些开发的名场面,看看你有没有中招:

  • 新开一个项目,复制粘贴上个项目的 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 干三件事

  1. 样式:引入 Reset、UnoCSS/Tailwind 等,保证打出来的 dist/style.css 一份就够。
  2. 统一导出:组件、指令、Composables(如 useFormuseMenu)全部 export,方便 使用方 按需 import。
  3. 插件形态:导出一个带 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 builddist/ 里就会出现 my-ui-kit.es.jsmy-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.tsvite-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.jsonexports 里暴露 ./style,使用方 import 'my-ui-kit/style' 即可。
  • 多语言 / 静态资源:若库内用到了 locales/zh-cn.json 等,构建时要把它们拷到 dist,否则发布后引用会 404。用 rollup-plugin-copywriteBundle 阶段拷贝:
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 都验证过了,就可以发版了:

  1. 版本号npm version patch(或改 package.json 里的 version)。
  2. 登录npm login(私有 registry 就按你们流程来)。
  3. 发布npm publish。若是 scoped 包且首次发,记得 npm publish --access public

发完,别人就能 pnpm add my-ui-kit ,然后愉快地使用啦。

总结:

把业务中积累的「components」 抽成公共包,从「复制粘贴」升级成「库模式构建 + 类型 + 规范发布 + 按需使用」,在多项目里复用同一套组件和指令就会稳很多。组件再也不是拖累,而是资源宝库!
后面还可以加上单元测试、文档站(如 VitePress)、Changelog 和语义化版本等等,一步步做成团队级的组件库产品。有坑一起踩,与掘友们共勉!