Rollup 的 Tree Shaking 原理:为什么有些代码无法被消除

深入解析Rollup的Tree Shaking原理,围绕ES Module静态结构与副作用标记,解释为何有些代码无法被消除;结合CommonJS、动态导入、环境分支等真实场景,给出工程化的检查步骤与优化建议,帮助你正确理解Rollup的打包边界。

很多团队在使用 Vite 或 Rollup 构建时,都会遇到一个相似的问题:明明只引入了一个组件,产物里却出现了整个库的代码。Tree Shaking 的核心思想很简单,但在真实项目里,总会碰到一部分代码无论如何都“摇不掉”。这往往不是因为 Rollup 没开启摇树,而是因为我们没有真正理解它的工作边界。

AI technology illustration

Rollup 的 Tree Shaking 原理,本质上依赖 ES Module 的静态结构。importexport 语句必须写在模块顶层,模块依赖在编译期就是确定的关系。这种限制让 Rollup 可以在构建阶段绘制出完整的模块依赖图,并标记哪些导出被引用、哪些导出是死代码。而 CommonJS 因为是运行时 resolve,很难做到同样程度的静态推断。

Tree Shaking 的基石:ES Module 的静态结构

用一个最简单的例子来理解。

// math.js
export function add(a, b) { return a + b; }
export function sub(a, b) { return a - b; }

// main.js
import { add } from './math.js';
console.log(add(1, 2));

Rollup 在解析 main.js 时,会顺着 import 找到 math.js。它发现 sub 从未被引用,因此最终产物里不会包含这个函数。这个过程并不依赖压缩器对代码做进一步分析,而是模块依赖图已经给出了答案。

但是,这个“有没有被引用”的判断,必须建立在编译器能静态确认的前提上。一旦代码里出现动态的模块访问、顶层调用或者运行时分支,Rollup 就只能在“可能有用”和“可能没用”之间选择后者。这正是下面这些“摇不掉”的代码产生的地方。

为什么有些代码无法被消除

我们遇到的大部分问题,其实都可以归因到四个字:静态失真。让 Rollup 失去精确判断能力的因素很多,常见的有以下几种。

模块副作用:先证明它没用,再删除它

Rollup 的默认行为是假设模块是纯的,但如果模块顶层存在无法被忽略的调用,它就必须假设模块执行会产生影响。比如下面的模块:

// module-with-effect.js
export const count = 1;
window.count = count;

即使你在入口只导入了 countwindow.count = count 也是一句有副作用的赋值。Rollup 无法确定这句赋值会不会影响其他代码,只能把整个模块保留。

很多项目在引入 polyfill、CSS 或者初始化脚本时,都依赖这种“模块副作用”。问题是,当一个库的每个组件都在顶层做了类似的事,Rollup 就只能把整个库都当成“有依赖存在”的代码,摇树自然失效。

CommonJS:无法静态追踪的模块边界

如果你使用 @rollup/plugin-commonjs 将 CommonJS 转换为 ESM,转换后的代码常常保留原始动态访问的痕迹。比如 exports[foo] = barmodule.exports = something 这类代码,会破坏 Rollup 对导出关系的推断。当 Rollup 无法列出这个模块到底导出哪些绑定,它只能让整个模块进入产物。

这也是为什么新的库都在尽量提供 ESM 版本的原因之一。CommonJS 本身并不是不能被 tree shake,但它需要作者遵循非常严格的书写模式,现实世界显然做不到。

动态导入和动态属性访问会扩大保留范围

import() 本身并不会让 Rollup 放弃摇树,它会按导入点生成 chunk,并在 chunk 内继续分析。真正棘手的是运行时属性访问。看下面这段代码:

import * as utils from './utils';
const fn = utils[methodName];
fn();

methodName 是什么,Rollup 在编译期无法知道。它只能假设 utils 的每个导出都可能被用到,因此所有导出都会被打进包。这不是 Rollup 的问题,而是静态分析面对运行时行为的必然保守。

没有被替换的环境分支,也会成为死代码

很多库会用 process.env.NODE_ENVtypeof window 做分支。如果 Rollup 没有通过插件把这些值替换成具体的字符串或布尔值,它就无法判断某个分支是否可达。最典型的是 if (process.env.NODE_ENV !== 'production') { ... },如果不注入 process.env.NODE_ENV,Rollup 会把这段代码保留,因为它不能假设环境变量一定等于什么。

