Vue3组件库的架构设计与打包实践

一套组件库能否长期演进,取决于源码边界、发布边界和运行时边界是否一致。本文给出一套基于 pnpm、Vue 3、Vite、Rollup、TypeScript 和 Changesets 的可落地方案,同时解释全量包、按需包、API 包、语言包、ESM CDN 与传统 script CDN 为什么不能简单共用一次构建。

一、pnpm monorepo 的分包边界

建议至少拆成三个 workspace:组件包负责 Vue 组件、样式和安装器;API 包负责与框架无关的请求类型和客户端;文档站负责示例、调试与说明,不参与 npm 发布。

1
2
3
4
5
6
7
8
repo/
├── packages/
│ ├── components/
│ └── api/
├── docs/
├── pnpm-workspace.yaml
├── package.json
└── .changeset/
1
2
3
packages:
- packages/*
- docs

根目录只编排任务:

1
2
3
4
5
6
7
8
9
10
11
12
{
"private": true,
"scripts": {
"build": "pnpm -r --filter ./packages/** build",
"changeset": "changeset",
"version": "changeset version",
"release": "pnpm build && changeset publish"
},
"devDependencies": {
"@changesets/cli": "^2.29.0"
}
}

组件包依赖 API 包时使用 workspace:^,发布时 pnpm 会改写为真实版本范围:

1
2
3
4
5
{
"dependencies": {
"@acme/api": "workspace:^"
}
}

二、推荐源码目录与职责

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
packages/components/
├── src/
│ ├── components/
│ │ ├── button/
│ │ │ ├── Button.vue
│ │ │ ├── index.ts
│ │ │ └── style.scss
│ │ └── dialog/
│ ├── locale/
│ │ ├── en-US.ts
│ │ └── zh-CN.ts
│ ├── styles/
│ │ ├── base.scss
│ │ └── index.scss
│ ├── installer.ts
│ └── index.ts
├── scripts/entries.ts
├── vite.shared.ts
├── vite.full.config.ts
├── vite.es.config.ts
├── vite.iife.config.ts
├── tsconfig.build.json
└── package.json

components 放独立公共组件,目录入口定义按需发布边界;locale 放可单独加载的字典;styles 放变量、reset 与全量主题;installer.ts 只负责批量注册;根 index.ts 是全量 ESM 入口。业务请求、DTO、错误模型进入 API 包,不应反向依赖 Vue 组件。

三、全量入口、组件入口与 installer

组件入口既导出组件,也提供 Vue 插件安装能力:

1
2
3
4
5
6
7
8
// src/components/button/index.ts
import type { App, Plugin } from 'vue'
import Button from './Button.vue'
import './style.scss'

export const AcButton = Button as typeof Button & Plugin
AcButton.install = (app: App) => app.component('AcButton', AcButton)
export default AcButton
1
2
3
4
5
6
7
8
9
10
11
12
// src/installer.ts
import type { App, Plugin } from 'vue'
import { AcButton } from './components/button'
import { AcDialog } from './components/dialog'

const components = [AcButton, AcDialog] as const

export const installer: Plugin = {
install(app: App) {
components.forEach((component) => app.use(component))
},
}
1
2
3
4
5
6
7
// src/index.ts
import './styles/index.scss'
export * from './components/button'
export * from './components/dialog'
export * from './locale/zh-CN'
export * from './locale/en-US'
export { installer as default } from './installer'

全量使用 app.use(ComponentLibrary);按需使用 import { AcButton } from "@acme/components/button"。两者必须引用同一份组件实现,避免行为漂移。

四、Vite/Rollup 库模式与 external

ESM 构建不应打入 Vue,否则宿主可能出现两份运行时。external 表示保留导入给消费方解析,不等于这个依赖可以从 package.json 中消失。Vue 应同时声明为 peerDependencies

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'node:path'
import { componentEntries } from './scripts/entries'

export default defineConfig({
plugins: [vue()],
build: {
outDir: 'dist/es',
emptyOutDir: false,
cssCodeSplit: true,
lib: { entry: resolve(__dirname, 'src/index.ts'), formats: ['es'] },
rollupOptions: {
external: ['vue'],
input: { index: resolve(__dirname, 'src/index.ts'), ...componentEntries },
output: {
format: 'es',
entryFileNames: '[name]/index.js',
chunkFileNames: 'chunks/[name]-[hash].js',
assetFileNames: 'assets/[name][extname]',
},
},
},
})

lib.entry 描述库入口,存在 rollupOptions.input 时实际由后者决定多入口。不要 external 掉库自身组件,否则全量入口会留下消费者无法解析的内部源码路径。

五、API 包只用 tsc、不 bundle

API 包若主要提供 TypeScript 客户端、DTO 和纯函数,可让 tsc 一对一编译模块:

1
2
3
4
5
6
{
"scripts": {
"build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json"
},
"devDependencies": { "tsc-alias": "^1.8.16", "typescript": "^5.9.0" }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*.ts"]
}

“不 bundle”意味着保留模块边界和第三方 import,有利于 tree-shaking、调试和子路径导出,适合 Node/现代构建器消费的纯 TS 库。限制是文件更多、请求数不适合直接浏览器裸加载;依赖必须由消费者解析;TS 路径别名和扩展名必须在产物中有效;若要兼容 CommonJS、旧浏览器或单文件 CDN,仍需额外构建。tsc-alias 用于把声明和 JS 中的路径别名改成可发布的相对路径。

六、为什么组件内部依赖默认合并进总入口

全量 index.js 的语义是开箱即用。组件内部工具、hooks 和其他组件属于实现细节,默认应由 Rollup 纳入模块图;若把它们 external,消费者不仅要安装并解析内部包,还会暴露版本耦合。第三方依赖是否 external 应按契约决定:Vue 这类宿主单例必须 external;体积小且纯内部的依赖可合并;需共享版本或体积很大的依赖应作为 peer/dependency 并 external。

七、语言包、多 entry、external 与 preserveModules

将语言包加入显式 entry,只是让 Rollup 额外产出稳定子路径,并不会阻止全量入口中的静态导入。只要 src/index.ts 导出了语言包,它仍会保留相应 import 或共享 chunk。要让全量包不含语言包,应删除全量入口导出,改由消费者从子路径导入。

external 把某模块排除在构建图外,产物保留对外部模块的 import;preserveModules 则仍编译构建图内模块,只保持接近源码的文件边界。前者改变依赖归属,后者改变输出形态。显式多 entry 通常比全局 preserveModules 更适合公共 API:发布面明确,内部目录不会被意外暴露。

八、自动扫描组件入口

配置文件加载时可同步扫描 components/*/index.ts。固定排序保证产物稳定,并对目录名生成公共子路径:

