上周帮一位同事排查组件库打包问题,他拿着一个 Vue 3 组件库的构建脚本来找我,Rollup 已经配置得差不多了,但打出来的 UMD 包在接入方页面里始终报模块解析错误,后来又变成组件完全不渲染。查到最后,问题出在 external 配置和 peerDependencies 不一致上——Rollup 打包 Vue 项目时,这类坑几乎每个刚上手的人都会踩一遍。Rollup 在 Vue 项目里的定位其实很明确:它不负责开发服务器,也不负责应用级代码分割,它最擅长的是把你的组件库、工具函数库做成干净利落的 npm 包。这篇文章,我把自己从零搭 Rollup + Vue 打包流程的完整过程和踩坑记录整理出来,适合正在做组件库、或者想把项目里的公共模块抽出来单独发布的人参考,希望能帮你少走一截弯路。
1. 为什么我会在 Vue 项目里选择 Rollup 而不是 Webpack
1.1 从一次组件库打包需求说起
我们团队之前维护一套对内的 Vue 3 基础组件库,最开始是拿 Webpack 5 的 library mode 直接构建,产物是一个接近 2.4MB 的单文件 UMD 包,里面连 Vue 运行时也塞了一份。这个包在接入方的后台系统里用起来有两个明显的毛病:一是二次引入导致全局 API 被覆盖,组件的行为时好时坏;二是任何一个组件都拿到全量代码,按需加载基本只是口头上的概念。后来我重新审视构建链路,发现团队的真实需求并不是"把整个应用打包上线",而是"对外发布一个可供多个业务系统消费的组件集合"。这种场景下,Rollup 以 ESM 为核心的构建模型明显更贴合。
很多人一听到"打包"就默认用 Webpack,其实这是应用工程化的惯性思维。Vue 项目里,Webpack 的优势在于 loader 生态丰富、dev server 和 HMR 成熟、代码分割策略灵活,这些恰恰是大型应用开发时最需要的。可一旦你面对的是要发到 npm 上的库,情况就反过来了:Webpack 产物会带上一段自己的运行时引导代码,处理副作用的策略也偏保守,tree-shaking 常常达不到预期的效果。Rollup 的设计目标从诞生起就是"把 ESM 模块合并成更少的文件,顺便把没用到的导出摇掉",对这个场景来说恰好是专业对口。
1.2 Rollup 与 Webpack、Vite 的分工边界
很多新手会混淆这几种工具的分工,我习惯用一个表格把它们的职责讲清楚,这样选型时一眼就能看出边界在哪里。
| 工具 | 核心定位 | 最适合的场景 | Vue 项目里的典型用法 |
|---|---|---|---|
| Webpack | 面向应用的通用打包器 | 大型应用、复杂 loader 需求、精细化代码分割 | vue-cli 的底层,支撑整个应用开发与生产构建 |
| Vite | 面向应用的开发与构建工具 | 快速启动的开发服务器、现代浏览器为主的应用 | 开发时用 esbuild 提速,生产构建最终仍然调 Rollup |
| Rollup | 面向库的 ESM 打包器 | npm 包、组件库、工具库、多格式产物 | 把组件库打成 ESM/CJS/UMD 产物,保持 tree-shaking |
Vite 生产构建的底层就是 Rollup,这本身就说明了一个事实:两者不是竞争关系,而是不同层级的工具。开发体验归 Vite,库产出的精细控制归 Rollup。我自己写业务系统时毫不避讳地用 Vite,但每次要发包,还是会单独开一个 Rollup 构建脚本,因为库的交付标准和应用不同——你需要的是干净的 ESM 产物、正确的 external 声明、可选的 preserveModules 目录结构,这些在 Rollup 里都是"一等公民"。
1.3 什么时候该用 Rollup,以及什么时候别用
这里我给一个相对明确的判断标准,能帮你少纠结几天。如果你的项目满足下面任意一条,Rollup 大概率是比 Webpack 更合适的选择:要发到 npm 的组件库或函数库,需要同时输出 ESM、CJS、UMD 多种格式;需要保留模块结构,让使用方可以按目录去 import 单个组件;对产物体积和 tree-shaking 效果有硬性要求。反过来,如果你只是在做一个用完即走的业务系统,需要按路由拆包、有复杂的图片和字体处理管线、团队依赖 Webpack 的生态插件——那就继续用 Webpack 或 Vite,别为了"潮流"而强行迁移。
还有一条容易踩的线:Rollup 也能跑 dev server 类插件,但体验远不如 Vite。我见过有人想在 Rollup 里复刻完整的 HMR 开发流程,最后配置越来越诡异,纯粹是自己给自己找麻烦。我的习惯是把开发链路留在 Vite 或 Webpack 上,把发布链路交给 Rollup,两条线互不干扰,各自做擅长的事。
2. 搭建最小可用的 Rollup + Vue 工程:依赖、配置与第一次产物
2.1 依赖清单与版本匹配
先说依赖,这是第一个容易乱的地方。Vue 3 项目用 Rollup 打包,核心依赖不是一大堆,而是下面四件套:
- rollup 本体,建议直接用当前最新的稳定主版本,别和老插件版本混搭;
- @rollup/plugin-node-resolve,用来解析 import 语句里的 node_modules 包名,没有它你连
import { ref } from 'vue'都解析不了; - @rollup/plugin-commonjs,负责把 CommonJS 模块转换成 ESM,node_modules 里大量包还是 CJS 写的;
- @rollup/plugin-vue,这是处理 .vue 单文件组件的关键插件,它内部依赖 Vue 的 compiler-sfc 做模板编译。
安装命令大致是这样,版本号我建议直接交给 npm 按 peer 依赖自动解析,不要手工锁得太死:
npm install -D rollup @rollup/plugin-vue @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-babel @rollup/plugin-terser rollup-plugin-postcss @rollup/plugin-url @vue/compiler-sfc这里有个特别容易踩的版本坑:如果你在给 Vue 2 组件库打包,@rollup/plugin-vue 是不适用的,Vue 2 需要的是 rollup-plugin-vue 加 vue-template-compiler 这一套老组合。我刚开始迁移时没注意这个差异,装了一堆新版插件后发现 SFC 里的 template 编译老是报错,后来才意识到是版本矩阵错了。我在配置里会通过target: 'vue3'显式声明当前插件面向的 Vue 版本,避免插件做自动判断时出幺蛾子。
2.2 一个能直接抄的 rollup.config.js
我给出一个我实际用在多个组件库项目里的基础配置,核心逻辑都保留着,你可以直接拿过去改名字用:
import resolve from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; import vue from '@rollup/plugin-vue'; import babel from '@rollup/plugin-babel'; import postcss from 'rollup-plugin-postcss'; import terser from '@rollup/plugin-terser'; export default { input: 'src/index.js', external: ['vue'], plugins: [ resolve({ extensions: ['.js', '.jsx', '.vue', '.json'] }), commonjs(), vue({ target: 'vue3' }), babel({ babelHelpers: 'bundled', exclude: 'node_modules/**', extensions: ['.js', '.jsx', '.vue'] }), postcss({ extract: 'dist/style.css' }), terser() ], output: [ { file: 'dist/my-lib.esm.js', format: 'es', sourcemap: true }, { file: 'dist/my-lib.cjs.js', format: 'cjs', sourcemap: true }, { file: 'dist/my-lib.umd.js', format: 'umd', name: 'MyLib', globals: { vue: 'Vue' }, sourcemap: true } ] };配置里每一项都不是摆设。input指向组件库的唯一入口,通常是一个负责导出所有组件的 index.js;external声明 vue 是外部依赖,理由我后面单独讲;plugins 的顺序基本决定了整个编译链路:先让 node-resolve 把 bare import 解析成具体文件路径,再让 commonjs 处理历史包袱里的 CJS 模块,接着 vue 插件把 .vue 文件拆成可编译的 JS 和 CSS,babel 负责把新语法转译到目标浏览器能接受的级别,postcss 处理样式,最后 terser 收尾压缩。这个顺序我踩过几次坑,比如把 vue 插件放在 resolve 前面,某些情况下会导致 .vue 文件的路径解析绕不过去,报出一些看起来莫名其妙的模块找不到错误。
2.3 单文件组件的编译原理简聊
有人问我为什么有了 @rollup/plugin-vue 还要单独装 @vue/compiler-sfc,这得从 .vue 文件的结构说起。一个单文件组件其实是三个语言块的组合:template 里的 HTML 模板、script 里的逻辑、style 里的样式。Rollup 本身只认识 JS 模块,所以 @rollup/plugin-vue 的核心工作是把这个混合文件拆开:template 部分交给 compiler-sfc 编译成 render 函数,script 部分提取成常规 JS 模块,style 部分则被抽出来流转给下游的 CSS 插件。拆开后的三段逻辑各自独立,这也解释了为什么你会看到 postcss 这类的插件需要出现在 plugin-vue 后面——vue 插件本质上只是"拆迁队",真正的样式整理要靠后头的 CSS 处理器。
理解了这一点,你就不会再问"为什么我加了 plugin-vue 样式却没有任何输出"这种问题了。很可能是 plugin-vue 已经把样式抽出来了,但后续没有接 postcss 去接收它,或者 postcss 的 extract 路径没配对。检查这类问题,我一般会先看插件执行顺序,再去看产物里是否有独立的 CSS 文件,两步基本能定位九成的情况。
2.4 第一次打包必踩的典型报错
我整理了几个第一次跑 Rollup + Vue 时几乎必然遇到的报错,以及对应的处理方式,建议收藏备用。
| 报错信息 | 原因 | 解决方案 |
|---|---|---|
Cannot find module '@vue/compiler-sfc' | 只装了 @rollup/plugin-vue,没装编译依赖 | 把 @vue/compiler-sfc 装进 devDependencies |
Unexpected token 'export' | 某个 JS 文件里的 ESM 语法没有被解析 | 检查 node-resolve 的 extensions 配置,以及文件路径是否被 exclude 误伤 |
Unresolved dependencies警告 | import 的包既没有被插件解析,也没有在 external 里声明 | 明确这个包是外部依赖还是需要被 node-resolve 从源码解析 |
| UMD 包运行时报找不到 Vue | output.globals 没把vue映射到全局变量Vue | 在 globals 里加上{ vue: 'Vue' } |
我印象最深的是最后一条。有次我把 UMD 产物发给了接入方,对方直接用<script>标签引入,结果控制台报错说找不到渲渲染所需的全局能力,折腾半天才发现 globals 映射缺失。UMD 格式要求外部依赖必须知道在浏览器环境里对应哪个全局变量,你漏了这一步,运行时自然就"断粮"了。
3. external 与多格式产物:组件库交付里的几个关键决策
3.1 external:为什么 Vue 绝对不能被打进你的库
这一节是全篇最值得划线的地方。很多人第一次构建组件库,看到产物里把 Vue 一起打包进去,还觉得"这下使用者不用自己装了,多贴心",这是大错特错。Vue 这类框架依赖必须作为 peerDependencies 存在,也就是说,你的组件库声明"我要求宿主环境必须提供一个 Vue",但绝不能自己再带一份。
原因很简单:如果组件库的包里还有一份独立的 Vue 运行时,接入方的应用里就会同时存在两个 Vue 实例。两个实例意味着两套响应式系统、两套全局 API、两套组件注册表。你库内部用 provide/inject 做的跨层级通信,跟应用里完全对不上;你在组件库里注册的全局组件,应用侧也不认。更隐蔽的是,这类问题通常不会在本地 demo 里暴露,因为本地开发时依赖解析往往正好指向同一个 Vue,一旦发布到真实环境,依赖树一变就炸。我排查过不止一次"组件为什么在线上不渲染"的问题,根源都是这种重复 Vue。
正确的做法是在 Rollup 配置里显式 external,并且在 package.json 里同步声明 peerDependencies:
external: (id) => { const externals = ['vue', 'vue-router', /^@vue\/.*/]; return externals.some((item) => typeof item === 'string' ? id === item : item.test(id) ); }{ "peerDependencies": { "vue": "^3.2.0" } }用函数形式写 external 的好处是可以用正则把一类依赖一次性排除掉,避免漏网之鱼。很多人只写external: ['vue'],结果@vue/runtime-core之类的子包还是被打进去了,一样会造成重复,用正则匹配^@vue/.*能把这个口子彻底堵上。
3.2 ESM、CJS、UMD:三种产物分别给谁用
组件库发布时常见的问题是"到底要出几种格式",我的答案是至少三种,分别对应三条完全不同的消费链路。
| 产物格式 | 典型文件 | 面向的使用者 | 特点 |
|---|---|---|---|
| ES Module | index.esm.js | 使用 Vite/Webpack/Rollup 的现代工程 | 保留 ESM 语义,tree-shaking 效果好 |
| CommonJS | index.cjs.js | Node 环境、老构建链、直接 require 的场景 | 兼容性最好,但无法静态分析 |
| UMD | index.umd.js | 浏览器<script>直接引入 | 需要 globals 映射,适合快速接入演示 |
在 Rollup 配置里,这三种格式不需要写三份插件链,只需要在 output 数组里并列声明即可。唯一需要额外操心的还是 UMD,因为它的产物要暴露一个全局变量给浏览器,所以必须有name字段(我例子里的 MyLib)以及globals映射。这里有一个工程上的小建议:不要手动维护三个 output 对象,可以用循环生成,减少以后加格式时漏改的风险。
const formats = [ { file: 'dist/my-lib.esm.js', format: 'es' }, { file: 'dist/my-lib.cjs.js', format: 'cjs' }, { file: 'dist/my-lib.umd.js', format: 'umd', name: 'MyLib' } ]; const configs = formats.map((item) => ({ input: 'src/index.js', external: ['vue'], plugins: [...], output: { ...item, globals: { vue: 'Vue' }, sourcemap: true } }));3.3 preserveModules:要 tree-shaking 就得付出一点代价
组件库和普通函数库还有一个本质区别:组件天然带着模板和样式,如果全部打进一个单文件产物,使用方的打包器虽然可以通过 ESM 的 tree-shaking 摇掉未引用的 JS 逻辑,但样式文件往往很难跟着被切干净。为了给组件库提供真正的按需能力,Rollup 提供了preserveModules: true选项,它的作用是保留源码里的模块结构,把每个组件打成独立的 chunk 文件,而不是摊平成一个 bundle。
开启 preserveModules 之后,输出配置不能再使用file,必须改用dir和preserveModules:
output: { dir: 'dist/es', format: 'es', preserveModules: true, preserveModulesRoot: 'src', sourcemap: true }preserveModulesRoot的作用是让产物目录结构与源码目录对齐,否则打包器会给所有模块套上冗长的相对路径,发布出来的包路径会很难看。代价也很明显:文件数量会从几个变成几十上百个,npm 包里的小文件变多,安装时的 IO 消耗会上升,部分老工具对深层目录的解析也可能出现兼容问题。所以我的建议是组件库主产物用单文件版本,给大多数使用方提供开箱即用的体验;同时再用 preserveModules 出一套 ES 目录产物,给有按需引入诉求、并且打包链路比较现代的团队使用。两者并存,各取所需。
4. 样式、字体和按需加载:打包过程中最容易被忽略的一环
4.1 抽出独立 CSS 而不是让样式留在 JS 里
样式处理是 Rollup + Vue 项目里最容易被低估的环节,我第一次配置时也犯过懒,直接把样式留在 JS 里靠运行时注入。在纯浏览器单页应用里,这种方案看着一切正常,但换成服务端渲染场景就出问题了:样式注入发生在 JS 执行阶段,服务端返回的 HTML 可能已经渲染完成,会出现首屏无样式或者样式闪烁的现象。组件库这类产物要面对各种宿主环境,不能赌对方一定是个普通 SPA。
所以我在配置里几乎总会让 postcss 插件把样式抽成独立文件:
postcss({ extract: 'dist/style.css', minimize: false, sourceMap: true })extract的值既有文件路径,也承担着"是否抽取"的开关作用。设置成true或者具体路径后,所有组件的样式会合并到这个独立 CSS 里,使用方只需要在入口处引入一次,或通过库的 style 字段让打包工具自动处理。要注意的是,minimize建议显式设置,如果你后面接了 terser 做 JS 压缩,CSS 的压缩最好交给专门的工具去做,不要在 postcss 里开 minimize 又开一份别的压缩器,容易双重处理出问题。
4.2 字体和图片资源的路径问题
组件库里的静态资源比应用项目更敏感,因为使用方不会按你的项目目录结构来组织文件。Font 文件和图标图片一旦路径写死,接进来就是一片 404。我在处理这类资源时用 @rollup/plugin-url,它负责把小文件转成 base64 内联,把大文件按规则拷贝到产物目录并重写引用路径:
url({ include: ['**/*.png', '**/*.jpg', '**/*.svg', '**/*.woff', '**/*.woff2'], limit: 4096, fileName: 'assets/[name][hash][extname]' })limit是内联阈值,4KB 以下的小图标建议直接 base64 内联,减少 HTTP 请求数;大文件则走文件拷贝路线。这里有个实际经验:组件库里的字体文件特别容易踩坑,比如图标字体动辄几十 KB,如果超过 limit 被拷贝出去,一定要检查使用方打包器能否正确处理相对路径。很多情况下,我会额外让 node-resolve 插件配置 alias,把源码里的@/assets这类别名解析到真实路径,避免资源 import 在构建时迷失方向。
4.3 按需引入时样式怎么跟着组件走
这是组件库开发的进阶问题,也是最能体现 Rollup 配置功力的地方。开启 preserveModules 之后,JS 模块按目录拆开了,但样式文件也变成独立的 chunk,如果使用方只 import 单个组件的 JS 而没有引入对应 CSS,组件渲染出来就是裸的 HTML,样式全丢。针对这个问题,业界常见有三种处理思路,我按实际效果推荐组合使用。
第一种是在 package.json 里声明全局的样式入口,比如"style": "dist/style.css",让大多数打包工具在解析包时自动带上全量样式。这种方案简单粗暴,但牺牲了按需加载的精细度。第二种是在 sideEffects 字段里把 CSS 显式标记为有副作用的模块:
{ "sideEffects": ["**/*.css"] }这个字段很重要,很多人在发布组件库时漏了它。如果不声明,现代打包器在做 tree-shaking 时可能因为"这个 CSS 模块看起来没有导出任何 JS 值"而把它当纯副作用模块丢掉。声明之后,只要使用方 import 了组件模块,与之关联的样式副作用就会被保留。第三种做法是每个组件目录里自带一份样式文件,并在组件 JS 里显式import './style.css',要求使用方按目录引入时连同样式一起打包。三种思路没有绝对优劣,我目前是"全量 CSS + sideEffects"双保险,既保证开箱即用,又不至于把样式摇没。
5. 调试、验证与发布:把产物从"能跑"变成"能发布"
5.1 用 sourcemap 和 demo 工程做本地闭环验证
很多组件库的发布事故,问题并不在打包本身,而在于发布者只验证了"打包没报错",没有验证"接进去的产物真的能用"。我的经验是把验证拆成两层。第一层是 Rollup 构建时打开 sourcemap,确保问题的排查不至于在产物里寸步难行。第二层是建一个最小的 demo 工程,用 Vite 搭一个十几KB的空白应用,通过 npm 的file:协议直接指向本地组件库目录,模拟真实接入场景。
这个 demo 工程里至少要验证三种场景:在 SPA 里全局注册组件并渲染、单独 import 某个组件并按需引入、在 SSR 或至少 Node 端执行模式下确认样式和 JS 不报错。我见过很多配置在本地构建一切正常,换到接入方环境就崩的例子,原因无外乎环境依赖差异或格式选择错误。本地 demo 做不了百分之百的兜底,但能把八成低级问题拦在发布之前。
5.2 发布前必须检查的五个细节
发布组件库的检查清单,我每次都会过一遍,这里分享给你,照着做可以避开大量看起来很低级但破坏力很大的坑。
第一,package.json 的入口字段要对得上。main指向 CJS 产物,module指向 ESM 产物,如果用了 exports 字段,还要注意里面的条件导出顺序,import条件必须在require之前。第二,peerDependencies 必须和 external 保持同步,库依赖 Vue 就声明 Vue,库用了 vue-router 的功能也要一并声明。第三,files 白名单要精简,别把 demo、测试用例、源文件全部塞进 npm 包,我只发布 dist、package.json、README 这几类必要内容。第四,sideEffects 字段按第 4 章讲的思路配置好,否则按需引入时样式会被摇掉。第五,发布前用npm pack生成 tarball,然后在 demo 工程里安装这个 tarball,模拟真实消费路径,这一步能发现大量"本地依赖解析过于理想"的问题。
5.3 高频报错速查表
我把这几年处理过的 Rollup + Vue 打包报错整理成一个速查表,按出现频率排序,留着当排查手册用。
| 报错现象 | 根因方向 | 排查要点 |
|---|---|---|
| 构建期找不到编译模块 | @vue/compiler-sfc 缺失 | 先装依赖,再看插件版本和 Vue 主版本是否匹配 |
| 大量 Unresolved dependencies 警告 | bare import 未被解析或未被 external | 检查 node-resolve 配置,以及 external 是否漏写了包名 |
| UMD 产物接入即报错 | globals 映射缺失 | 确认 output.globals 里把 vue 映射成了 Vue |
| 样式文件在产物里消失 | 插件顺序或 postcss 没接到样式流 | 检查 plugin-vue 到 postcss 的链路是否完整 |
| 使用方打包时 tree-shaking 把样式摇掉 | sideEffects 配置不对 | 在 package.json 里声明"sideEffects": ["**/*.css"] |
| 源码里能跑,发出去的包不能跑 | 验证不充分 | 用 npm pack 后安装 tarball 的方式做冒烟测试 |
这张表不是让你逐条死记,而是想说明一个规律:Rollup + Vue 的报错绝大多数都能归结到依赖解析、模块格式、样式处理、发布验证这四个维度,排查时按这个框架走,比乱试配置高效得多。
最后再分享一个小技巧。我现在的组件库构建流程会故意跑两遍:一遍出单文件产物用于发布,一遍用 preserveModules 出按需目录供内部团队使用。在 CI 脚本里,我会在构建完成后自动执行一次最小 Vite demo 的构建,把它当成冒烟测试,只要 demo 构建失败,发布流程就自动中断。这套机制帮我挡住了不少"我以为能跑"的坑,也让 Rollup 这套打包体系在团队里稳定服役到了现在。如果你打算正式引入 Rollup 来发 Vue 组件库,建议也把验证环节放进行动清单,而不是等到接入方报警再去排查。