为了更好地说明,我把常见场景整理成一张表。

场景 为什么无法摇掉 常见处理方式
模块顶层存在副作用 无法证明删除是否安全 移除副作用,或在 package.json 中声明 sideEffects
CommonJS 动态导出 无法静态分析导出集合 改用 ESM 版本,或配置 commonjs 插件
动态属性访问 运行时才能确认使用哪个导出 改为具名导入,或重构 API
环境变量分支 环境值未在构建期替换 使用 @rollup/plugin-replace 注入环境变量
装饰器/反射元数据 存在编译器无法感知的副作用 框架预编译或隔离元数据

一次真实的排查:为什么组件库被全量引入

我们团队在迁移 Vite 时,接入了一个自己维护的组件库。入口是标准的 ESM,组件都用具名导出。理论上,import { Button } from '@ui/button' 应该只打包 Button 相关代码。可当我打开产物分析,发现图标、表单、弹窗等没用到组件的代码全部躺在里面。

花了一天排查,才定位到原因。组件库的每个组件入口都会导入一个公共的 register.js,而这个文件在模块顶层执行了自定义事件监听和全局样式注入。Rollup 无法证明这些代码可以安全删除,于是把所有依赖了这个入口的组件都保留下来。同时,组件库的 package.json 没有声明 sideEffects,Rollup 只能默认每个模块都有副作用。

解决方案是在组件库的 package.json 里加上 sideEffects: false,并将带副作用的文件(如 CSS、全局注册脚本)单独列进白名单。修改后,产物体积下降了接近一半。这里的关键不是 Rollup 的配置魔法,而是库作者有没有尊重模块的“可静态分析边界”。

关于 Tree Shaking 的几个常见误解

在社区里经常能看到一些简单化的说法,很容易让人误判问题。

  • 只要用了 ES Module,tree shaking 就会生效。 实际上还需要模块本身没有副作用、构建工具配置正确、第三方库的 module 字段可用。一个环节被破坏,摇树就会失效。
  • sideEffects: false 是万能药。 如果模块真的有副作用,标 false 会让 Rollup 直接删掉它,导致运行时错误。它只适用于确实无副作用的纯模块。
  • 动态导入的 chunk 一定会比静态全部引入小。 如果动态导入的模块内部存在副作用或动态依赖,chunk 可能很大,甚至把整个依赖树都带进来。
  • tree shaking 会替代压缩器。 Tree Shaking 是模块级消除,压缩器负责函数内部的死代码消除,两者互相配合,不能互相替代。

在 Rollup 的边界内写出可摇树的代码

理解了原因,落地建议就清晰了。下面是我自己在项目中会检查的几条。

  • 源码保持 ESM。Babel 配置中设置 modules: false,避免把 ES Module 转成 CommonJS。
  • 确认第三方库的 module 字段存在,并且打包器没有解析到 main 字段。
  • 在 package.json 中准确声明 sideEffects。例如:
{
  "name": "@your/ui-lib",
  "sideEffects": [
    "**/*.css",
    "./src/register.js"
  ],
  "module": "dist/index.esm.js",
  "main": "dist/index.cjs.js"
}
  • 使用 @rollup/plugin-replace 注入 process.env.NODE_ENV,让条件分支变得更可预测。
  • 对于 CommonJS 依赖,尽量选择带 ESM 构建的包。无法避免时,用 namedExports 显式指定导出名,减少 Rollup 的猜测成本。
  • 建立体积监控。使用 rollup-plugin-visualizer 定期检查依赖占比,在版本升级时尤其重要。

Tree Shaking 不是一种事后优化,而是模块设计层面的约束。生产环境的高效产物,从库作者向打包器公开模块语义开始。

回到标题的问题:为什么有些代码无法被消除?因为它被不可静态分析的因素保护起来了。Rollup 的 Tree Shaking 原理并不复杂,难的是在真实项目中不断识别这些“保护因素”:副作用、CommonJS、动态访问、环境分支。理解这些边界,你才能在配置、库选型甚至在编写代码时做出更正确的取舍。下次当 Rollup 摇不动某段代码时,先不要急着加 Babel 插件,而是问一问:这段代码,真的可以被证明是“没用”的吗?

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/679/

(0)
上一篇 3小时前
下一篇 3小时前

相关推荐