1
2
3
4
5
6
7
8
9
10
11
12
// scripts/entries.ts
import { readdirSync, statSync } from 'node:fs'
import { resolve } from 'node:path'

const componentsDir = resolve(__dirname, '../src/components')

export const componentEntries = Object.fromEntries(
readdirSync(componentsDir)
.filter((name) => statSync(resolve(componentsDir, name)).isDirectory())
.sort()
.map((name) => [name, resolve(componentsDir, name, 'index.ts')]),
)

若仓库允许没有入口文件的辅助目录,可用 existsSync 过滤;若约定每个目录都必须发布,直接失败更能及时暴露结构错误。

九、新增组件入口后的总入口与共享 chunk

新增 components/select/index.ts 后,扫描器会生成 select/index.js;但总入口不会自动导出它,仍需在 src/index.ts 添加 export * from "./components/select",并在 installer.ts 注册。多入口之间复用的 hooks、Vue SFC helper 或工具可能被 Rollup 抽成 chunks/*.js,这是正常去重。不能只上传某个入口文件而遗漏共享 chunk;npm 的 files 和 CDN 发布必须覆盖整个 dist

单文件 ESM 的 Tree Shaking 边界

组件全部打进一个 ESM 文件后,消费者仍可使用具名导入:

1
import { AcButton } from '@acme/components'

消费项目再次经过 Vite、Rollup 或 Webpack 构建时,未使用的导出理论上仍可被 Tree Shaking。单文件并不等于完全无法摇树,但效果受以下条件限制:

  • 必须由消费方再次打包;浏览器或 CDN 直接加载该文件时仍会下载全部内容。
  • 顶层副作用、全量注册器、动态访问以及难以静态分析的依赖链可能阻止代码移除。
  • 根入口若静态导入 installer,而 installer 又维护完整组件数组,即使业务只具名导入一个组件,也可能保留更多组件实现。
  • sideEffects 应只保护 CSS、SCSS 等确有副作用的文件,避免把整个包标记为有副作用。

静态资源还需要单独考虑。例如某组件静态导入一张图片:

1
2
3
4
5
6
7
<script setup lang="ts">
import backgroundUrl from './images/background.png'
</script>

<template>
<img :src="backgroundUrl" alt="" />
</template>

组件库预构建时,Vite 可能已经将图片复制到 dist/assets,或按资源内联规则转成 data URL。消费方 Tree Shaking 的主要对象是 JavaScript 模块代码,它不会回头删除 npm 包中已经发布的文件;若资产插件无条件复制资源,图片也可能继续进入消费项目产物。因此,“业务没有使用该组件”并不必然意味着图片会从发布包或最终产物中消失。

更可靠的做法是将组件作为独立 ESM 入口,让图片只出现在该组件的依赖图中:

1
import { AcButton } from '@acme/components/button'

这样未导入带图片的组件时,消费构建不会遍历其入口及静态资源。若只是希望延迟图片请求,可在组件真正展示时动态加载,但动态加载主要改变加载时机,并不一定减少 npm 发布包体积。对于图片、字体、WASM 等大资源,应把“是否进入发布包”“是否进入应用产物”“是否在运行时发起网络请求”作为三个不同问题分别验证。

十、全量自包含与按需组件包建议两次构建

全量包希望除 Vue 外尽可能自包含、入口少;按需包希望每个组件稳定可寻址、公共代码可共享。一次多入口构建会让全量入口引用共享 chunk,违背“单个全量文件”的预期;强行内联又可能让每个组件重复公共代码。因此建议两次构建:

  1. 全量 ESM 构建:单入口,生成自包含 dist/full/index.js
  2. 按需 ESM 构建:多入口,生成 dist/es/*/index.js 与共享 chunks。

