Rspack Tree-Shaking 原理:Rust 让 Tree-Shaking 更彻底?

287 阅读14分钟

背景

上篇文章我们介绍了 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 模块的 importexport 是静态声明,在编译时即可确定依赖关系,因此工具可以进行静态分析,识别出哪些导出未被使用,并在打包时将其删除。

直到 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.cb.c是否被使用),实现更细粒度的死代码消除,尤其对复杂表达式和链式调用有显著优化效果。

实现原理

刚才我们介绍了开启 Tree-Shaking 所需的关键配置项(如 providedExportsusedExportssideEffectsinnerGraph 等)。实际上,Rspack 中 Tree-Shaking 的实现机制正是围绕这些配置展开的,整个 Tree-Shaking 过程可以分为四个关键阶段:

解析模块导出

Tree-Shaking 的第一步是识别模块中哪些符号被导出。这一步发生在编译的 make 阶段,具体是在模块构建过程中调用的 parser_and_generator 方法中。在之前的文章我们介绍过 make 阶段调用 parser_and_generator 方法的流程,你可以查看 Rspack 原理:Rspack 原理:webpack,我为什么不要你 这篇文章。

为了便于理解 Tree-Shaking 在整个构建流程中的位置,我准备了如下流程图帮助你更好地理解:

1.png

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 模块中:

  • ab 是命名导出,属于顶级符号;
  • c 是局部变量,但它是默认导出(default)的值;
  • c 的计算依赖于 ab

现在假设外部模块只引用了这个模块的 default 导出:

import result from './test.js';
console.log(result); // 输出 3

在这种情况下,虽然 ab 没有被直接引用,但由于 default 依赖它们,所以 ab 不能被删除。InnerGraphPlugin 的工作就是追踪模块内部的变量的依赖关系,记录变量之间的引用关系,以上述代码为例,InnerGraphPlugin 会构建如下依赖图:

2.png

当后续进行标记使用阶段时,只要发现 default 被使用,就会沿着这张图反向追溯,将 cab 全部标记为活跃(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 将它们合并到一个统一的数据结构中:ExportsInfoDataExportsInfoData 是挂载在 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 方法,修改 exportInfoused_in_runtime 属性,标记为具体的 UsageState 状态(如 UsedUnused 等)。

此功能对应配置项:usedExports: true,标记哪些导出被实际使用,未使用的将在打包时被移除

这一阶段完成后,Rspack 就具备了删除死代码的全部依据。接下来,进入最后一个阶段:代码生成和删除 Dead Code

生成代码和删除 Dead Code

经过前面的收集与标记步骤后,Rspack 已经在 ModuleGraph 体系中清楚地记录了每个模块都导出了哪些值,以及每个导出值又被哪个模块所使用。

该阶段调用 generate 进行代码生成,Rspack 会根据导出值的使用情况生成不同的代码。

核心逻辑位于 DependencyTemplate.render 方法中(DependencyTemplate 是一个策略模式,不同的 Dependency 类型如 ESMExportSpecifierDependencyESMImportSpecifierDependency 等都有对应的 Template 实现,每个 Templaterender 方法负责将该依赖类型转换为具体的 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 函数为未使用的导出:

image.png

未开启 minimize: false,构建生成代码如下,可以看到,对于未使用的函数声明导出会保留函数体,而对于表达式导出会生成注释:ESM default export

image.png

总结

到此为止,Rspack Tree-Shaking 的核心流程已介绍完毕。总结来说,Rspack 的 Tree-Shaking 实现机制与 webpack 在算法层面是高度一致的,均基于 ES Modules 的静态结构,通过以上四个关键阶段:解析导出、收集信息、标记使用、生成和删除代码。其核心逻辑依赖于 ModuleGraphExportsInfo,并以 sideEffectsusedExportsinnerGraph 等配置项实现精准控制。

那么,Rust 真的让 Tree-Shaking 更彻底?答案是未必。Tree-Shaking 的彻底性本质上取决于静态分析的精度与策略,通过以上流程的分析发现,Rspack 并未引入全新的算法,而是继承并实现了与 webpack 相同的分析算法,因此在优化精度上与 webpack 相比是接近的。

但是,Rspack 的真正优势在于性能层面:更快的构建速度。例如 Rust 的 rayon 库支持并行,能在 make 阶段并行扫描多个模块的依赖,大大提升 providedExportsusedExports 的分析效率。还有 FxHashMap 高性能数据存储结构,支持更快的查找和更新。这些性能优势使得更细的静态分析(如 innerGraph 等)在中大型项目中得以高效运行,从而在构建中实现更可靠 Tree-Shaking。