Rspack 源码解析(十八):Tree Shaking 如何用 SideEffects 重写模块连接

本篇是 Rspack 源码解析系列第十八篇。第六篇提到 exports 与 side effects 信息会被后续优化消费,第七篇又看到 OptimizeModulesPass 会驱动模块移除。本文进入 rspack_plugin_javascript/src/plugin/side_effects_flag_plugin.rs,看 tree shaking 如何在 optimize_dependencies 阶段重写模块图上的连接关系。

前言:tree shaking 不是删代码

很多人把 tree shaking 想象成"扫描 AST,把没 import 的函数删掉"。实际上 Rspack 的 tree shaking 更接近一次图优化:

text 复制代码
读 package.json 的 sideEffects
  -> 判断每个模块是否 side-effect-free
  -> 找 module graph 中可优化的连接
  -> 把连接直接指向真正的目标模块
  -> 跳过中间的 side-effect-free 模块

被跳过的模块因为不再被引用,后续代码生成阶段自然不会输出。这才是"摇树"的本质:先重写依赖关系,再让无用模块自然消失。

源码入口:SideEffectsFlagPlugin

这个插件注册在两个 Hook 上:

rust 复制代码
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(())
  }
}
  • normal_module_factory_hooks.module:模块创建时,把 sideEffects 标记写入模块的 FactoryMeta;
  • compilation_hooks.optimize_dependencies:依赖优化阶段,执行真正的连接重写。

第一步:从 package.json 读 sideEffects

nmf_module 这个 Hook 在模块创建时运行,负责读取 sideEffects 配置:

rust 复制代码
async fn nmf_module(
  &self,
  _data: &mut ModuleFactoryCreateData,
  create_data: &mut NormalModuleCreateData,
  module: &mut BoxModule,
) -> Result<()> {
  if let Some(has_side_effects) = create_data.side_effects {
    module.set_factory_meta(FactoryMeta {
      side_effect_free: Some(!has_side_effects),
    });
    return Ok(());
  }
  // ...
  let Some(side_effects) = SideEffects::from_description(description.json()) else {
    return Ok(());
  };
  let relative_path = resource_path.as_std_path().relative(package_path).assert_utf8();
  let has_side_effects = get_side_effects_from_package_json(side_effects, relative_path.as_path());
  module.set_factory_meta(FactoryMeta {
    side_effect_free: Some(!has_side_effects),
  });
  Ok(())
}

SideEffects::from_description 支持三种形态:

rust 复制代码
enum SideEffects {
  Bool(bool),
  String(String),
  Array(Vec<String>),
}

对应 package.json 里的:

json 复制代码
"sideEffects": false,
"sideEffects": "./src/side-effect.js",
"sideEffects": ["./src/polyfill.js", "*.css"]

第二步:把 sideEffects 转成连接状态

真正优化发生在 optimize_dependencies。它先为每个模块计算 ConnectionState:

rust 复制代码
let side_effects_state_map: IdentifierMap<ConnectionState> = module_graph
  .modules_par()
  .map(|(module_identifier, module)| {
    (
      *module_identifier,
      module.get_side_effects_connection_state(
        module_graph,
        &compilation.module_graph_cache_artifact,
        &mut Default::default(),
        &mut Default::default(),
      ),
    )
  })
  .collect();

ConnectionState::Active(false) 表示这个模块"active,但没有副作用"。只有这种模块才可能被跳过。modules_par 表明这一步是并行计算的。

第三步:结合 Mutation 做增量优化

tree shaking 不需要每次都全量扫描。它先看有没有 incremental Mutation:

rust 复制代码
let modules: IdentifierSet = if let Some(mutations) = compilation
  .incremental
  .mutations_read(IncrementalPasses::OPTIMIZE_DEPENDENCIES)
  && !side_effects_optimize_artifact.is_empty()
{
  side_effects_optimize_artifact.retain(|dependency_id, do_optimize| {
    // 只保留仍然存在的连接
  });
  // 从 mutation 推导受影响模块
  let modules: IdentifierSet = mutations.iter().fold(...);
  modules
} else {
  module_graph.modules_keys().copied().collect()
};

这是第十一篇讲的 Mutation 体系在 tree shaking 中的具体落地:文件变化后,只重新处理受影响的模块,而不是重跑全图。

第四步:找可优化的连接

can_optimize_connection 是核心判定函数,处理两类依赖:

ESM export 重导出

rust 复制代码
if let Some(dep) = dep.downcast_ref::<ESMExportImportedSpecifierDependency>()
  && let Some(name) = &dep.name
{
  // ...
  let target = can_move_target(&export_info, module_graph, exports_info_artifact, ...)?;
  // ...
  return Some(SideEffectsDoOptimize {
    ids: processed_ids,
    target_module: target.module,
    need_move_target,
  });
}

ESM import 具名导入

rust 复制代码
if let Some(dep) = dep.downcast_ref::<ESMImportSpecifierDependency>()
  && !dep.namespace_object_as_context
  && let ids = dep.get_ids(module_graph)
  && !ids.is_empty()
{
  // ...
  let Some(GetTargetResult::Target(target)) = get_target(...) else {
    return None;
  };
  // ...
}

关键条件是 side_effects_state_map[&target.module] == ConnectionState::Active(false)------只有当目标模块"无副作用"时,连接才可以被"穿透"。