两次构建应共享插件、alias、external 等基础配置,但拥有独立的 input 和输出策略。

十一、ESM/npm 与 CDN/IIFE 拆分配置

先提取共享配置:

1
2
3
4
5
6
7
// vite.shared.ts
import vue from '@vitejs/plugin-vue'

export const shared = {
plugins: [vue()],
resolve: { alias: { '@': new URL('./src', import.meta.url).pathname } },
}

全量与按需 ESM 分别使用可直接执行的 Vite 配置,避免将配置数组传给 defineConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// vite.full.config.ts
import { defineConfig } from 'vite'
import { resolve } from 'node:path'
import { shared } from './vite.shared'

export default defineConfig({
...shared,
build: {
outDir: 'dist/full',
emptyOutDir: false,
cssCodeSplit: false,
lib: { entry: resolve(__dirname, 'src/index.ts'), formats: ['es'] },
rollupOptions: {
external: ['vue'],
output: {
format: 'es',
entryFileNames: 'index.js',
assetFileNames: 'style[extname]',
inlineDynamicImports: true,
},
},
},
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// vite.es.config.ts
import { defineConfig } from 'vite'
import { shared } from './vite.shared'
import { componentEntries } from './scripts/entries'

export default defineConfig({
...shared,
build: {
outDir: 'dist/es',
emptyOutDir: false,
cssCodeSplit: true,
rollupOptions: {
external: ['vue'],
input: componentEntries,
output: {
format: 'es',
entryFileNames: '[name]/index.js',
chunkFileNames: 'chunks/[name]-[hash].js',
assetFileNames: 'assets/[name][extname]',
},
},
},
})

IIFE 单独配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// vite.iife.config.ts
import { defineConfig } from 'vite'
import { resolve } from 'node:path'
import { shared } from './vite.shared'

export default defineConfig({
...shared,
build: {
outDir: 'dist/iife',
emptyOutDir: false,
cssCodeSplit: false,
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'AcmeComponents',
formats: ['iife'],
fileName: () => 'index.iife.js',
},
rollupOptions: {
external: ['vue'],
output: { globals: { vue: 'Vue' }, assetFileNames: 'style[extname]' },
},
},
})

output.globals 把 external 的模块名映射到页面全局变量;遗漏后 IIFE 无法找到 Vue。emptyOutDir: false 防止后一次构建清空前一次产物,但发布前应由脚本显式清理一次目录。可用 rimraf 跨平台清理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"scripts": {
"clean": "rimraf dist",
"build:types": "vue-tsc -p tsconfig.build.json --declaration --emitDeclarationOnly && tsc-alias -p tsconfig.build.json",
"build:esm": "vite build --config vite.full.config.ts && vite build --config vite.es.config.ts",
"build:iife": "vite build --config vite.iife.config.ts",
"build": "pnpm clean && pnpm build:types && pnpm build:esm && pnpm build:iife"
},
"devDependencies": {
"rimraf": "^6.0.0",
"tsc-alias": "^1.8.16",
"vue-tsc": "^3.0.0"
}
}

