想象你正站在老房子客厅里,面前是堆了五年的杂物:过期的杂志、落灰的哑铃、再也不会穿的牛仔裤。要搬到新家,你不可能全带走——行李箱就那么大,唯一聪明的做法是分类收纳、只带必需品、抽干空气再封箱。前端工程师发布一个 npm 库,做的正是同一件事:把散落在各个文件里的 JavaScript 模块,用 Rollup 这个“金牌搬家公司”打包成一个体积小、加载快、对使用方友好的包裹。ESM 模块规范是每个收纳箱外贴的清单;Tree-shaking 是把三年没穿的大衣留在老房子;同时输出 ESM 和 CommonJS 两种格式,好比同款商品做了罐装和袋装两种包装;按需加载是把大衣柜拆成板件随取随用;最后再配上真空压缩——一个干净利落的库就出炉了。这篇文章就沿着这条“搬家”主线,把 Rollup 打包库的核心技巧一件件拆给你看。
对 AI 从业者而言,掌握这些打包优化技巧,意味着你能交付体积更小、加载更快的 AI 前端组件与可视化库,直接提升库的下载量、社区采用率和开发者口碑。
目录
可视化演示
动态演示Rollup打包与ESM模块的核心机制,通过基础/核心/应用/扩展四种模式展示关键概念与交互流程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
Rollup打包与ESM模块:Roll Rollup核心定位与ESM优 Tree Shaking与静态 核心机制可视化 Rollup打包与ESM模块整体架构与关键组件
模块化演进:从杂物间到标准收纳
你站在老房子客厅中央,满地都是袜子、过期杂志和落灰的健身器材。十几年前的前端世界就是这间客厅——所有脚本一个接一个塞进全局作用域,谁都能碰到谁的东西,稍不留神就相互踩脚。这种“全局混沌”让代码根本没法安全地搬进新家。后来社区从 Node 借来了 CommonJS:每个文件是一间上锁的小房间,require() 是敲门取物。但它是运行时加载,搬家公司得真走进去翻,没法提前知道箱子里装着什么。
随着浏览器原生支持 ESM(ES Module),局面彻底改变:import/export 写在文件顶部,像每个收纳箱外贴的透明清单,搬家公司扫一眼就知道里面装了哪些导出,不需要打开箱子。CommonJS 的 module.exports 在运行期才确定,而 ESM 的静态声明让解析器在编译期就能构建出完整依赖图。npm 包管理器则像小区物业,负责把标准化的收纳箱分发到每个项目。从“全屋敞门”到“上锁房间”再到“门口挂清单”,模块化的每一步都在降低搬运的不可预知性。
清单贴好,搬家就赢在了起跑线。有了可静态分析的 ESM,接下来只需选对搬家公司——它对打包结果的影响超乎你的想象。
- 单个模块体积 V_i第 i 个模块自身的代码量。模块就像收纳箱,体积就是它的份量。
- 求和符号 Σ把从第 1 个到第 n 个模块的体积逐一加总。
- 总搬运量 V_totaln 个模块的总代码体积,也就是打包动作发生前的原始总量。
Rollup优势:为什么库打包要选它
你找到了两家搬家公司。Webpack 是“全能搬家公司”,什么都能搬——图片、CSS、字体、脚本一锅端,非常适合复杂的网页应用。但搬家后打开箱子,你会发现每个箱子里都塞了一本“通用搬运手册”(运行时 runtime)。如果你只搬几个书箱(一个 npm 库),这本手册就显得格外笨重。Rollup 是“精品搬家公司”,专精 JS 库打包,它基于 ESM 静态依赖图一次成型,输出就是干净的模块串联:所见即所得,没有多余胶水代码。
库的消费者(webpack、Vite、Node)各自都有完整的打包能力,库本身不需要再携带运行时引导代码。这正是 Rollup 在库场景胜出的核心:Webpack 为应用而生,擅长处理各种资源与复杂分包;Rollup 为库而生,产物整洁、无 runtime、天然支持 tree-shaking。两者不是替代关系,而是“库用 Rollup、应用用 Webpack/Vite”的黄金分工。由于 Rollup 处理的资源类型少、依赖图静态明确,构建速度通常也更快。
选对了承运商,行李已经在安心上路。但真正让箱子肉眼可见变轻的,是接下来的决定:哪些东西根本不搬。
- V_rollupRollup 打包后的产物体积,通常更小更干净。
- V_webpackWebpack 打包后的产物体积,包含额外的运行时胶水。
- 比值 R两个体积之比。R 越小于 1,说明 Rollup 的“精简优势”越大。
Tree-shaking:只带需要的行李
装车时,妻子从柜子里翻出一件三年没穿的大衣和一台一次没用的榨汁机。打包代码也一样——一个库往往导出很多函数,但使用者真正触达的只是其中一部分,剩下的就是死代码(dead code)。Tree-shaking(摇树)这个词源于打包器:像摇苹果树,熟透的果子(被 import 的代码)掉进筐,枯枝烂叶(没被引用的导出)留在树上。Rollup 为什么能摇树?因为 ESM 的 import/export 是静态的,依赖图在编译期就是一张透明的搬运清单。
从技术机制看,Rollup 从入口模块出发,做一次图遍历(本文 Python 演示就是一个极简模拟),标记所有可达代码,不可达的导出整个不输出。精准度可以到符号级:同一个模块里用了 formatURL,没被引用的 randomText 也能被单独摇掉。但有个关键前提——模块不能有副作用(side effect):比如模块一加载就给全局对象挂属性、立即执行 console.log。打包器出于安全会保守地保留这类模块。解决办法是在 package.json 里声明 "sideEffects": false 表示全库无副作用,或给纯函数调用加上 /*#__PURE__*/ 注释,告诉打包器“这箱东西纯得很,没人用就放心扔”。
杂物留在老房子,箱子肉眼可见地轻了。接下来是包装问题:同样的货,怎么让不同习惯的“使用方”都方便打开?
- 可达模块集合 U从入口通过
import能抵达的所有模块,如同搬家公司实际会碰到的箱子。 - 模块体积 V_m集合 U 中第 m 个模块的代码量。
- 有效体积 V_used对 U 中所有模块体积求和——不可达的死代码已经不在求和范围内。
双格式输出:一样的货,两种包装
同一种酱料,超市会做罐装和袋装:现代厨房开罐即用,露营族撕袋即食。ESM 和 CommonJS 就是 JS 世界的两种包装规格。ESM 是浏览器和现代构建工具的原生格式,支持静态分析和 tree-shaking;CJS 是 Node 老项目的主场,历史上大量 npm 包基于它。你的库最好同时提供两份,让不同模块系统的使用方各取所需。Rollup 用 output 数组一次产出两份文件:dist/index.esm.js 和 dist/index.cjs.js,配置 format 分别为 'esm' 和 'cjs'。
但产出两份文件只是第一步,真正的关键在 package.json 的 exports 字段——货架上的价签。它精确控制“谁在什么条件下看到哪个文件”:当使用方用 import 语法(或现代构建工具)引入时,指向 ESM 文件;当使用方用 require() 引入时,指向 CJS 文件。这里要特别警惕“双包危险模式”(dual package hazard):如果两份产物各自持有状态,且某些场景下使用方同时加载了两份,就会出现“同一个库两个实例”的诡异 bug——好比同一款酱料罐装和袋装各倒半包,味道就变了。
同样的馅料,两种面皮都包得妥帖。解决了包装兼容,下一步是让大包裹能“拆着搬”——这就是按需加载。
import 走 ESM 产物,require 走 CJS 产物,各回各家。- 输入条件 f使用方所用的模块系统类型,只有
import和require两种取值。 - ESM 分支当 f 为
import时,映射到dist/index.esm.js。 - CJS 分支当 f 为
require时,映射到dist/index.cjs.js。
按需加载:拆成小件随取随用
搬家车一次装不下整柜家具,于是把大衣柜拆成板件,进了新家想用哪块再拼哪块。库的场景完全一样:使用方往往只用到你库的一小部分功能,如果所有代码都塞进一个文件,首屏就得下载全部体积。动态 import() 语法让“用到时再加载”成为可能:使用方调用某个子功能时,才去拉取对应的 chunk。Rollup 识别到动态 import() 后会自动做代码分割(code splitting),把原本的一个大包拆成多个 chunk 文件。
技术上有几个关键点:第一,output 配置里可以控制 chunk 的命名与分包策略,让产物文件名可预测、可缓存;第二,分包粒度需要拿捏——拆得太碎,几十个 2KB 的小 chunk 反而增加请求开销;第三,库的按需加载常见做法是入口只暴露主功能,低频子功能通过动态 import 渐进加载,并在文档里引导使用方配合现代构建工具的 tree-shaking 做按需引用。最终目标是让使用方的首屏只加载“当前需要的板件”,而不是整箱家具。
大件拆成小件,新家随取随用。最后一步,是给每个小件抽掉空气、压到最实。
- 实际加载集合 C使用方在当前页面真正拉取的 chunk 集合,对应“拆开后实际用到的板件”。
- 单个 chunk 体积 S_c第 c 个 chunk 的文件大小。
- 带宽 B网络传输速度,分母固定时分子越小,加载越快。
- 总加载时间 T_load所有被加载 chunk 的传输时间之和,C 越小这个值越小。
体积优化:真空压缩轻装上阵
收纳师拿出真空压缩袋,把羽绒服压成饼干大小。代码也有“抽空气”的手段——压缩与混淆(minify):去掉空格和注释、把长变量名缩成单字母。压缩通常是打包产物的最后一道工序,能把体积再砍掉一半以上。但对 npm 库来说,“谁压缩”很有讲究:库代码通常交给使用方的构建工具统一压缩,库作者只需保证源码整洁、便于 tree-shaking。如果你把库预先压成“真空饼干”,使用方的工具反而无法再次摇树,sourcemap 也会错位,出了问题排错极难。
另一种更重要的瘦身是外置依赖(external):把 react、lodash 等大件声明为“不打包”。这些依赖应该在使用方项目里统一安装和提供,否则会出现两份 React 实例、体积膨胀、版本冲突一堆问题。具体做法是在 package.json 里用 peerDependencies 声明“本库需要 react 作为同伴依赖”,而不是塞进 dependencies。库体积的真正度量标准是 gzip 体积——网络传输实际计费的“快递重量”,业界常以“gzip 后多少 KB”作为库是否体面的指标。
抽掉空气的行李,扛起来一身轻。所有技巧都备齐了,最后把它们固化成一张可以反复使用的派工单吧。
- 有效体积 V_used经过 Tree-shaking 后还留在产物里的代码量。
- 压缩系数 cminify 压缩后体积与原体积之比,通常小于 1。
- gzip 压缩率 r网络传输层 gzip 能再压掉的比例。
- 最终体积 V_final三者连乘后的结果,也就是用户真实下载的字节数。
实战配置:搬家公司的作业手册
搬家公司会给每位客户一张派工单:从哪个房间开始搬、带哪些工具、送到哪几个地址。Rollup 的配置文件 rollup.config.js 就是这张派工单——它是整个打包流程的指挥中枢。一份典型的库配置长这样:input 指定打包入口文件(从哪个房间开始);output 数组同时输出 ESM 和 CJS 两种格式(送到几个地址);plugins 挂载压缩、别名、声明文件生成等工具;external 列出外置依赖清单(哪些大件不搬)。
完整发布流程是一条标准流水线:清理 dist 目录 → 执行 rollup -c 打包 → 类型检查与声明文件生成 → 用 publint、arethetypeswrong 等工具检查产物 → 最后 npm publish。发布检查重点看三件事:exports 字段是否完整映射、类型入口是否存在、是否存在双包危险。配置项之间环环相扣:external 决定外置清单,output 决定格式与文件名,plugins 决定产物质量——任何一环配错,产物体积或兼容性都会出问题。
手册在手,下次搬家照章办事。从杂物间到标准收纳,从选对搬家公司到真空压缩——你现在已经完全有能力,做自己库的金牌搬家公司了。
- 配置对象 config
input、output、plugins、external等选项的集合。 - 构建过程 FRollup 依据配置执行的读取、摇树、打包、插件处理全套流程。
- 产物数组 outputs输出结果,常同时包含
O_esm与O_cjs两种格式文件。
AI 应用场景
AI辅助库体积体检
阶段:性能分析
解决的问题:传统做法靠人工 review 和手动翻打包产物找体积问题,效率低、易遗漏。AI 可以扫描库源码与构建产物,自动识别未使用导出、重复代码、可外置依赖,并生成带优先级的优化建议清单,相当于给搬家行李做一次 AI 智能盘点。
具体案例:某团队开发一个图表库,用 LLM 分析 Rollup 构建日志与产物依赖图,自动发现 tools 目录下三个从未被引用的工具函数、误入 dependencies 的 lodash,并建议改用 lodash-es 按需引入。按建议优化后,产物体积从 120KB 降到 48KB,gzip 后仅 18KB。
自然语言生成 rollup.config.js
阶段:自动生成
解决的问题:Rollup 配置项繁多(output、external、plugins),新手容易配错,老手也常翻文档。用 LLM 理解用户意图——“我要输出 ESM 和 CJS,外置 react,开启压缩”——直接生成合规的配置文件,相当于用大白话给搬家公司下派工单。
具体案例:某公司内部 Copilot 结合工程规范与历史配置样本,将一句“生成一个双格式输出的库配置”指令转换为完整的 rollup.config.js,并自动校验 exports 字段合法性与双包风险,省去半小时的手写调试时间。
ESM 迁移助手
阶段:代码迁移
解决的问题:老库仍在使用 CommonJS,享受不到 ESM 静态分析和 tree-shaking 红利,迁移成本又高。AI 可以静态分析 require/module.exports 调用关系,自动改写为 import/export,并保持行为一致,让老库重新获得摇树能力——这就是把杂物间改造成标准收纳房。
具体案例:用 AST + LLM 流水线处理一个拥有 40 个模块的老工具库,将 CJS 改写为 ESM 后配合 Rollup 打包,产物体积减少 35%,且全部单元测试通过。
Python 演示
"""Tree-shaking 极简模拟: 从入口出发做可达性分析""" from collections import deque MODULE_GRAPH = { "entry": (["main"], [("utils", "formatURL"), ("api", "fetchData")]), "utils": (["formatURL", "randomText"], [("lib", "pad")]), "api": (["fetchData"], []), "lib": (["pad", "sha256"], []), "debug": (["logTrace"], []),} def tree_shake(entry): """返回保留模块集合, 以及各模块中被实际用到的符号表""" used_modules = set() used_symbols = {} queue = deque([entry]) while queue: mod = queue.popleft() if mod in used_modules: continue used_modules.add(mod) exports, imports = MODULE_GRAPH[mod] syms = used_symbols.get(mod, set()) for sym in exports: syms.add(sym) used_symbols[mod] = syms for dep_mod, dep_sym in imports: if dep_mod not in used_symbols: used_symbols[dep_mod] = set() used_symbols[dep_mod].add(dep_sym) if dep_mod not in used_modules: queue.append(dep_mod) return used_modules, used_symbols def main(): used_modules, used_symbols = tree_shake("entry") print("保留的模块:", sorted(used_modules)) print("各模块记录到的符号(导出+被引用):") for mod in sorted(used_symbols): print(f" {mod}: {sorted(used_symbols[mod])}") dead = [m for m in MODULE_GRAPH if m not in used_modules] print("被剔除的死代码模块:", dead) if __name__ == "__main__": main()
queue = deque([entry])从入口模块开始的遍历队列,是可达性分析的起点if mod in used_modules: continue已访问过的模块直接跳过,防止循环依赖导致死循环exports, imports = MODULE_GRAPH[mod]拆出当前模块的导出清单和引用清单,对应 ESM 的静态声明used_symbols[dep_mod].add(dep_sym)记录依赖模块中被真正用到的符号,这是符号级摇树的依据if dep_mod not in used_modules: queue.append(dep_mod)把未访问的依赖模块放进队列,继续扩散分析dead = [m for m in MODULE_GRAPH if m not in used_modules]遍历结束后,从未被访问到的模块就是死代码,打包时整块剔除
常见误区
误区既然 Rollup 会摇树,我只要把代码丢进去,所有没用到的部分都会自动消失,什么都不用管。
正解Tree-shaking 依赖 ESM 的静态结构,CommonJS 的 require 是运行时动态调用,根本无法静态分析。而且有副作用的模块会被打包器保守保留——搬家公司只认清单,但如果你没给箱子贴“纯物品”标签,它不敢扔,万一里面藏着定时炸弹呢?正确做法是声明 sideEffects: false 并善用 /*#__PURE__*/ 注释。
为何常见:打包器 demo 展示的摇树效果过于惊艳,让新手把“优化能力”当成了“免维护承诺”。
误区发布前必须把产物体积压到最小,最好先用 Terser 压缩好再上传 npm,用户下载时越小越好。
正解库通常交给使用方的构建链统一压缩。预压缩的产物无法被上层再次 tree-shaking,sourcemap 对不上还增加排错难度。你应该交付“洗好切好的净菜”而不是“真空熟食”——保留未压缩的 ESM 源码作为主入口,必要时额外提供压缩变体供 CDN 直接引用。
为何常见:应用打包时压缩是标配操作,人们把应用场景的经验直接平移到了库场景。
误区用 Rollup 的 `output` 数组同时输出两种格式,所有使用方的兼容问题都解决了。
正解还差关键一步:配置 exports 条件映射,并且警惕“双包危险模式”。当使用方某些场景同时加载 ESM 和 CJS 两份产物,会产生两个模块实例——React 库双实例会导致 hooks 状态错乱。罐装和袋装虽然是同一款酱,但两罐同时下锅,味道就过了。解决思路:要么只保留 ESM(现代浏览器和 Node 16+ 都已支持),要么确保两份格式不持有模块顶层可变状态。
为何常见:看到 dist 里有两个文件就以为大功告成,忽略了“同一份逻辑被加载两次”这种运行时层面的问题。
误区库应该自带全部依赖,用户只装我一个小包就能开箱即用,体验最好。
正解把 react、lodash 这类常见依赖打进包内,会导致使用方项目出现两份依赖实例、体积膨胀和 peer 版本冲突。正确做法是在 Rollup 配置里声明 external,并在 package.json 用 peerDependencies 声明同伴依赖。就像新房本来就配好冰箱,你硬要再搬一台进去,厨房会挤得站不下人。
为何常见:直觉上“一键安装即用”很友好,没意识到依赖去重和版本协同的复杂性远超想象。
误区只要代码里写了 `import()`,库的按需加载就自动完成了,用户一定只加载需要的部分。
正解动态 import() 只是代码分割的触发器,后续还有 chunk 命名、分包粒度控制、避免循环依赖、文档引导等一系列工程化工作。如果对每个函数都做动态 import,会产生几十个 2KB 的小 chunk,请求数爆炸反而拖垮性能。拆小件只是第一步,小件还要贴好标签、放到对应楼层,才算真正方便。
为何常见:把单个 API 的“用法”当成了完整“策略”,缺少工程化全局视角。
面试题
Q1. 关于 Rollup 和 Webpack,下列说法正确的是?
- A.Rollup 只能打包纯 JS 文件
- B.Webpack 更适合打包 NPM 库
- C.Rollup 打包库输出更干净精简← 正确答案
- D.Webpack 必然比 Rollup 体积小
答案:C。Rollup 专精 JS 库打包,产物无运行时胶水、更干净;Webpack 为复杂应用而生,擅长处理各类资源。两者分工不同,A 说“只能”过于绝对,B 说反了,D 的“必然”不成立,所以选 C。
Q2. Tree-shaking 能生效的前提条件是什么?
- A.模块使用 CommonJS 的 require
- B.模块使用 ESM 静态 import 导出← 正确答案
- C.代码必须预先经过压缩处理
- D.必须使用 webpack 进行构建
答案:B。Tree-shaking 依赖 ESM 的静态结构——import/export 写在文件顶部,编译器能提前构建依赖图。CommonJS 的 require 是运行时动态的,无法静态分析;压缩和 webpack 都不是前提,故选 B。
Q3. 在 package.json 中声明 "sideEffects": false 的含义是什么?
- A.打包时禁用所有插件工具
- B.所有模块无副作用可安全摇树← 正确答案
- C.该库只能在浏览器环境运行
- D.强制要求使用 ESM 模块格式
答案:B。该声明告诉打包器“这个包的模块都没有副作用”,因此没有引用的导出可以被安全删除。它不涉及插件、运行环境或格式强制,A、C、D 都是对副作用的误读,故选 B。
Q4. 下列哪些是 ESM 相比 CommonJS 的优势?
- A.静态结构便于工具分析优化← 正确答案
- B.天然支持 Tree-shaking 摇树← 正确答案
- C.模块同步加载后立即执行
- D.标准语法支持动态 import()← 正确答案
答案:ABD。A 和 B 是 ESM 静态声明的直接红利;D 的 import() 是 ESM 标准中的动态导入语法。C 恰好是 CommonJS 的特点(同步执行),不属于 ESM 优势,故不选。
Q5. package.json 的 exports 字段主要作用是什么?
- A.声明包的许可证与作者信息
- B.按条件映射模块入口文件← 正确答案
- C.限制包的最大体积上限
- D.声明依赖的最低版本要求
答案:B。exports 字段精确控制不同条件(如 import/require)下模块入口的映射路径,是双格式输出的“货架价签”。许可证、体积限制、依赖版本分别由其他字段负责,故选 B。
Q6. 关于双包危险模式(dual package hazard),下列说法正确的是?
- A.同时存在两套模块系统代码← 正确答案
- B.使用方可能加载两份模块实例← 正确答案
- C.引发多实例与状态不一致问题← 正确答案
- D.仅在浏览器环境才会出现
答案:ABC。双包危险指 ESM 和 CJS 两份产物被同一使用方同时加载,形成两套实例和两份状态,这在 Node 和浏览器环境都可能发生。D 说“仅浏览器”错误,故选 A、B、C。
Q7. /*#__PURE__*/ 注释在 Rollup 中的作用是什么?
- A.删除代码中所有 console 语句
- B.标注函数无副作用可被摇掉← 正确答案
- C.开启浏览器 GPU 硬件加速
- D.跳过 ESLint 语法检查规则
答案:B。/*#__PURE__*/ 标记一个函数调用是“纯”的——若返回值未被使用,该调用可以安全删除。它帮助 Tree-shaking 消除有副作用的误判,与删除日志、GPU 加速、ESLint 无关,故选 B。
Q8. 动态 import() 配合代码分割的主要收益是什么?
- A.加快项目的编译构建速度
- B.首包变小实现按需加载← 正确答案
- C.减少开发者的代码量
- D.自动修复运行时的 Bug
答案:B。动态 import() 触发代码分割,将大包拆成若干 chunk,使用方首屏只加载主 chunk,用到子功能时才拉取对应 chunk。它不直接影响编译速度、代码量或 Bug 修复,故选 B。
Q9. 库打包时在 external 中声明 react 的主要目的是什么?
- A.把依赖打包进最终产物
- B.让使用方自行提供该依赖← 正确答案
- C.禁止代码中使用该依赖
- D.自动压缩该依赖的代码
答案:B。external 声明让 Rollup 不打进 react,改由使用方项目统一提供,并通过 peerDependencies 声明配套版本,避免双实例与体积膨胀。它不是禁用、不是压缩、更不是打进产物,故选 B。
Q10. 下列哪些手段能有效减小 npm 库的体积?
- A.用 Tree-shaking 消除死代码← 正确答案
- B.压缩混淆减小产物体积← 正确答案
- C.外置依赖避免重复打包← 正确答案
- D.在文档中增加更多注释
答案:ABC。Tree-shaking 删死代码、压缩混淆抽掉“空气”、外置依赖把大件留给使用方,三管齐下能显著瘦身。D 写文档不影响产物体积(注释在压缩时还会被删掉),故不选。