Rollup 打包原理

同一份源码,rollup 的产物是几个平铺的函数,webpack 的产物自带一套模块系统。这个差别决定了打库该用哪个,也决定了 external、preserveModules 这些选项为什么存在。

预计
7 分钟

三个源文件,一个组件库该有的最小形状:入口只做转发,组件依赖一个 hook,hook 文件里还有一个没人用的导出。

src/ —— 三个文件一共五行
// index.js
export { Button } from './Button.js';
export { useToggle } from './useToggle.js';
// Button.js
import { useToggle } from './useToggle.js';
export function Button() { return useToggle(); }
// useToggle.js
export function useToggle() { return 'toggled'; }
export function neverUsed() { return 'dead'; }

npx rollup src/index.js --format es 的全部产物是这样(rollup 4.62.4,没有配置文件,没有插件,没有压缩):

rollup · format=es 的完整产物
function useToggle() { return 'toggled'; }
function Button() { return useToggle(); }
export { Button, useToggle };

三件事同时发生了:模块被摊平进一个作用域、neverUsed 不见了、产物里没有任何 rollup 自己的代码。最后一条是理解 rollup 的关键 —— Webpack 5 打包原理详解里同样两个模块的产物,前六十行是 __webpack_module_cache____webpack_require__,模块被包成函数存进一张表。rollup 什么都不加,importexport 原样留着,交给使用者的运行环境去解决。

摇掉没用的导出不需要压缩器

neverUsed 消失这一下,是没有开任何压缩的情况下发生的。这和 webpack 那边不一样:webpack 的 Tree Shaking 只负责分析出「这个导出没人用」并留下标记,真正删掉那几行文本的是 Terser,关掉压缩它就原样留在产物里。rollup 自己就把节点从 AST 上摘了。

能摘的前提是 ESM 的静态结构。importexport 只能写在模块顶层、名字必须是字面量,所以不执行代码就能算出「从入口出发,哪些绑定可达」。require() 是运行时求值的函数调用,同样的分析做不了 —— CommonJS 的依赖要靠 @rollup/plugin-commonjs 先转成 ESM,转不干净的部分就摇不动。

比可达性更麻烦的是副作用。一个模块没人用它的导出,能不能整个丢掉?取决于「光是 import 它会不会产生影响」——往原型上挂方法、注册全局 CSS、打 polyfill 的模块都会。rollup 的 treeshake.moduleSideEffects 管这件事,默认假定所有模块都有副作用,保守但安全。

这里有个容易记错的分工:package.json 里的 sideEffects 字段不是 rollup 核心读的,那是 @rollup/plugin-node-resolve 的行为 —— 它的 README 里写着会尊重这个字段,还给了个 ignoreSideEffectsForRoot 开关来关掉根包的这份尊重。没装这个插件而指望 sideEffects 生效,是查半天查不出来的那类问题。

format 决定产物给谁用

output.format 是打库时第一个要想清楚的选项,官方文档列的取值是这六个(括号里是文档承认的别名):

format 产物形态 给谁
esesm / module 保留 import / export 别的打包器、现代浏览器的 <script type=module>
cjscommonjs exports.X = X Node 和老打包器
umd 三种都认的判断外壳 既要 <script> 直接引,又要被 require
iife 自执行函数 直接 <script> 引入
amd define([...], …) RequireJS 这类加载器
systemsystemjs SystemJS 的原生格式 需要在老浏览器里做代码分割

cjs 的产物和 es 只差收尾两行:

rollup · format=cjs
'use strict';
function useToggle() { return 'toggled'; }
function Button() { return useToggle(); }
exports.Button = Button;
exports.useToggle = useToggle;

umd 那层外壳是唯一有点体积的:

rollup · format=umd --name MyLib
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.MyLib = {}));
})(this, (function (exports) { 'use strict';
// …模块内容和上面两份一样
}));

高亮的三行就是它在运行时依次问的三个问题:有 exports 吗(CommonJS)、有 define.amd 吗(AMD)、都没有就挂到全局上。umdiife 必须给 --name,因为「挂到全局的哪个名字上」没有别的地方能推断。

一个库通常同时出 escjs 两份,在 package.jsonexports 字段里分别指过去。es 那份是给使用者的打包器摇树用的,cjs 那份是为了在 Node 里 require 得动。

external:库不该把 React 打进去

默认情况下 rollup 会把能解析到的东西全部拉进产物。组件库里 import { useState } from 'react' 而不声明 external,rollup 会先警告一句:

没声明 external 时
(!) Unresolved dependencies
https://rollupjs.org/troubleshooting/#warning-treating-module-as-external-dependency