IIFE 适合单入口与传统 script CDN,不支持多入口代码拆分。按需加载应使用 ESM/npm 构建,不要试图把多个组件入口塞进 IIFE。

十二、ESM CDN import map 与传统 script CDN

支持 ESM 的 CDN 可通过 import map 映射裸模块名,浏览器直接加载 ESM:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script type="importmap">
{
"imports": {
"vue": "https://cdn.jsdelivr.net/npm/vue@3.5.0/dist/vue.esm-browser.prod.js",
"@acme/components": "https://cdn.jsdelivr.net/npm/@acme/components@1.0.0/dist/full/index.js"
}
}
</script>
<script type="module">
import { createApp } from 'vue'
import Components from '@acme/components'
import 'https://cdn.jsdelivr.net/npm/@acme/components@1.0.0/dist/full/style.css'
createApp({ template: '<ac-button>确定</ac-button>' })
.use(Components)
.mount('#app')
</script>

传统 script 模式要求 Vue 先提供全局变量,再加载 IIFE:

1
2
3
4
5
6
7
8
9
10
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@acme/components@1.0.0/dist/iife/style.css"
/>
<div id="app"><ac-button>确定</ac-button></div>
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.0/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@acme/components@1.0.0/dist/iife/index.iife.js"></script>
<script>
Vue.createApp({}).use(AcmeComponents.default).mount('#app')
</script>

十三、CSS/SCSS:全量主题与按需样式

src/styles/index.scss 汇总基础样式和所有组件样式,供全量入口生成 style.css

1
2
3
4
// src/styles/index.scss
@use './base.scss';
@use '../components/button/style.scss';
@use '../components/dialog/style.scss';

组件入口直接导入自己的 SCSS,按需构建才能抽取对应 CSS。为避免全量入口与组件入口同时导入造成重复,实际工程可让全量入口仅导入 styles/index.scss,而两次构建分别选择全量和按需入口;Sass 变量、mixin 不产生 CSS,可安全复用。公共 reset 只进入全量主题,不应被每个按需组件重复注入。若希望用户自行覆盖变量,推荐输出 CSS 自定义属性;发布原始 SCSS 时还要将源码加入 filesexports

十四、package.json 与产物严格对齐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
"name": "@acme/components",
"version": "1.0.0",
"type": "module",
"main": "./dist/full/index.js",
"module": "./dist/full/index.js",
"types": "./dist/types/index.d.ts",
"files": ["dist", "src/**/*.scss"],
"sideEffects": ["**/*.css", "**/*.scss"],
"peerDependencies": { "vue": "^3.5.0" },
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/full/index.js",
"default": "./dist/full/index.js"
},
"./button": {
"types": "./dist/types/components/button/index.d.ts",
"import": "./dist/es/button/index.js"
},
"./dialog": {
"types": "./dist/types/components/dialog/index.d.ts",
"import": "./dist/es/dialog/index.js"
},
"./style.css": "./dist/full/style.css",
"./iife": "./dist/iife/index.iife.js",
"./package.json": "./package.json"
}
}

