背景
上篇文章我们介绍了 Rspack 的核心构建流程(Rspack 原理:webpack,我为什么不要你),了解到了 Rspack 并不是简单地使用 Rust 来重写 webpack,而是重新思考了“构建”这件事本身,在高性能和与 webpack 生态兼容之间是如何找到了平衡。
今天我们来聊聊对性能影响非常关键的 Tree-Shaking,使用 Rust 重写的 Tree-Shaking 是否在算法实现上和 webpack 不一致?最终的打包产物是否比 webpack 更小?我们一起通过源码一探究竟。
什么是 Tree-Shaking
我们先了解一下基本概念,Tree-Shaking 是一种 删除无用代码(Dead Code Elimination) 用于现代 JavaScript 打包过程中,移除那些被定义但从未被使用的模块、函数或变量,从而减小最终打包产物的体积。
我们可以把它比喻为摇一棵树:树上枯黄的叶子(即未被使用的代码)在摇动中自然脱落,而真正有用的枝叶(被引用的代码)则得以保留,这正是 Tree-Shaking 名称的由来。
Dead Code Elimination 的思想最早可追溯至 2009 年 Google 发布的 Closure Compiler,该工具通过静态分析移除无效代码、重写并压缩 JavaScript,以提升执行效率和加载性能,后来 Rich Harris(Svelte 框架的作者)将这一理念引入到了现代前端工程化体系中。
2015 年,他在开发打包工具 Rollup.js 时,首次提出了 Tree-Shaking 这一术语,并基于 ES Modules(ESM) 的静态结构特性实现了该技术。由于 ES 模块的 import 和 export 是静态声明,在编译时即可确定依赖关系,因此工具可以进行静态分析,识别出哪些导出未被使用,并在打包时将其删除。
直到 2016 年,webpack 2 引入了 Tree-Shaking 支持,使其迅速在前端社区普及。
如何开启 Tree-Shaking
在 Rspack 中开启 Tree-Shaking 其机制与 webpack 一样,都需要满足以下两个关键前置条件:
- 将 mode 设置为 production:Rspack 在
production模式下会自动启用一系列优化策略,包括压缩、代码分割和 Tree-Shaking。开发模式(development)出于调试便利考虑,通常不会进行彻底的死代码消除; - 使用 ES modules 语法(即 import 和 export):这是实现 Tree-Shaking 的核心前提。只有基于 ES Modules 的静态模块结构,构建工具才能在编译时进行静态分析,从而准确判断哪些代码未被引用,可以安全移除。
注意:如果使用 CommonJS(
require/module.exports),则无法启用 Tree-Shaking。因为 CommonJS 是动态模块系统,导入和导出可以在运行时动态决定(例如通过变量拼接require(moduleName)),这使得构建工具无法在打包阶段静态推断模块依赖关系。
Rspack 默认在 production 模式下已开启 Tree-Shaking,只要你使用的是 ESM 语法,即可自动享受优化,但你也可以通过 optimization 字段控制其行为:
// rspack.config.js
module.exports = {
mode: "production", // 必须为 production 才能启用完整优化
optimization: {
providedExports: true, // 分析每个模块的导出列表(如 export { a, b })
usedExports: true, // 标记哪些导出被实际使用,未使用的将在打包时被移除
sideEffects: true, // 启用副作用分析,用于判断模块是否可安全删除
innerGraph: true, // 启用细粒度的变量依赖追踪,提升 Tree-Shaking 精度
minimize: true, // 是否使用 optimization.minimizer 中声明的压缩器对产物进行压缩
minimizer: [], // 自定义压缩器。默认使用 SwcJsMinimizerRspackPlugin
...
},
};
各配置项详解:
- providedExports:分析模块中所有被
export声明的变量,构建导出清单; - usedExports:检查每个导出是否在其他模块中被
import引用。如果某个导出从未被使用,它将被标记为未使用,并在最终打包时删除; - sideEffects:判断模块是否有副作用(如执行全局配置等)。若模块无副作用且未被引用,整个模块可被完全移除。你也可以在
package.json中通过"sideEffects": false声明为无副作用的模块,这样 Rspack 就无需自行判断; - innerGraph:进一步追踪模块内部变量的引用链(例如:
export const a = b.c中b.c是否被使用),实现更细粒度的死代码消除,尤其对复杂表达式和链式调用有显著优化效果。
实现原理
刚才我们介绍了开启 Tree-Shaking 所需的关键配置项(如 providedExports、usedExports、sideEffects、innerGraph 等)。实际上,Rspack 中 Tree-Shaking 的实现机制正是围绕这些配置展开的,整个 Tree-Shaking 过程可以分为四个关键阶段:
解析模块导出
Tree-Shaking 的第一步是识别模块中哪些符号被导出。这一步发生在编译的 make 阶段,具体是在模块构建过程中调用的 parser_and_generator 方法中。在之前的文章我们介绍过 make 阶段调用 parser_and_generator 方法的流程,你可以查看 Rspack 原理:Rspack 原理:webpack,我为什么不要你 这篇文章。
为了便于理解 Tree-Shaking 在整个构建流程中的位置,我准备了如下流程图帮助你更好地理解:
在 make 阶段,Rspack 会通过 scan_dependencies 方法对模块的源代码进行依赖扫描。这个过程的核心是使用基于 SWC 的高性能 JavaScript Parser 构建抽象语法树(AST),然后通过一系列插件来识别 ESM(ECMAScript Module)语法中的导出语句,其中最关键的插件是:ESMExportDependencyParserPlugin。
ESMExportDependencyParserPlugin 的注册是在实例化 JavaScriptParser 的时候,该插件负责将不同的 ESM 导出语法转换为对应的 Dependency 对象,从而为后续的静态分析提供结构化数据支持。不同类型的导出语句会被映射为不同类型的 Dependency 实例。
规则如下:
- 当导出本地标识符(如
export { a as b })时,会生成ESMExportSpecifierDependency对象,用于记录导出名与本地变量的对应关系; - 当导出表达式或声明(如
export default function() {})时,会生成 ESMExportExpressionDependency 对象,用于描述默认导出的表达式节点; - 当导出来自其他模块的符号或整体导出(重导出(Re-export),如
export * from 'foo'或export { x } from 'foo')时,则创建ESMExportImportedSpecifierDependency对象以及配合ESMImportSideEffectDependency对象来建立跨模块引用,并标记可能存在的副作用。
特别说明:重导出(Re-export)的处理机制
以如下语句为例:
export * from './foo';
这条语句看似只是转发导出,但实际上它隐含了一个导入动作,并且可能触发副作用执行。Rspack 将其等价理解为:
import * as _foos from './foo';
export { ..._foos };
因此,在依赖图中,会同时创建两个 Dependency:
- ESMImportSideEffectDependency:表示我们导入了
'./foo'模块,并且需要考虑它的副作用; - ESMExportImportedSpecifierDependency:表示我们将来自
foo的某些或全部符号重新导出。
因此 ESMImportSideEffectDependency 对象就表示:我导入了 foo,并且它可能有副作用需要执行。
这意味着即使没有直接引用
foo中的任何变量,只要存在export * from 'foo',Rspack 就不会对foo模块进行 Tree-Shaking,除非明确标注sideEffects: false。
ESMExportDependencyParserPlugin 插件同时还会通过 InnerGraphPlugin::add_variable_usage 标记变量使用信息,追踪符号引用关系:
fn export_specifier(
&self,
parser: &mut JavascriptParser,
statement: ExportLocal,
local_id: &Atom,
export_name: &Atom,
export_name_span: Span,
) -> Option<bool> {
InnerGraphPlugin::add_variable_usage(
parser,
local_id,
InnerGraphMapUsage::Value(export_name.clone()),
);
...
}
理解起来有点抽象,这里涉及到 内部引用图(Inner Graph) 的概念,内部图(Inner Graph)状态的核心数据结构如下:
#[derive(Default)]
pub struct InnerGraphState {
pub(crate) inner_graph: HashMap<TopLevelSymbol, InnerGraphMapValue>,
pub(crate) usage_callback_map: HashMap<TopLevelSymbol, Vec<UsageCallback>>,
current_top_level_symbol: Option<TopLevelSymbol>,
enable: bool,
}
我们来介绍下 InnerGraphState 内部的几个字段:
- inner_graph:存储顶级符号到内部图映射值的映射关系,使用
FxHashMap高性能数据结构; - usage_callback_map:存储每个顶级符号的使用回调函数列表,用于跟踪符号的使用情况;
- current_top_level_symbol:当前正在处理的顶级符号,在解析过程中跟踪当前处理的符号;
- enable:是否启用内部图功能,控制是否进行内部图分析。
我们来举个例子解释下:
// test.js
export const a = 1;
export const b = 2;
const c = a + b;
export default c;
在这个 test.js 模块中:
a和b是命名导出,属于顶级符号;c是局部变量,但它是默认导出(default)的值;c的计算依赖于a和b。
现在假设外部模块只引用了这个模块的 default 导出:
import result from './test.js';
console.log(result); // 输出 3
在这种情况下,虽然 a 和 b 没有被直接引用,但由于 default 依赖它们,所以 a 和 b 不能被删除。InnerGraphPlugin 的工作就是追踪模块内部的变量的依赖关系,记录变量之间的引用关系,以上述代码为例,InnerGraphPlugin 会构建如下依赖图:
当后续进行标记使用阶段时,只要发现 default 被使用,就会沿着这张图反向追溯,将 c、a、b 全部标记为活跃(used),从而避免误删。
此功能对应配置项:innerGraph: true,启用内部引用图分析
收集模块导出信息
当所有模块完成 make 阶段的依赖扫描与 AST 解析后,模块依赖图(ModuleGraph) 已经基本构建完成。此时,每个模块不仅包含了自身的源码和资源信息,还通过 JavascriptParser 解析出了各种 ESM 导出语句,并将其转换为对应的 Dependency 对象(如 ESMExportSpecifierDependency 等)。
但这只是第一步,为了实现 Tree-Shaking,Rspack 需要弄清楚每模块到底导出了哪些符号?这些符号是否依赖于其他模块的导出?这就进入了 Tree-Shaking 流程的第二阶段:收集模块导出信息。
该阶段的核心任务是:
- 为每个模块生成一份结构化的导出描述(
ExportsInfo); - 建立跨模块的导出依赖关系图;
- 为后续的
usedExports分析和 Dead Code 删除提供数据基础。
这一过程主要由 FlagDependencyExportsPlugin 插件来完成,在 finish_modules 钩子中被触发执行:
impl Plugin for FlagDependencyExportsPlugin {
fn name(&self) -> &'static str {
"FlagDependencyExportsPlugin"
}
fn apply(&self, ctx: &mut rspack_core::ApplyContext<'_>) -> Result<()> {
ctx
.compilation_hooks
.finish_modules
.tap(finish_modules::new(self));
Ok(())
}
}
FlagDependencyExportsPlugin 会遍历当前模块的所有 Dependency,并对每一个与导出相关的 Dependency 调用其 .get_exports() 方法,获取一个 ExportsSpec 对象。
ExportsSpec的结构如下:
pub struct ExportsSpec {
pub exports: ExportsOfExportsSpec, // 导出哪些符号
pub priority: Option<u8>, // 优先级
pub can_mangle: Option<bool>, // 是否可压缩
pub terminal_binding: Option<bool>, // 是否终端绑定
pub from: Option<ModuleGraphConnection>, // 来源模块
pub dependencies: Option<Vec<ModuleIdentifier>>, // 依赖模块列表
pub hide_export: Option<FxHashSet<Atom>>, // 隐藏某些导出
pub exclude_exports: Option<FxHashSet<Atom>>, // 排除某些导出符号
}
举个例子,当你写导出语句 export const a = 1,生成的 Dependency 对象是ESMExportSpecifierDependency,对应的 ExportsSpec 是 { exports: ["a"], from: None }。
收集完所有 ExportsSpec 后,FlagDependencyExportsPlugin 将它们合并到一个统一的数据结构中:ExportsInfoData,ExportsInfoData 是挂载在 ModuleGraph 中每个模块上的元数据。
执行完 FlagDependencyExportsPlugin 后,每个模块在 ModuleGraph 中都拥有一份 ExportsInfoData,用于描述每个模块导出了什么以及从哪导出。
标记模块的使用情况
在完成收集模块导出信息阶段后,Rspack 已为每个模块构建了完整的 ExportsInfoData,明确了每个模块导出了什么以及这些导出是否依赖于其他模块。接下来,进入 Tree-Shaking 的关键阶段:标记模块使用情况,该阶段的目标是:
- 从入口模块出发,沿着依赖图(ModuleGraph)反向追踪;
- 识别哪些导出符号被实际使用(
used); - 将未被引用的导出标记为可删除。
这一过程发生在 seal 阶段,Rspack 会触发 optimize_dependencies 钩子,然后触发以下两个核心插件的执行:
- SideEffectsFlagPlugin:判断模块是否具有副作用,决定是否可被 Tree-Shaking。定义如下:
impl Plugin for SideEffectsFlagPlugin {
fn name(&self) -> &'static str {
"SideEffectsFlagPlugin"
}
fn apply(&self, ctx: &mut rspack_core::ApplyContext<'_>) -> Result<()> {
ctx
.normal_module_factory_hooks
.module
.tap(nmf_module::new(self));
ctx
.compilation_hooks
.optimize_dependencies
.tap(optimize_dependencies::new(self));
Ok(())
}
}
- FlagDependencyUsagePlugin:遍历依赖图,标记具体导出符号的使用状态。定义如下:
impl Plugin for FlagDependencyUsagePlugin {
fn apply(&self, ctx: &mut rspack_core::ApplyContext<'_>) -> Result<()> {
ctx
.compilation_hooks
.optimize_dependencies
.tap(optimize_dependencies::new(self));
Ok(())
}
}
SideEffectsFlagPlugin 遍历所有模块,对每个模块,查找其所属 package.json 中的sideEffects 配置,若配置允许(如 false),则标记模块为可摇树,否则标记为有副作用,即使导出未被使用,也必须保留整个模块。
此功能对应配置项:sideEffects: true,启用副作用分析,用于判断模块是否可安全删除
FlagDependencyUsagePlugin 从入口 entry 模块开始遍历 ModuleGraph,对每个模块调用get_exports_info 获取模块的 exports_info,然后遍历模块的 exports_info.get_exports所有导出,调用 get_referenced_exports() 获取该依赖引用了哪些导出。
然后调用 set_used_conditionally 方法,修改 exportInfo 的 used_in_runtime 属性,标记为具体的 UsageState 状态(如 Used、Unused 等)。
此功能对应配置项:usedExports: true,标记哪些导出被实际使用,未使用的将在打包时被移除
这一阶段完成后,Rspack 就具备了删除死代码的全部依据。接下来,进入最后一个阶段:代码生成和删除 Dead Code。
生成代码和删除 Dead Code
经过前面的收集与标记步骤后,Rspack 已经在 ModuleGraph 体系中清楚地记录了每个模块都导出了哪些值,以及每个导出值又被哪个模块所使用。
该阶段调用 generate 进行代码生成,Rspack 会根据导出值的使用情况生成不同的代码。
核心逻辑位于 DependencyTemplate.render 方法中(DependencyTemplate 是一个策略模式,不同的 Dependency 类型如 ESMExportSpecifierDependency、ESMImportSpecifierDependency 等都有对应的 Template 实现,每个 Template 的 render 方法负责将该依赖类型转换为具体的 JavaScript 代码,并根据使用状态决定生成正常代码还是死代码注释)。
该方法会读取 ModuleGraph 中存储的 exports_info,并通过调用 get_used 获取每个导出项在运行时的使用状态(UsageState 枚举),然后根据使用状态决定是否调用 get_used_name 来获取实际使用的名称。UsageState 枚举如下:
pub enum UsageState {
Unused = 0,
OnlyPropertiesUsed = 1,
NoInfo = 2,
Unknown = 3,
Used = 4,
}
解释一下部分字段:
- Used:导出值被完整使用,按正常逻辑生成代码;
- Unused:导出值未被使用,保留函数体或或生成注释占位(如
/* unused export / undefined、/ ESM default export /等),最后由 SWC 压缩清理。
基于上述状态,Rspack 为不同使用情况创建对应的 InitFragment 对象,并将其添加到 TemplateContext.init_fragments 数组中。随后通过 render_init_fragments 函数遍历该数组,将所有代码片段组合并渲染为最终的输出代码。
以上的流程理解起来有点抽象,我们抓住重点就行:Rspack 对于不同使用状态的导出会生成不同形式的代码,例如被标记为 Unused 的导出会生成注释形式的代码或保留函数体,然后依赖内置的 SwcJsMinimizerRspackPlugin(基于 SWC 的高性能压缩器)进行最终压缩清理。
举个例子,unused 函数为未使用的导出:
未开启 minimize: false,构建生成代码如下,可以看到,对于未使用的函数声明导出会保留函数体,而对于表达式导出会生成注释:ESM default export:
总结
到此为止,Rspack Tree-Shaking 的核心流程已介绍完毕。总结来说,Rspack 的 Tree-Shaking 实现机制与 webpack 在算法层面是高度一致的,均基于 ES Modules 的静态结构,通过以上四个关键阶段:解析导出、收集信息、标记使用、生成和删除代码。其核心逻辑依赖于 ModuleGraph 和 ExportsInfo,并以 sideEffects、usedExports、innerGraph 等配置项实现精准控制。
那么,Rust 真的让 Tree-Shaking 更彻底?答案是未必。Tree-Shaking 的彻底性本质上取决于静态分析的精度与策略,通过以上流程的分析发现,Rspack 并未引入全新的算法,而是继承并实现了与 webpack 相同的分析算法,因此在优化精度上与 webpack 相比是接近的。
但是,Rspack 的真正优势在于性能层面:更快的构建速度。例如 Rust 的 rayon 库支持并行,能在 make 阶段并行扫描多个模块的依赖,大大提升 providedExports 和 usedExports 的分析效率。还有 FxHashMap 高性能数据存储结构,支持更快的查找和更新。这些性能优势使得更细的静态分析(如 innerGraph 等)在中大型项目中得以高效运行,从而在构建中实现更可靠 Tree-Shaking。