加上 --external react 之后,那条 import 原样留在产物里:

rollup · --external react
import { useState } from 'react';
function C() { return useState(0); }
export { C };

这正是打库要的结果。React 留给使用者的应用去提供,库里带一份的后果是应用里出现两个 React 实例 —— hooks 直接报错,因为两份 React 各有各的内部状态。

externalpeerDependencies 是同一件事的两面,rollup 官方文档里有一节就叫 Peer dependencies,讲的就是把 React、Lodash 这类声明成 external 之后「不会被打进你的库里」。实践上的写法是从 package.json 里把 peerDependencies 的 key 读出来直接喂给 external,省得两处手写着写着就对不上。注意 external 匹配的是模块 ID:写了 react 不会自动覆盖 react/jsx-runtime,要用正则或者函数形式。

preserveModules 保住文件结构

默认行为是尽量少切块,三个源文件合成一个产物。加上 --preserveModules 就变成一一对应:

rollup --format es --dir dist --preserveModules
dist/Button.js
dist/index.js
dist/useToggle.js

dist/index.js 里就是原样的两行转发,dist/useToggle.jsneverUsed 依然被摘掉了 —— 文档明确说这个模式下 Tree Shaking 照常生效,会去掉非入口文件里没人用的导出。

打库的人用它,图的是使用者那边的粒度:一个大文件意味着使用者的打包器要么全要要么全不要,拆开之后没被 import 到的文件根本不会进最终产物。代价有两条,文档里都写了:需要 output.dir 而不能用 output.file;转成 CJS 或 AMD 时每个文件默认按 exports: auto 处理,同时有默认导出和具名导出的文件会警告,得显式写 output.exports: 'named'。还有个 preserveModulesRoot,作用是把输入目录那一截前缀从输出路径里去掉,不然产物里会多出一层 src/

Rollup 4 把解析器换掉了

「rollup 用 acorn 解析」这句话现在是错的,虽然它对了很多年。Rollup 4(2023 年 10 月)的发布说明里写的是把解析器换成了 Rust 写的原生实现,acornacornInjectPlugins 两个配置项一起删掉了。

自己验一下最快:

看 rollup 到底依赖什么
node -p "JSON.stringify(require('rollup/package.json').dependencies)"

rollup 3.30.0 的 dist 里能搜到 acorn;rollup 4.62.4 的 dependencies 只剩一个 @types/estree,实际干活的是 @rollup/rollup-<平台> 那个可选依赖里的原生二进制。这带来两个实际后果:装不上对应平台的二进制时得改用 @rollup/wasm-node;以前靠 acorn 插件支持实验语法的路子没有了。

什么时候不该用 rollup

「rollup 打库、webpack 打应用」这句流传很广的话,两边官方都没这么说过。Rollup 的 FAQ 里专门有一问「Rollup 是给库还是给应用用的」,答案是它能构建绝大多数应用,只是在老浏览器上用代码分割需要额外的运行时(推荐 SystemJS);webpack 那边也有一篇正经的 Author Libraries 指南。所以这是生态里长出来的惯例,不是谁的立场。

惯例背后的技术理由,就是开头那份产物:应用需要一套能在浏览器里跑的模块系统,那套外壳是刚需;库不需要,它只要交出一份使用者的打包器能继续分析的代码。选谁的判断落在这一句上 —— 你的产物是最终形态,还是别人的输入。

具体不该选 rollup 的场合:

  • 应用里大量按需加载、要精细控制 chunk 分组。 webpack 的 splitChunks 在这块的表达能力和生态积累都更厚。
  • 依赖树里 CommonJS 占比高。 rollup 要靠 @rollup/plugin-commonjs 把它们转过来,转换本身就是一层可能出问题的地方;webpack 原生就吃两种格式。
  • 需要开发服务器和 HMR。 rollup 自己不提供,--watch 只是重新构建。

最后一条要特意分清楚:开发服务器、依赖预打包、import.meta.env、glob 导入这些是 Vite 的行为,不是 rollup 的。Vite 长期把 rollup 用在生产构建那一步,但它底下用什么打包器一直在变,读到任何「Vite 用 rollup 所以 rollup 会……」的说法都要先看它是哪一年写的。

同一套判断也适用于 TypeScript 项目的分层:产物给谁用,决定了它该长什么样。TypeScript:从架构分层设计到 IOC 和 AOP那篇讲的是源码这一侧的分层,这篇讲的是它出厂之后的形状。

这篇归在 TypeScript 下。同一主题里最近的另外两篇是 TypeScript:从架构分层设计到 IOC 和 AOPTypescript:Nest.js 中使用声明文件定义依赖注入的类型