exports 是现代解析器的真实边界;纯 ESM 包应让 mainmodule 与根导出的 importdefault 都指向全量 ESM 产物,避免 Node 或旧工具误把 IIFE 当作 CommonJS。IIFE 仅用于 CDN 直接加载,这里通过可选的 ./iife 子路径暴露;若不希望 npm 消费者引用,也可以只保留构建产物而不加入 exports。若必须支持 require,应增加真正的 CJS 构建并设置 exports.requirefiles 必须包含入口、共享 chunk、CSS 和声明;sideEffects 必须保护样式导入,否则消费者 tree-shaking 可能删除 CSS。自动扫描新增组件后,也应同步生成或维护 exports,否则文件存在但子路径被封锁。

十五、vue-tsc 与 tsc-alias 生成声明

Vue SFC 声明应使用 vue-tsc,普通 tsc 无法完整理解 .vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist/types",
"baseUrl": ".",
"paths": { "@/*": ["src/*"] },
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

先运行 vue-tsc 产出声明,再运行 tsc-alias 重写声明中的 @/。应检查 rootDirexports.types 的目录层级一致;若声明实际落在 dist/types/src,应调整 rootDir,而不是用错误 exports 掩盖问题。

十六、Changesets 发布链路

初始化后,每个功能或修复提交一份变更集:

1
pnpm changeset

文件描述哪些包按 major、minor、patch 升级以及用户可读说明。发布链路通常为:合并变更集;CI 执行 changeset version 更新版本、依赖范围与 CHANGELOG;安装锁文件并完整构建;执行测试和发布前产物校验;最后 changeset publish。内部依赖使用 workspace 协议时,Changesets 会按配置联动版本。组件包依赖 API 包且 API 有破坏性升级时,必须确认组件包是否也需要升级和重新发布。

1
2
3
4
5
6
7
8
9
10
11
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}

十七、常见坑与决策建议

  1. 重复 Vue:Vue 未 external 或被放入普通 dependencies,可能导致注入上下文、响应式身份异常。库侧使用 peerDependencies,并在所有构建中 external。
  2. 把内部源码 external:会留下无法解析的相对路径。只有明确由消费者提供的依赖才 external。
  3. 多入口等于按需的误解:总入口仍静态导出所有组件时,全量导入仍会纳入完整依赖图;具名导入只能依赖消费者继续 Tree Shaking,稳定按需应使用组件子路径入口。
  4. 以为未用组件的图片一定会消失:组件库预构建时已经复制或内联的静态资源,不一定随消费方 JS Tree Shaking 删除;大资源应归属独立组件入口,并分别检查发布包、应用产物和运行时请求。
  5. 语言包显式 entry 后仍被引用:entry 只增加输出,不删除根入口的 import;需要从根入口移除导出。
  6. 滥用 preserveModules:会暴露内部目录、生成大量文件并放大 exports 维护成本。优先显式公共入口。
  7. 期待一次构建兼顾所有目标:全量自包含、组件共享 chunk、IIFE 单文件目标冲突,应拆为两次或三次构建。
  8. 后一次构建清空 dist:多个配置写同一根目录时设置 emptyOutDir: false,只在最开始清理一次。
  9. CSS 被摇掉或重复:声明 sideEffects;基础样式只在全量主题引入;组件入口只带自身样式。
  10. exports 与真实文件不一致:新增组件时同步入口、installer、类型路径和 exports,最好由同一份组件清单生成。
  11. 把 IIFE 当 CJS:IIFE 服务全局变量,不等于 Node 的 require 格式;需要 CommonJS 时单独输出 CJS。
  12. CDN 遗漏 globals:external 的 Vue 在 IIFE 配置中必须映射为 Vue,并保证加载顺序。
  13. 只发布入口、不发布 chunk:多入口 ESM 会引用共享 chunk,必须发布完整 dist。
  14. 声明含路径别名:构建后运行 tsc-alias,并确保声明的子路径可由 exports 访问。
  15. API 包盲目不 bundle:若目标是浏览器直接使用、隐藏内部模块或兼容旧运行时,应增加 bundle 产物,而非强迫消费者解决。

最终决策可以归纳为:先定义消费方式,再定义入口;先定义哪些依赖由宿主提供,再设置 external;先定义发布 API,再选择显式多入口或 preserveModules。npm/现代构建器优先 ESM,全量和按需分别优化;传统浏览器提供单入口 IIFE;API 包保持模块化;样式和声明作为一等发布产物。这样架构、构建配置和 package.json 才能形成闭环。