第五步:do_update_module 直接改连接

找到可优化的连接后,do_optimize_connection 执行真正的重写:

rust 复制代码
fn do_optimize_connection(
  dependency: DependencyId,
  do_optimize: SideEffectsDoOptimize,
  module_graph: &mut ModuleGraph,
  exports_info_artifact: &mut ExportsInfoArtifact,
) -> (DependencyId, ModuleIdentifier) {
  let SideEffectsDoOptimize { ids, target_module, need_move_target } = do_optimize;
  module_graph.do_update_module(&dependency, &target_module);
  module_graph.set_dependency_extra_meta(
    dependency,
    DependencyExtraMeta {
      ids,
      explanation: Some("(skipped side-effect-free modules)"),
    },
  );
  // ...
}

do_update_module 把这根依赖直接指向 target_module,并在 DependencyExtraMeta 里留下 "(skipped side-effect-free modules)" 的说明。这样 stats 输出时用户能看到"这个引用跳过了哪些无副作用模块"。

第六步:循环直到稳定

优化不是一趟就结束的。跳过一层无副作用模块后,可能暴露出新的可优化连接,所以要循环:

rust 复制代码
let mut do_optimizes = side_effects_optimize_artifact.clone();
let mut do_optimized_count = 0;
while !do_optimizes.is_empty() {
  do_optimized_count += do_optimizes.len();
  // 执行一批 do_optimize_connection
  // 从新连接里再找出可继续优化的
  do_optimizes = new_connections
    .into_par_iter()
    .filter(...)
    .filter_map(...)
    .collect();
}

这与第七篇讲的"Some(true) 表示未稳定、需要重跑"是同一个思想:图优化要迭代到不动点。

一个具体例子

假设有:

text 复制代码
index.js
  import { Button } from './components'
  -> components/index.js 只做 re-export,sideEffects: false
  -> components/Button.js

优化前:

text 复制代码
index.js --依赖--> components/index.js --依赖--> components/Button.js

优化时,components/index.js 是 Active(false),连接可以被穿透:

text 复制代码
index.js --依赖--> components/Button.js (skipped components/index.js)

components/index.js 不再被任何模块引用,最终从产物里消失。这就是为什么 tree shaking 常常能"删掉"一整个 barrel 文件。

sideEffects 与 usedExports 的关系

sideEffects 回答"这个模块能不能被整体跳过",usedExports 回答"这个模块内部哪些导出被使用"。两者配合:

text 复制代码
sideEffects: false
  -> 模块整体无副作用,整模块可跳过

usedExports
  -> 模块有副作用,但可以只保留被使用的导出

在 splitChunks 里看到的 usedExports 分组(第十七篇)也依赖这里算好的 exports info。两者共享 ExportsInfoArtifact。

Rust 角度:把"删代码"降维成"改图"

这个插件最值得学习的地方,是它没有直接删任何 AST 节点。它做的只是:

text 复制代码
计算连接状态
  -> 判断连接能否穿透
  -> 重写依赖的指向
  -> 留下去重说明

真正"代码消失"发生在后续 code generation 阶段------因为模块不再被引用,就不会被渲染进 chunk。把"优化"拆成"图重写 + 自然淘汰",比在 AST 上边删边遍历要安全得多,也更容易做增量。

这一篇应该带走什么

  1. tree shaking 的本质是重写 ModuleGraph 连接,而不是直接删 AST;
  2. SideEffectsFlagPlugin 在模块创建时读 sideEffects,在 optimize_dependencies 时改连接;
  3. 只有 ConnectionState::Active(false) 的模块连接才能被穿透;
  4. do_update_module 直接改依赖指向,并留下 "(skipped side-effect-free modules)" 说明;
  5. 优化循环迭代到不动点,并利用 Mutation 做增量失效。

写在最后

side effects 优化解决的是"模块之间的引用能不能缩短"。但还有一类更激进的优化:把多个模块合并成一个 ConcatenatedModule,消除模块之间的运行时调用开销。这就是 scope hoisting。下一篇我们进入 rspack_core/src/concatenated_module.rs,看模块拼接如何保持统一的 Module 抽象。

相关推荐
百度一下吧1 小时前
前端包管理工具和使用手册
前端
anxiao_m2 小时前
水利数字孪生怎么选?主流可视化渲染平台深度横向测评
大数据·前端·人工智能·图形渲染·云渲染
kill5222 小时前
useRef
前端·javascript·react.js
蓝悦无人机2 小时前
从“摸形状“到“点到线面的距离“——聊聊 SLAM 前端(激光雷达篇)
前端·loam·数据关联·激光slam·点云特征提取·点到线面距离·icp/ndt
flash俊杰2 小时前
工程约束怎么不拖后腿
前端
霍营_托尼3 小时前
纯前端播 40GB 本地视频?我把浏览器改造成了「磁盘流式」播放器
前端·javascript·音视频开发
溪语流沙3 小时前
【Web全栈进阶】FastAPI工程化:APIRouter拆分 + 配置 + 依赖注入
java·前端·fastapi
Shirley~~3 小时前
Three.js的基础概念
前端·3d
deli0073 小时前
黏菌没有大脑,3000 个 Physarum 粒子跑 12973 步自己铺出了迷宫最短路
前端