Rollup — ESM 优先的打包器
是什么
Rollup 是一个 JavaScript 打包器(bundler)——把分散在很多文件里的代码合并成一个发布产物。日常类比:你写一本书用了 30 个 markdown 文件分章节,出版前要把它们合并成一份 PDF。Rollup 就干这事,但还会顺手做一件更厉害的:砍掉没人引用的章节——这叫 tree-shaking。
类比:“整理书架时把重复的书拿掉,只留真正有人借过的”。你 import 了 lodash 的一个 debounce,Rollup 知道你只用了这个,就只把 debounce 打进产物,剩下 600 个 lodash 函数全部丢掉。
它最大的舞台是「发 npm 库」:React / Vue / Three.js / d3 这些大型库的构建工具都是 Rollup。它不太适合做 SPA 应用(那是 webpack / Vite 的活),擅长把库精雕成最小最干净的发布形态。
为什么重要
不理解 Rollup,下面这些事都没法解释:
- 为什么 tree-shaking 这个词成了 JS 工具链的通用术语——它就是 Rich Harris 2015 年在 Rollup 文档里发明的
- 为什么 webpack 4(2018)才补上 tree-shaking——Rollup 早 3 年就有了
- 为什么 Vite production build 慢——它底层调的就是 Rollup(用 JS 写的,不快)
- 为什么 React / Vue 这些「库」的产物比 webpack SPA 干净那么多——格式不同,思路不同
简单说:你用任何现代 JS 库,几乎都吃了 Rollup 的产物;你用 Vite 部署,build 阶段也是 Rollup。
核心要点
Rollup 干活的过程可以拆成 三步:
-
静态分析:读
import/export语法。ES module 的厉害之处在于「编译期就能知道谁 import 了谁」——不像 CommonJS 的require()是运行时调用、可以包在 if 里、可以拼接路径。Rollup 利用这个静态性,构出一张完整的依赖图。 -
Tree-shaking:从入口(你指定的
index.js)出发,沿依赖图标记每个被「真正用到」的 export。没人用到的 export 直接不输出。类比:扫描一棵树,只留有人在上面走过的枝丫,其他全部锯掉。 -
Plugin 钩子:Rollup 自己只懂 ESM。其他东西(TypeScript / CSS / JSON / CommonJS 模块)全靠 plugin 转成 ESM 喂给它。Plugin 写起来是三个核心钩子:
resolveId(解析路径)、load(读文件)、transform(改源码)。
实践案例
案例 1:最小可用的 rollup.config.js
import typescript from '@rollup/plugin-typescript';import { nodeResolve } from '@rollup/plugin-node-resolve';
export default { input: 'src/index.ts', // 入口文件 output: { file: 'dist/index.mjs', // 输出位置 format: 'esm', // 输出格式 }, plugins: [ nodeResolve(), // 找 node_modules 里的依赖 typescript(), // .ts → .js ],};逐部分解释:
input是你的入口文件——Rollup 从这里开始追依赖output.format: 'esm'让产物保留import/export语法(适合现代环境)。其他选项有cjs(老 Node)、umd(同时兼容浏览器和 Node)、iife(直接<script>引用)plugins是顺序敏感的——nodeResolve必须在typescript之前,否则它读不懂import 'foo'该去哪找
案例 2:写一个最小 plugin(10 行)
// 把 .json 文件转成 ESM exportexport default function jsonPlugin() { return { name: 'json', transform(code, id) { // 钩子:每个文件读完后触发 if (!id.endsWith('.json')) return null; // 只管 .json const data = JSON.parse(code); return `export default ${JSON.stringify(data)};`; }, };}return null 表示「这个 plugin 不处理此文件,传给下一个」。这就是 plugin 链——链式串接,每个 plugin 改一次。
案例 3:tree-shaking 体积对比
// 不 tree-shake 友好的写法import _ from 'lodash'; // 全包,~70KBconst fn = _.debounce(handler, 300);
// tree-shake 友好的写法import { debounce } from 'lodash-es'; // 只含 debounce + 它的依赖,~3KBconst fn = debounce(handler, 300);差别:lodash 是 CommonJS 包,Rollup 没法静态分析它的内部结构,整包打进产物。lodash-es 是 ESM 版本,Rollup 能精确切下你用到的部分。这就是为什么很多大库都同时维护 xxx + xxx-es 两个 npm 包。
踩过的坑
-
CommonJS 默认不能 tree-shake:Rollup 内部只懂 ESM。CJS 包必须先用
@rollup/plugin-commonjs转换。但这个 plugin 是 best-effort——遇到动态 require、条件 require、module.exports = function() {}等动态形态可能转换失败。CJS 重的项目慎选 Rollup。 -
sideEffects: false配错会砍掉副作用代码:在package.json写"sideEffects": false等于告诉 Rollup「我这包没副作用,shake 吧」。但如果你有import './styles.css'(CSS 也是副作用),Rollup 会把整行丢掉,产物里就没 CSS 了。修法:写成"sideEffects": ["*.css"]列出例外。 -
output.format选错发布的库装不上:format: 'esm'只能在支持 ESM 的环境用(现代 Node、浏览器原生)。老 Node + 老 webpack 项目要cjs。库作者通常两份都发——dist/index.mjs+dist/index.cjs,配套package.json的exports字段告诉 Node 该用哪个。 -
多 entry 共享 chunk 配置易混乱:当
input是多个文件、且某些模块被多个入口引用时,Rollup 会自动拆出 shared chunk。但 chunk 的命名、依赖顺序、是否 inline 等细节要靠output.manualChunks手动指定。新手在这里容易输出一堆chunk-XXXX.js文件名乱七八糟。
适用 vs 不适用场景
适用:
- 发 npm 库(库作者首选)——产物扁平、tree-shake 干净、ESM 优先
- 单文件构建工具(CLI 工具的 bundle 阶段)
- monorepo 里的 library 子项目(配合 Vite 做 application)
不适用:
- SPA / 大型应用——没内置 dev server / HMR,配套生态不如 webpack / Vite
- CommonJS 模块为主的老项目——@rollup/plugin-commonjs 兼容性边界多
- 需要复杂 code splitting + cache 优化的浏览器应用——webpack 的 splitChunks 更成熟
历史小故事(可跳过)
- 2015 年:Svelte 创始人 Rich Harris 在 Medium 写了一篇「Tree-shaking versus dead code elimination」,把基于 ESM 静态分析做 dead code 剔除的思路命名为 tree-shaking,并发布 Rollup v0.1
- 2018 年:webpack 4 跟进 tree-shaking,但要靠
sideEffects字段 + ModuleConcatenationPlugin + terser pass 才能完整工作。Rollup 仍是 library 场景首选 - 2020-2021 年:Vite 1.0 / 2.0 选 Rollup 作为 production build 引擎。理由:dev 用 esbuild 够快,但 production 要的精度只有 Rollup 给得了
- 2021 年:Rich Harris 加入 Vercel,Rollup 维护转向 Lukas Taegert-Atkinson 团队
- 2024-2026 年:VoidZero(Evan You 的新公司)启动 Rolldown——用 Rust 重写一个 Rollup-API-兼容的 bundler,目标是把 Vite 底下的 Rollup 替换掉,速度对齐 esbuild
学到什么
- ESM 静态结构是 tree-shaking 的根——CommonJS 的
require()是运行时调用,编译器没法在编译期推可达性。这就是为什么 Rollup 把「ESM-first」当做核心架构而非 marketing 话术 - plugin 钩子设计反映了构建流程的关键节点——
resolveId/load/transform三件套对应「找文件 / 读文件 / 改文件」,是任何 bundler 的最低骨架 - library 和 application 是两种世界观——library 要扁平、单文件、tree-shake 友好;application 要 chunk / cache / runtime patch。Rollup 选了前者,webpack 选了后者
sideEffects字段是 npm 生态的隐形 convention——发库时漏写,下游 bundler 就 shake 不动你;写错,CSS 等副作用文件被砍。这是踩坑高发区- 理论 → 工程 → 替换:tree-shaking 概念 2015 年提出 → Rollup 工程化 → webpack 跟进 → 2024 年用 Rust 重写。每一步隔约 5 年
延伸阅读
- 官方教程:Rollup Tutorial(30 分钟跑通 Hello World library)
- 概念出处:Rich Harris — Tree-shaking versus dead code elimination
- 实战练习:自己写一个 50 行 npm library,配
package.json的exports/sideEffects,跑npm pack看产物 - 进阶:读
@rollup/plugin-typescript源码(约 300 行),是最简的真实 plugin 范例 - webpack —— Rollup 的「另一极」:application 打包的标杆
- vite —— Rollup 的「上层包装」:dev 用 esbuild,build 用 Rollup
关联
- webpack —— 与 Rollup 形成 application vs library 的两极
- vite —— 把 Rollup 装在底下做 production build,是 Rollup 在 application 场景的解
- esbuild —— 与 Rollup 形成「速度 vs 精度」的对比
- vue —— 内核构建用 Rollup,是「ESM-first 库」的典型用户
反向链接
- esbuild —— esbuild — 用 Go 写的极速 JS bundler
- layerzero —— LayerZero V2 — 让一条链上的合约能给另一条链上的合约发消息
- nx —— Nx — 一个仓库装几十个项目时帮你少跑活的工具
- oclif —— oclif — 给 50+ 命令的 CLI 一套”目录即路由”的框架
- rolldown —— rolldown — 用 Rust 给 Vite 当统一引擎的打包器
- swc —— SWC — Rust 写的 TS/JS 编译器
- vite —— Vite — 浏览器自己加载源码的构建工具
- vue —— Vue.js — 渐进式 UI 框架
- webpack —— webpack 模块打包