本篇是 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 上边删边遍历要安全得多,也更容易做增量。
这一篇应该带走什么
- tree shaking 的本质是重写 ModuleGraph 连接,而不是直接删 AST;
SideEffectsFlagPlugin在模块创建时读 sideEffects,在 optimize_dependencies 时改连接;- 只有
ConnectionState::Active(false)的模块连接才能被穿透; do_update_module直接改依赖指向,并留下"(skipped side-effect-free modules)"说明;- 优化循环迭代到不动点,并利用 Mutation 做增量失效。
写在最后
side effects 优化解决的是"模块之间的引用能不能缩短"。但还有一类更激进的优化:把多个模块合并成一个 ConcatenatedModule,消除模块之间的运行时调用开销。这就是 scope hoisting。下一篇我们进入 rspack_core/src/concatenated_module.rs,看模块拼接如何保持统一的 Module 抽象。