Rollup Tree Shaking 原理与实践:从模块机制到打包体积优化

Rollup Tree Shaking 原理与实践:从模块机制到打包体积优化 前阵子帮组里做组件库构建方案改造工具箱里上百个工具函数业务侧每个模块就用到三五个结果打包出来一个基础包动不动就一两百 KB。当时第一反应就是上 tree shaking但真正折腾下来才发现Rollup 的摇树优化并不是简单开个开关就完事里面涉及到模块规范、副作用判断、工具链配合、产物验证一整套链路。这期间踩了不少坑也把 Rollup 的 tree shaking 原理重新捋了一遍。这篇就把我对 Rollup tree shaking 的完整理解、实操过程和排查经验写出来希望能帮到正在和打包体积作斗争的朋友。1. tree shaking 的底层逻辑模块机制才是起点想明白 tree shaking首先要回答一个基本问题为什么有的代码能被“摇掉”有的代码却死活删不掉这背后其实不取决于打包工具有多聪明而取决于模块系统本身给不给静态分析的机会。Rollup 能在 tree shaking 上做得激进最大的原因就是它从设计之初就完全围绕 ES Module 来构建这一点和很多后期兼容 ESM 的打包器有本质区别。1.1 ES Module 是摇树的地基ES Module 规范要求 import 和 export 必须写在模块顶层不能嵌套在条件语句、函数体或者作用域块里。这个看起来像限制的规则恰恰是 tree shaking 能够成立的根本前提。因为所有导入导出关系在代码真正执行之前就是完全确定的就像签合同之前条款已经白纸黑字列清楚打包器不需要运行代码就能知道这个模块对外暴露了哪些东西、内部又依赖了哪些模块。对比 CommonJS 就非常直观了。CJS 的 require 是一个运行时函数调用它可以写在任意位置依赖完整路径甚至可以是动态拼接出来的字符串。举个简单的例子const name someCondition ? ./a : ./b; require(name)这种写法在静态分析阶段根本没法确定最终加载哪个模块更不要说分析模块里哪些导出被用到了。即便大部分项目不会真这么写只要规范允许打包器就必须处理这种不确定性最终只能采取保守策略把整个模块保留下来。所以一个很现实的经验是如果你在 Rollup 项目里引入了一个 CommonJS 风格的依赖tree shaking 在跨这个模块边界的时候基本就失效了。这也是为什么很多老牌 npm 包在 ESM 化改造之前再怎么配 sideEffects 都没办法把体积降下来因为模块内部根本不是静态结构。Rollup 官方文档里也一直在强调只有 ES Module 才是 tree shaking 的前提。1.2 副作用摇树时判断删与不删的标尺有了静态结构之后还需要回答另一个问题这段代码我没用到但它能不能被安全删除答案是看它有没有“副作用”。副作用在 JavaScript 里的定义很宽泛只要一段代码在运行时可能影响外部世界它就有副作用。最简单的例子是模块顶层执行了console.log或者修改了某个全局变量、修改了传入对象的属性、发起了一个网络请求甚至只是访问了一个带有 getter 的属性都可能产生副作用。这里的判断难点在于副作用并不总是显而易见的。const result someFunction()这行代码有没有副作用取决于someFunction内部做了什么。如果它只是返回一个纯计算的值删掉没问题但如果它内部修改了全局状态删掉就相当于改变了程序行为。难点在于静态分析器面对一个函数调用时本质上没法百分百确定函数内部有没有副作用因为 JavaScript 是一门动态语言函数内部的代码只有真正执行才知道会做什么。Rollup 对副作用的处理策略简单说起来就是“默认怀疑按需放行”——如果某个顶层表达式看起来可能产生副作用它就会保守地保留如果是纯函数调用且加了明确标记它才会放心地删除。而标记的方法就是我们常说的/*#__PURE__*/注解这个后面的章节会详细展开。总之副作用判断是 tree shaking 最核心的判定逻辑也是实践中最容易出问题的地方。2. Rollup 摇树机制拆解从入口到产物经历了什么知道了原理层面的前提我们再深入到 Rollup 内部看它到底是怎么一步步把没用到的代码挑出来扔掉的。很多文章会把 Rollup 的 tree shaking 概括为“标记-清除”两个阶段这个说法方向是对的但实际过程要更细一些至少包含模块图构建、标记可达声明、生成阶段重写这三块。2.1 模块图构建把依赖关系织成一张网Rollup 处理代码的第一步是从入口文件开始逐个解析 import 语句找到对应的依赖模块再递归解析这些模块里的 import直到把所有可达模块全部加载完形成一棵完整的模块依赖图。这个过程有点像从一棵树的树根出发沿着枝条往上爬把所有能到达的叶子都摸一遍。在解析每个模块的时候Rollup 并不是简单地把源码存起来而是会把它解析成 AST抽象语法树然后基于 AST 分析出这个模块的导入导出信息。比如它要知道这个模块到底 export 了哪些名字、每个名字对应哪个顶层声明同时也要知道它 import 了哪些名字这些名字分别来自哪个模块。这就是 Rollup 著名的“模块绑定”阶段它把整个依赖图里的每个导入导出关系都建立成可追踪的引用关系。之所以能做到精准摇树就是因为这种绑定关系是静态且完整的。这个阶段的操作对性能的影响很大。一个大型项目可能有几千上万个模块每个模块都要做完整解析。好在 Rollup 默认会缓存解析结果而且只处理入口可达的模块不会像某些工具那样把整个 node_modules 都扫一遍。这里插一句如果你引入的某个库没有module或exports字段指向 ESM 版本Rollup 可能找不到对应的 ESM 入口最终还会走 CommonJS 插件转换的路径而这一步往往就是 tree shaking 失效的开始。2.2 标记阶段从入口出发只给活代码盖戳模块图建好之后Rollup 会从入口模块的顶层语句开始顺着引用关系往下走把所有执行路径上真正会被用到的声明都标记为“存活”。这个过程很像从树根开始灌溉只有水流经过的枝条才能存活水流到不到的枝叶最后都会被剪掉。标记阶段一个关键操作是入口模块的顶层代码会被全部视为有副作用的执行代码因为入口函数总是要运行的。而其他模块中未被入口引用的导出如果它不产生副作用就会被判定为死代码。Rollup 在标记的时候也会传递性地处理如果模块 A 里一个被引用的函数调用了模块 B 里的某个函数那么 B 里的这个函数也会被标记为存活因为它属于执行路径的一部分。这里面有一个非常重要的细节Rollup 对未使用导出的处理是非常激进的不仅会删掉未被引用的函数声明还会把那些只被“部分使用”的导出对象进行属性级别的分析。比如你从工具库引入了import { debounce } from lodash-es但模块里还导出了 throttle、cloneDeep 等几百个函数Rollup 会精准地把这些没被 import 到的函数全部排除掉。但这有一个前提就是这些函数必须是相互独立的顶层声明不能通过一个整体对象被间接引用。2.3 生成阶段的代码重写把多模块压缩成单文件完成标记之后Rollup 进入代码生成阶段。这里的核心策略叫 scope hoisting中文一般叫作用域提升。简单说就是不再像 CommonJS 一样把每个模块包一层函数而是把所有模块的代码平铺到同一个作用域里通过重命名避免变量冲突。这样做好处很多代码体积更小、运行时性能更好同时也让 tree shaking 的效果在最终产物里变得更彻底。在生成代码时Rollup 会基于前面标记的结果做二次清理。所有未被标记的声明直接不输出标记为存活的声明会被保留并做必要的重命名。同时Rollup 还会处理各种语句级别的优化比如把export const a 1直接替换成const a 1去掉所有 export 关键字因为最终产物已经不需要模块导出了。还有个容易被忽略的点Rollup 在对类声明、函数声明和变量声明做 tree shaking 时会把“声明本身”和“初始化表达式”分开考虑。一个未被使用的变量它的初始化表达式如果有副作用表达式代码会被保留如果没有整个声明都会被丢弃。这也就导致了实践中经常看到的一种现象某个模块里你只 import 了一个函数但产物里还是残留了几行莫名其妙的赋值语句那多半就是这个函数内部或模块顶层存在副作用。3. 实测角度哪些因素真正决定 tree shaking 的成败原理归原理实战中真正让 tree shaking 失效的原因五花八门。我实际改造项目时发现很多情况下问题根本不出在 Rollup 本身而是出在代码写法、依赖配置、编译链路这些外部因素上。这一部分我把自己踩过和被问过最多的问题集中整理出来基本覆盖了绝大多数摇树失败的情况。3.1 package.json 的 sideEffects 字段别乱标 false也别不标在 npm 包开发场景里sideEffects字段是决定下游使用者能不能有效摇树的关键配置。它有两个取值方向声明整个包无副作用或者列出有副作用的文件白名单。当你的包声明sideEffects: false时打包器就认为包内所有模块的顶层代码都是纯声明没有外部影响可以放心大胆地删除任何未被引用的导出。但副作用声明是一把双刃剑。如果你的包实际上有副作用比如引入了全局样式、做了 polyfill、在模块顶层初始化了某个全局变量却错误地标了sideEffects: false那么下游用户在使用时会发现这些副作用代码被删掉了程序行为直接出错而且是那种很难排查的“静默错误”。反过来说如果你的包确实没有任何副作用却不标注下游的 Rollup 项目就不得不保守处理tree shaking 效果大打折扣。我给自己的组件库配置的时候采用了明确的文件白名单方式在 package.json 里这样写{ sideEffects: [ **/*.css, ./src/polyfills.ts ] }这样做的意思是除了 CSS 文件和 polyfill 入口其他所有模块都视为无副作用可以安全摇树。这里特别提醒一点很多组件库会把每个组件的样式单独打包成 css 文件这种场景下如果不把*.css加进白名单下游打包时样式会被误删。这是我见过最普遍的 sideEffects 误配场景。3.2 纯注解手动告诉 Rollup“这里能删”/*#__PURE__*/是注释形式的注解Rollup 和 Terser 等工具都认识它。它的含义很直接标记这个函数调用或构造函数调用是纯的没有副作用如果它的返回值没有被用到就可以安全删除整行代码。什么时候需要手动加这个注解典型场景是调用一个自己写的或第三方提供的函数但返回值没被使用。比如// 未加注解Rollup 默认认为 toWarn 可能有副作用会保留这行 toWarn(init done); // 加了注解Rollup 认为这个调用是纯函数调用可以删除 /*#__PURE__*/ toWarn(init done);实际开发中最常见需要加纯注解的地方是“仅用于初始化的函数调用”还有 React 的createElement、Vue 的defineComponent这类工厂函数——它们返回值会被赋给变量一般不用加。真正需要手动加的场景是类似/*#__PURE__*/ React.createElement(Comp, null)这种表达式语句或者一些工具类里为了链式调用而单独出现的函数调用。这里说一个判断技巧当你看到产物里出现一段“没被任何变量引用、却依然存在”的函数调用代码基本就是 Rollup 判断它有副作用但无法确定具体是什么副作用这时候加纯注解一般能解决问题。当然加之前要确认这个函数真的没有副作用否则等于主动删掉了必要逻辑。3.3 破坏 tree shaking 的常见写法盘点有相当一部分 tree shaking 失效问题根因是代码写法本身不够“静态”。这里列几个我遇到最多的高频雷区。第一个雷区是动态访问导出。比如import * as utils from ./utils; utils[methodName]()这种写法Rollup 没法确定methodName到底对应哪个导出最安全的方式只能是保留 utils 模块里所有可能被访问到的导出。老实的做法是改成具名导入或者用静态的分支调用。第二个雷区是模块顶层的可执行代码。有些开发者习惯在模块里写测试代码、调试日志或者临时的初始化逻辑比如// utils.js export function format() { ... } export function parse() { ... } console.log(utils loaded);只要这个模块被入口链路上的任何模块导入这个console.log就会被保留因为它是一个模块层级的副作用。Rollup 没有任何办法判断这个日志是该删还是该留。规范的做法是去掉调试日志或者把它们放到函数内部按需调用再不然就用环境变量包一层。第三个雷区是打包器无法静态分析的对象访问。比如export default { a, b, c }这种默认导出对象如果你在业务代码里只用到obj.aRollup 理论上可以追踪属性的使用情况但实际上遇到通过变量间接访问、或者对象在模块之间传递后再访问的场景追踪链条很容易断掉。一旦断掉Rollup 会保留整个对象。这也是为什么很多库作者更推荐具名导出而不是默认导出对象默认导出对象在极端情况下几乎等于放弃了摇树能力。4. 调试与验证怎么确认 tree shaking 真的生效了配置都做了、注解也加了怎么确定 tree shaking 真的生效了我在实际工作中验证 tree shaking 效果主要靠三招看产物大小变化、直接用代码搜索残留、借助可视化插件分析模块构成。这一节把这套排查路径完整写出来可以直接照着操作。4.1 产物可视化一眼定位体积大头Rollup 生态里最常用的体积分析工具是rollup-plugin-visualizer。它会在构建结束后生成一个 HTML 报告把产物里每个模块的大小、依赖关系可视化出来。我最常用的姿势是配合 gzip 压缩后大小一起看这样能直观发现哪些模块占据了大量体积。配置方式很简单import { visualizer } from rollup-plugin-visualizer; export default { plugins: [ visualizer({ filename: ./dist/report.html, gzipSize: true, brotliSize: true, }), ], };跑完构建打开 report.html如果发现某个库的代码整块留下来了点击模块进去看具体保留了哪些函数就能初步判断是没摇掉还是真的被用到了。有个小技巧报告里每个模块会显示原始大小和渲染后大小如果原始大小很大但渲染后很小说明 tree shaking 起作用了如果两者接近说明这个模块基本全量输出需要进一步排查引用关系。4.2 残留代码定位从产物反推问题源头可视化报告能看出宏观问题但要精准定位某个函数为什么没被摇掉最直接的办法是在产物里搜索函数名。举个例子我改造一个工具库时发现chunk函数被打进了产物但项目里根本没有 import 过它。这时候我会在 dist 目录里直接搜索函数名grep -n function chunk dist/index.js找到残留位置后对照源码文件反推引用路径。最常见的结果是某个存活模块内部通过Array.prototype或某个工具对象间接调用了它Rollup 在追踪这种跨模块间接引用时丢失了目标。遇到这种情况可以把间接调用改成直接导入或者把被误保留的函数拆分成独立小模块再具名导出往往就能解决问题。另一个排查隐患的操作是检查 Rollup 构建时有没有警告信息。当 Rollup 遇到无法静态分析的代码比如eval或者动态require它会输出 “This is probably not a bug in Rollup” 之类的提示。虽然这种提示不一定直接和 tree shaking 相关但 eval 这种操作会让 Rollup 对当前模块的摇树直接失效因为它没法再静态判断后续代码的执行路径了。所以构建日志还是值得扫一眼的。4.3 treeshake 配置参数别只认识 true 和 falseRollup 的 treeshake 选项除了布尔值还可以传一个对象来精细控制摇树行为。这里挑几个实际中用得上的参数讲一讲。treeshake.moduleSideEffects可以覆盖模块的副作用判断默认值是true也就是所有模块默认可能有副作用。如果你非常确定某个依赖是无副作用的可以在构建配置里把它加入白名单treeshake: { moduleSideEffects: (id) { if (id.includes(pure-lib)) return false; return true; }, }treeshake.propertyReadSideEffects控制属性读取是否被视为有副作用默认true表示obj.prop这种读取可能触发 getter 因此被保留。如果项目里的属性访问都非常纯粹可以设为false能额外摇掉一批未使用的属性读取代码。但这里我要提醒如果代码里真的有用到 getter 做副作用的地方关了这选项就可能出问题。treeshake.tryCatchDeoptimization默认开启它会让 Rollup 在遇到 try/catch 时保守处理不排除任何可能抛错的代码。如果你确定代码的 try/catch 不会影响 tree shaking 判断可以关掉它来提升摇树率。这个参数名字比较冷门但我在处理某些大型第三方库时靠它挤出了几个百分点的体积值得记下来。5. 工具链配合Babel、TypeScript 和压缩器如何影响摇树Rollup 本身做得好不够构建链路里的其他工具如果配置不对同样能让 tree shaking 形同虚设。这里单独开一节讲工具链的配合因为这些坑在团队协作项目里非常常见而且报错方式不直观。5.1 Babel 的 modules 配置一旦转成 CommonJS 就前功尽弃使用 rollup/plugin-babel 时最经典的问题是 Babel 默认配置可能把 ES Module 转换成 CommonJS。一旦代码在编译阶段变成了require和module.exportsRollup 对这部分代码的 tree shaking 能力会直接大打折扣因为前面说过CJS 是运行时动态结构Rollup 根本没法静态分析它的导入导出关系。解决办法是在 Babel 配置里明确禁止转换模块语法。使用 babel/preset-env 时要设置modules: false{ presets: [ [babel/preset-env, { modules: false }] ] }如果你用的是 rollup/plugin-babel 且已经搭配了 preset-env这个配置尤其重要。我见过不少项目因为从 Webpack 迁移过来直接拷贝了原来的 Babel 配置结果 Rollup 构建产物完全绕过了 ESM 分析摇树等于零。排查这个问题最快的办法是看产物里还有没有require(或Object.defineProperty(exports这类代码有的话基本就是编译链路上把模块语法转掉了。5.2 TypeScript 产物对 tree shaking 的影响TypeScript 编译时默认保留import语法这本身对 Rollup 是友好的。但有几个细节非常容易踩坑。第一个是枚举enum的处理。TypeScript 的枚举编译产物里有反向映射逻辑比如数字枚举会生成类似var Status; (function (Status) {...})(Status || (Status {}))的代码这是一个 IIFE有副作用Rollup 通常没法在未被使用的情况下删掉它。所以你在写库的时候如果某个枚举只被少量地方用到优先考虑用字符串联合类型替代或者用as const对象加typeof的方式这样编译产物更纯净也更容易摇树。第二个是import type的使用。虽然这个更多影响编译速度和类型检查但在某些配置下非 type 的 import 也会被编译进产物如果这个模块本身没被真正使用就会白白占据体积。所以团队规范里应该要求类型导入必须写import type。第三点是命名空间namespace它的编译产物和枚举类似也是 IIFE摇树效果同样有限。5.3 压缩器是摇树的最后一道防线Rollup 完成 tree shaking 后产物通常还会交给压缩器做最终优化。市面上常用的有 Terser、esbuild、SWC 等。压缩器不仅做变量名压缩还承担了另一层死代码删除的工作——它会在代码级别删除那些“确实不会被执行的语句”比如永远为 false 的条件分支、未使用到的变量声明等。Terser 对纯注解的支持非常成熟/*#__PURE__*/注释在 Terser 压缩阶段会继续发挥作用。所以哪怕 Rollup 没能在生成阶段删掉的纯函数调用只要加了注解Terser 也可能补刀删掉。这里有个实践上的经验Rollup 和压缩器对副作用的判断标准并不完全一致Rollup 偏向模块级别的静态分析Terser 偏向代码块级别的执行流分析。两者配合往往能达到单靠一个工具达不到的压缩效果。举个实际体验我在一个项目里用rollup/plugin-terser作为压缩器配置了compress: { pure_getters: true }和toplevel: true这两个参数在摇树基础上又额外减少了大约 8% 的产物体积。当然这类激进配置要配合充分测试否则容易误删代码但这说明压缩器确实是最后一道值得花时间调的卡口。6. 实操案例一个工具库从 120KB 瘦身到 35KB 的过程前面的原理、配置都讲清楚了最后用一个我实际经手的工具库瘦身案例把整套方法论串起来。这个案例很有代表性因为它几乎覆盖了前面讲到的所有坑点。这个工具库原本用 Webpack 构建迁移到 Rollup 后第一次打包结果是 120KB。当时初步排除了代码量本身因为库里纯函数比较多理论上可摇树空间很大所以问题一定在某个环节把摇树挡死了。排查路径是这样的第一步检查构建产物里有没有 CommonJS 痕迹。我在产物里发现大量Object.defineProperty(exports, __esModule代码说明编译链路确实出了问题。检查后发现是 Babel 的 preset-env 没有设置modules: false导致所有 ESM 都被转成了 CJS。改掉之后体积降到了 78KB这是第一波收益。第二步处理 sideEffects 配置。这个库的 package.json 当时没有声明 sideEffects下游使用者没法放心摇树。我们自己构建时还好但发到 npm 之后别人用就会有大量冗余。于是我在 package.json 里加了白名单只保留了样式文件其余模块全部视为无副作用。这步操作对库的构建产物影响其实有限但对使用者影响巨大。第三步排查第三方依赖。库里依赖了一个老旧的日期处理库这个库只有 CommonJS 版本而且入口是module.exports { ...一堆方法 }的整体导出结构。这意味着 Rollup 只能拿到一个整体对象没法对里面的方法做树摇。最终我把这个依赖替换成了支持 ESM 且方法独立的日期库这一步直接把体积从 78KB 降到了 45KB。第四步处理库自身的写法和注解问题。我用可视化报告发现还有几个模块在产物里全量输出点进去一看是模块顶层有一段初始化函数调用返回值没被使用但确实有副作用嫌疑。给这些调用加上/*#__PURE__*/注解后体积又降到了 38KB。第五步调整压缩器配置。换用 rollup/plugin-terser 并激进地开启纯 getter 和 top-level 优化后最终体积稳定在 35KB 左右。整个过程中我没有改任何业务逻辑代码纯粹靠构建链路优化就把体积压缩了 70% 左右。这个案例给我最深的体会是tree shaking 不是单一工具的一个功能而是从源码写法、模块格式、依赖选择、编译配置、产物验证多个环节共同作用的结果。任何一个环节出现短板最终效果都会大打折扣。如果你现在正被打包体积困扰不妨从这几个维度逐项排查一遍应该能找出不少优化空间。