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

Rollup 的 Tree Shaking 原理,本质上依赖 ES Module 的静态结构。import、export 语句必须写在模块顶层,模块依赖在编译期就是确定的关系。这种限制让 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;
即使你在入口只导入了 count,window.count = count 也是一句有副作用的赋值。Rollup 无法确定这句赋值会不会影响其他代码,只能把整个模块保留。
很多项目在引入 polyfill、CSS 或者初始化脚本时,都依赖这种“模块副作用”。问题是,当一个库的每个组件都在顶层做了类似的事,Rollup 就只能把整个库都当成“有依赖存在”的代码,摇树自然失效。
CommonJS:无法静态追踪的模块边界
如果你使用 @rollup/plugin-commonjs 将 CommonJS 转换为 ESM,转换后的代码常常保留原始动态访问的痕迹。比如 exports[foo] = bar、module.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_ENV 或 typeof 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/