Rspack 的 Tree Shaking 到底在做什么?
一个打包产物里的"幽灵代码"
你写了一个工具函数库,导出了 50 个函数,项目里只用到了其中 3 个。打包上线后,你打开产物一看——50 个函数全在里面。这不是假设,而是每天都在发生的事。Tree Shaking 就是解决这个问题的核心机制:让构建工具识别并移除那些从未被使用的代码。
Rspack 作为新一代构建工具,它的 Tree Shaking 实现沿用了 Webpack 的基本框架,但在执行细节和扩展能力上有自己的路径。理解它的工作原理,才能在配置和编码层面真正让"摇树"生效。
Tree Shaking 的根基:ESM 静态分析
Tree Shaking 之所以可行,根本原因在于 ES Modules 的静态结构特性。ESM 在编译阶段就能确定模块的导入导出关系,这和 CommonJS 有着本质区别:
import和export只能出现在文件顶层,不能写在if语句或函数体内- 导入的模块路径必须是字符串常量,不能是变量或表达式
export导出的是值的引用,而非值的拷贝
这三条约束意味着,构建工具不需要执行代码,仅通过静态扫描就能画出完整的模块依赖图,标注哪些导出被使用、哪些从未被引用。CommonJS 的 require() 可以在任意位置动态调用,模块路径也可以是变量,运行时才能确定依赖关系,这使得静态分析几乎不可能。
js// ESM — 编译时可确定依赖,Tree Shaking 可工作 import { formatDate } from './utils'; // CommonJS — 运行时才确定依赖,Tree Shaking 基本失效 const moduleName = condition ? './utilsA' : './utilsB'; const lib = require(moduleName);
Rspack 的三层 Tree Shaking 机制
Rspack 的 Tree Shaking 并非一步到位删除代码,而是分三个层级逐步收紧:
usedExports:标记并移除未使用的导出
这是最基础的一层。Rspack 在构建模块依赖图时,追踪每个模块的哪些 export 被其他模块实际 import 了。未被使用的导出会被标记,在代码生成阶段跳过对应的导出属性。
js// utils.js export const add = (a, b) => a + b; // 被 index.js 引用 export const subtract = (a, b) => a - b; // 未被引用 // index.js import { add } from './utils'; console.log(add(1, 2));
开启 optimization.usedExports 后,subtract 的导出会被移除。但注意,const subtract = ... 的声明本身还留在代码中,只是成了死代码——没有引用指向它。这层优化依赖后续的压缩工具来完成最终删除。
sideEffects:跳过整个无用模块
usedExports 处理的是模块内部的细粒度优化,sideEffects 则是模块级别的优化。当 Rspack 发现一个模块的所有导出都没有被使用,并且该模块被标记为无副作用时,整个模块(包括它的子依赖树)会被直接跳过,不进入打包流程。
配置方式是在 package.json 中声明:
json{ "sideEffects": false }
如果你的库中部分文件有副作用(比如 polyfill、全局 CSS 注入),可以用数组精确指定:
json{ "sideEffects": ["./src/polyfill.js", "*.css"] }
sideEffects 的优化效果远强于 usedExports,因为它能跳过整个子树。但前提是标记必须准确——如果一个有副作用的模块被错误地标记为 "sideEffects": false,它会被直接丢弃,导致运行时错误。
DCE:语句级死代码消除
前两层由 Rspack 自身完成标记和初步清理,最终的语句级消除由压缩工具(Terser 或 SWC Minifier)执行。Rspack 在 processAssets 阶段调用压缩器,压缩器识别 /* unused */ 标记和不可达代码,完成删除。
同时,Rspack 在解析阶段通过 ConstPlugin 做基本的死代码消除,比如 if (false) { ... } 这种编译期可判定的不可达分支。
Rspack 与 Webpack 的实现差异
当前版本的 Rspack 在 Tree Shaking 机制上与 Webpack 保持了高度一致,两者共享相同的优化策略和生产模式默认配置。但差异正在显现:
执行性能:Rspack 用 Rust 重写了模块图构建和依赖分析逻辑,在大规模项目里,标记未使用导出的速度显著快于 Webpack 的 JavaScript 实现。这对于拥有数千模块的 monorepo 场景尤为明显。
CSS Tree Shaking:Rspack 通过内置的 LightningCssMinimizerRspackPlugin 实现了 CSS Modules 的 Tree Shaking。该插件利用 Rspack 的 Tree Shaking 信息识别 JS 中未使用的 CSS 类名,交由 lightningcss 执行删除:
js// index.module.css .a { color: red; } .b { color: blue; } // 未在 JS 中引用 // index.js import styles from './index.module.css'; document.body.className = styles.a; // 产物中 .b 被移除,只保留 .a
Webpack 生态中要实现类似效果,需要借助 purgecss 等 PostCSS 插件,配置更复杂,且无法直接复用打包器的依赖分析结果。
实验性特性:Rspack v1.4 引入了 experiments.inlineConst 等实验性优化,进一步压缩常量内联空间。Rspack 团队也在探索更高效的 Tree Shaking 策略,未来可能偏离 Webpack 的实现路径。
sideEffects 配置的陷阱
sideEffects 是效果最强但也最容易出错的配置。核心原则:未在数组中列出的文件一律被视为无副作用,会被直接跳过。
常见问题:
IIFE 和全局赋值被丢弃。一个模块如果在导入时修改了全局对象(如 window.xxx = ...),但没有 export,标记为 "sideEffects": false 后这个模块会被整块删除。
Polyfill 消失。Polyfill 通常不导出任何东西,它的作用是修改原型链或全局对象。如果 polyfill 文件没有被列入 sideEffects 数组,它会在打包时被移除。
CSS 文件被忽略。通过 import './style.css' 引入的全局样式文件,虽然看似没有导出,但它们会注入 CSS 到页面。必须在 sideEffects 数组中包含 "*.css"。
json{ "sideEffects": ["./src/polyfill.js", "./src/init.js", "*.css"] }
innerGraph 与跨模块追踪
生产模式下,Rspack 默认开启 optimization.innerGraph。这个优化的作用是追踪模块内部变量的使用链路,即使依赖关系跨越多个模块,也能精确判断某个导出是否真正被使用。
js// math.js export const PI = 3.14159; export const calculateArea = (r) => PI * r * r; // index.js import { calculateArea } from './math'; console.log(calculateArea(5));
这里 calculateArea 内部依赖了 PI。如果 PI 没有被其他模块直接 import,没有 innerGraph 时 Rspack 可能无法确定 PI 是否可以通过 calculateArea 的引用链保留,有可能误标为未使用。开启 innerGraph 后,Rspack 会追踪到 calculateArea 内部对 PI 的引用,确保两者都正确保留。
/#PURE/ 注解:手动标记无副作用
对于立即执行函数调用或类实例化,Rspack 默认认为它们可能有副作用,不会移除。你可以用 /*#__PURE__*/ 注解明确告诉构建工具:这个调用没有副作用,如果返回值未被使用,可以安全删除。
js// 没有 PURE 注解 — Rspack 保留这个调用 const emitter = new EventEmitter(); // 有 PURE 注解 — 如果 emitter 未被使用,整个调用会被移除 const emitter = /*#__PURE__*/ new EventEmitter();
这个注解在第三方库的源码中很常见。Lodash-es、RxJS 等库在导出函数时大量使用 /*#__PURE__*/,确保未使用的工具函数能被 Tree Shaking 清除。
CommonJS 的兼容处理
Rspack 对 CommonJS 模块的 Tree Shaking 支持有限,这是由 CommonJS 的动态特性决定的。但 Rspack 并非完全放弃:
CJS 重新导出的部分场景:如果一个 CommonJS 模块通过 module.exports = { a, b } 导出,Rspack 在某些情况下可以分析出 a 和 b 两个导出,并对未被使用的那个做标记。但 Object.assign(module.exports, ...) 或动态属性赋值则无法分析。
Babel/SWC 转译陷阱:如果 Babel 或 SWC 将 ESM 语法转译为 CommonJS(import 变成 require),Tree Shaking 将失效。确保编译器的模块转换被关闭:
js// babel.config.js { "presets": [["@babel/preset-env", { "modules": false }]] } // rspack.config.js — SWC 默认保留 ESM,无需额外配置
混用场景:项目中 ESM 和 CommonJS 混用时,ESM 导入 CommonJS 模块会触发整模块打包——因为无法静态确定 CJS 模块导出了哪些成员。反之,CJS 引用 ESM 模块时,Rspack 会尝试做默认导出映射,但效果取决于具体的导出结构。
验证 Tree Shaking 是否生效
配置了 Tree Shaking 并不意味着它一定在起作用。你需要主动验证:
使用 Rspack Analyze 工具
bashrspack --analyze
这会生成一个可视化的产物分析报告,展示每个模块的大小和包含关系。如果某个应该被移除的模块仍然出现在产物中,说明 Tree Shaking 对该模块未生效。
检查 usedExports 标记
在配置中临时启用 optimization.usedExports: true 和 optimization.providedExports: true,Rspack 会在产物注释中标记未使用的导出:
js/* unused harmony export subtract */ const subtract = (a, b) => a - b;
如果在产物中看到 /* unused harmony export */ 注释但对应的代码仍存在,说明标记生效但压缩器未完成清理——检查 mode 是否为 production,压缩是否开启。
对比产物体积
最直接的方式:分别用 sideEffects: false 和 sideEffects: true 打包,对比产物大小。如果体积没有变化,要么项目本身已经足够精简,要么 sideEffects 配置没有正确识别到可移除的模块。
Tree Shaking 失效的常见原因清单
排查 Tree Shaking 失效时,按以下顺序检查:
- 模块格式不对 — 使用了 CommonJS 的
require/module.exports,Tree Shaking 无法静态分析 - Babel/SWC 将 ESM 转译为 CJS — 检查编译器配置,确保
modules选项为false - sideEffects 未配置或配置错误 — 未在
package.json中声明,或遗漏了有副作用的文件 - 压缩器未启用 —
mode不是production,或手动关闭了压缩 - 动态导入 —
import()是运行时行为,但动态导入的模块内部如果使用了 ESM 的静态导出,子模块仍然可以被 Tree Shaking - 对象或类的属性赋值 —
obj.method = function() {}这类动态属性扩展无法被静态分析 - 全局副作用代码 — 修改
window、document、Object.prototype等全局对象的代码,Rspack 必须保守保留 - 第三方库未标记 sideEffects — 很多旧版本的 npm 包没有
sideEffects字段,Rspack 只能假设它们都有副作用
写出可 Tree Shaking 的代码
理解原理的最终目的是指导实践。几个可操作的编码原则:
- 始终使用 ESM 的
import/export语法,避免require - 导出细粒度的命名导出,而非一个巨大的默认导出对象
- 在库的
package.json中准确声明sideEffects - 对确定无副作用的函数调用加上
/*#__PURE__*/ - 按需导入第三方库:
import { debounce } from 'lodash-es'而非import _ from 'lodash' - 将有副作用的代码(polyfill、全局初始化)独立为单独文件,列入
sideEffects数组
Tree Shaking 不是自动生效的魔法,它需要你在模块设计、依赖选择和构建配置上持续配合。Rspack 提供了和 Webpack 一致的分析能力以及更快的执行速度,但判断代码是否有副作用、是否可以被安全移除,仍然需要开发者自己把关。