一套组件库能否长期演进,取决于源码边界、发布边界和运行时边界是否一致。本文给出一套基于 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
| 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
| 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
| 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
| 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
| 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,违背“单个全量文件”的预期;强行内联又可能让每个组件重复公共代码。因此建议两次构建:
- 全量 ESM 构建:单入口,生成自包含
dist/full/index.js。
- 按需 ESM 构建:多入口,生成
dist/es/*/index.js 与共享 chunks。
两次构建应共享插件、alias、external 等基础配置,但拥有独立的 input 和输出策略。
十一、ESM/npm 与 CDN/IIFE 拆分配置
先提取共享配置:
1 2 3 4 5 6 7
| 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
| 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
| 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
| 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
| @use './base.scss'; @use '../components/button/style.scss'; @use '../components/dialog/style.scss';
|
组件入口直接导入自己的 SCSS,按需构建才能抽取对应 CSS。为避免全量入口与组件入口同时导入造成重复,实际工程可让全量入口仅导入 styles/index.scss,而两次构建分别选择全量和按需入口;Sass 变量、mixin 不产生 CSS,可安全复用。公共 reset 只进入全量主题,不应被每个按需组件重复注入。若希望用户自行覆盖变量,推荐输出 CSS 自定义属性;发布原始 SCSS 时还要将源码加入 files 和 exports。
十四、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 包应让 main、module 与根导出的 import、default 都指向全量 ESM 产物,避免 Node 或旧工具误把 IIFE 当作 CommonJS。IIFE 仅用于 CDN 直接加载,这里通过可选的 ./iife 子路径暴露;若不希望 npm 消费者引用,也可以只保留构建产物而不加入 exports。若必须支持 require,应增加真正的 CJS 构建并设置 exports.require。files 必须包含入口、共享 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 重写声明中的 @/。应检查 rootDir 与 exports.types 的目录层级一致;若声明实际落在 dist/types/src,应调整 rootDir,而不是用错误 exports 掩盖问题。
十六、Changesets 发布链路
初始化后,每个功能或修复提交一份变更集:
文件描述哪些包按 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": [] }
|
十七、常见坑与决策建议
- 重复 Vue:Vue 未 external 或被放入普通 dependencies,可能导致注入上下文、响应式身份异常。库侧使用 peerDependencies,并在所有构建中 external。
- 把内部源码 external:会留下无法解析的相对路径。只有明确由消费者提供的依赖才 external。
- 多入口等于按需的误解:总入口仍静态导出所有组件时,全量导入仍会纳入完整依赖图;具名导入只能依赖消费者继续 Tree Shaking,稳定按需应使用组件子路径入口。
- 以为未用组件的图片一定会消失:组件库预构建时已经复制或内联的静态资源,不一定随消费方 JS Tree Shaking 删除;大资源应归属独立组件入口,并分别检查发布包、应用产物和运行时请求。
- 语言包显式 entry 后仍被引用:entry 只增加输出,不删除根入口的 import;需要从根入口移除导出。
- 滥用 preserveModules:会暴露内部目录、生成大量文件并放大 exports 维护成本。优先显式公共入口。
- 期待一次构建兼顾所有目标:全量自包含、组件共享 chunk、IIFE 单文件目标冲突,应拆为两次或三次构建。
- 后一次构建清空 dist:多个配置写同一根目录时设置
emptyOutDir: false,只在最开始清理一次。
- CSS 被摇掉或重复:声明
sideEffects;基础样式只在全量主题引入;组件入口只带自身样式。
- exports 与真实文件不一致:新增组件时同步入口、installer、类型路径和 exports,最好由同一份组件清单生成。
- 把 IIFE 当 CJS:IIFE 服务全局变量,不等于 Node 的
require 格式;需要 CommonJS 时单独输出 CJS。
- CDN 遗漏 globals:external 的 Vue 在 IIFE 配置中必须映射为
Vue,并保证加载顺序。
- 只发布入口、不发布 chunk:多入口 ESM 会引用共享 chunk,必须发布完整 dist。
- 声明含路径别名:构建后运行
tsc-alias,并确保声明的子路径可由 exports 访问。
- API 包盲目不 bundle:若目标是浏览器直接使用、隐藏内部模块或兼容旧运行时,应增加 bundle 产物,而非强迫消费者解决。
最终决策可以归纳为:先定义消费方式,再定义入口;先定义哪些依赖由宿主提供,再设置 external;先定义发布 API,再选择显式多入口或 preserveModules。npm/现代构建器优先 ESM,全量和按需分别优化;传统浏览器提供单入口 IIFE;API 包保持模块化;样式和声明作为一等发布产物。这样架构、构建配置和 package.json 才能形成闭环。