ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Rolldown 模块执行顺序(Module Execution Order)实现解析:sort_modules 的确定性排序算法与全链路应用

Rolldown 模块执行顺序(Module Execution Order)实现解析:sort_modules 的确定性排序算法与全链路应用 Rolldown 模块执行顺序Module Execution Order实现解析sort_modules 的确定性排序算法与全链路应用【免费下载链接】rolldownFast Rust bundler for JavaScript/TypeScript with Rollup-compatible API.项目地址: https://gitcode.com/GitHub_Trending/ro/rolldown本文深入解析 RolldownRust 编写的 JavaScript/TypeScript 打包器API 兼容 Rollup中模块执行顺序的底层实现。文章以internal-docs/linking/module-execution-order/implementation.md为核心骨架结合crates/rolldown/src/stages/link_stage/sort_modules.rs的源码系统讲解sort_modules如何为模块图中的每个模块生成确定性的、尊重依赖关系的执行顺序以及exec_order字段如何贯穿 chunk 组装、跨 chunk 链接计算、渲染等下游阶段。读完本文你将理解 Rolldown 保证输出确定性的核心机制掌握其排序规则、迭代式后序 DFS 算法、循环依赖检测原理以及它对整个打包器可复现输出的关键意义。概述为什么模块执行顺序是打包器的先行信号在 Rolldown 的多阶段流水线中链接link阶段负责把扫描阶段解析出的模块图缝合成可执行、可输出的形态。而模块执行顺序正是链接阶段要解决的第一个问题哪些模块必须先执行、哪些模块可以后执行直接决定了生成的 chunk 内模块的排列、跨 chunk 的依赖边、甚至最终渲染出的代码文本顺序。sort_modules就是承担这一职责的 pass。它运行在链接阶段的第一步为模块图中的每个模块分配一个单调递增的exec_order: u32并把结果写入LinkStageOutput::sorted_modules一个模块索引序列。下游的 chunk 组装、跨 chunk 链接计算、渲染等阶段都把这份顺序当作谁先谁后的权威依据——它是在已解析的模块图与打包器做出的每一个排序决策之间的桥梁。源码位置sort_modules.rs、link_stage/mod.rs从 LinkStage::link 的调用序列可以确认sort_modules是链接阶段管线中的第一个步骤pub fn link(mut self) - (LinkStageOutput, IndexEcmaAst, UsedSymbolRefsBuilder) { self.sort_modules(); // 第一步确定模块执行顺序 self.compute_tla(); self.determine_module_exports_kind(); // ... 后续多个 pass self.patch_module_dependencies(); }正因如此任何对排序遍历规则的改动都会表现为整个打包器可观察的输出变化——这正是sort_modules必须最先运行的原因它必须在任何读取exec_order字段的阶段之前完成。排序规则六条按优先级排列的保证sort_modules产生的顺序由一组规则定义按优先级从高到低排列。理解这组规则是理解整个算法的基础。1. 运行时模块永远排第一sorted_modules[0] runtime.id()在 pass 结束时通过debug_assert_eq!断言强制保证。原因是运行时模块中注入了生成式辅助函数如__commonJS、__toESM等这些 helper必须先于任何引用它们的模块被定义。源码中对应断言位于 sort_modules.rsself.sorted_modules sorted_modules; debug_assert_eq!( self.sorted_modules.first().copied(), Some(self.runtime.id()), runtime module should always be the first module in the sorted modules );这也与文档 runtime-helpers 实现说明 中运行时模块的定位相呼应——本 pass 正是为它提供永远第一保证的那一环。2. 用户定义入口按声明顺序执行入口在options.input中出现的顺序会被原样保留。而非用户入口动态导入入口和通过this.emitFile发射的入口在LinkStage::new中已被提前规范化按(entry.kind, module.id().as_str())排序见 link_stage/mod.rs// We need to preserve the original order of user defined entry points. let mut rest scan_stage_output .entry_points .extract_if(0.., |item| !matches!(item.kind, EntryPointKind::UserDefined)) .collect_vec(); rest.sort_by_cached_key(|item| { (item.kind, scan_stage_output.module_table.modules[item.idx].id().as_str()) });也就是说sort_modules本身不做任何入口重排序它消费的正是这份用户入口保序 非用户入口已规范化的排序后缀。文档还特别指出sort_modules源码注释中的Module#debug_id措辞已经过时权威排序键是模块 id 字符串module.id().as_str()。3. 依赖先于依赖者沿无环边环是唯一例外对于sort_modules遍历到的任意非回边A → B必然满足B.exec_order A.exec_order。但在循环中这条规则不成立当算法重新访问一个已经执行过的祖先时它会直接跳过这正是循环检测触发的位置因此环上的模块可能在其环内更深层的传递依赖之前就拿到了exec_order。下游阶段不能假设环上存在严格的拓扑序。4.require(...)被当作静态导入处理由于 ESimport语句会被提升hoistedrequire的模块被放置在静态导入之后。在多个require调用之间则以 AST 扫描时第一个出现者优先。文档给出的示例() require(b); require(c); import a;对应的执行顺序为a → b → c——静态导入a先执行然后按扫描顺序依次是b、c。这一语义在源码注释中有完整记录见 sort_modules.rs。5. 动态导入默认被跳过动态导入只在code_splitting被禁用即内联动态导入模式时才参与排序否则它们会成为独立的 chunk 根其子图会从各自的入口状态被单独遍历。这与 LinkStage::new 中 metas.dependencies 的过滤逻辑 一脉相承ImportKind::DynamicImport | ImportKind::Require | ImportKind::HotAccept在默认情况下都不作为普通依赖边参与链接元数据。6. 顺序是相对的不是全局的sort_modules只保证沿遍历到的边依赖先于依赖者并不承诺跨互不相关的子树存在某种规范的全序——最终顺序是以入口为根进行 DFS 遍历的函数。这意味着两个互不依赖的子树谁先谁后取决于入口声明顺序与遍历方向而非模块 id 或任何全局指标。算法迭代式后序 DFS 与双状态栈sort_modules的核心是一个迭代式后序 DFS使用显式的execution_stack: VecStatus而非递归。这样做的直接动机是真实代码库中模块图可能非常深递归容易导致栈溢出显式堆栈则完全没有这个顾虑。双状态栈条目ToBeExecuted / WaitForExitStatus枚举定义了两个状态见 sort_modules.rsenum Status { ToBeExecuted(ModuleIdx), // 前序需要先把它的依赖压栈 WaitForExit(ModuleIdx), // 后序依赖已完成分配 exec_order }一个ModuleIdx第一次真正访问时会经历ToBeExecuted → WaitForExit这对状态转换以ToBeExecuted弹出前序压入它自己的WaitForExit后序哨兵再把它的依赖压到哨兵之上。当哨兵弹出时所有传递依赖都已被分配了更小的exec_order——这等价于从递归调用返回后分配顺序的迭代实现。而来自其它入边的重复ToBeExecuted(id)压栈例如菱形依赖中的第二个 importer会被弹出后通过executed_ids成员检查短路跳过不再重新进入前后序对。栈的种子.rev()chain一举钉死规则 1 和 2初始化栈的代码极其精妙见 sort_modules.rslet mut execution_stack self .entries .keys() .rev() .map(|module_idx| Status::ToBeExecuted(module_idx)) .chain(iter::once(Status::ToBeExecuted(self.runtime.id()))) .collect::Vec_();入口反向压栈运行时模块最后压栈——因为Vec栈从尾部弹出这正好使运行时模块第一个被弹出入口则按原声明顺序依次弹出。这一行.rev() chain的组合就是上文规则1运行时永远第一和规则2入口按声明顺序执行的算法根源。访问依赖静态过滤 反向压栈保持源码顺序在ToBeExecuted(id)分支中见 sort_modules.rs若id已在executed_ids中直接跳过可能触发循环诊断见下节否则把id插入executed_ids压入WaitForExit(id)哨兵然后对该模块的 import records 做过滤后反向压栈execution_stack.extend( self.module_table[id] .import_records() .iter() .filter(|rec| { rec.kind.is_static() || (self.options.code_splitting.is_disabled() rec.kind.is_dynamic()) }) .filter_map(|rec| rec.resolved_module) .rev() .map(Status::ToBeExecuted), );过滤条件正是规则4与规则5的体现静态导入永远参与动态导入仅在code_splitting禁用时参与。import_records()返回的本来就是源码顺序.rev()压栈后经栈的 LIFO 反转恰好恢复源码顺序。在WaitForExit(id)分支见 sort_modules.rs中若为Module::Normal则debug_assert_eq!(module.exec_order, u32::MAX)验证尚未赋值捕获重复排序或绕过遍历的 bug赋exec_order next_exec_order并 push 进sorted_modules若为Module::External同样赋exec_order但不会进入sorted_modules外部模块不进 chunk 管线只有 normal 模块会被发射计数器next_exec_order递增并移除stack_indexes_of_executing_id中的簿记条目。循环依赖检测回边 vs 交叉边循环依赖只在用户通过options.checks.contains(EventKindSwitcher::CircularDependency)显式开启时才报告属于机会式检测。关键簿记是stack_indexes_of_executing_id: FxHashMapModuleIdx, usize它记录每个模块的WaitForExit哨兵在栈上的活位置。当ToBeExecuted(id)遇到已执行的id时若stack_indexes_of_executing_id中仍有id的条目说明我们正深处 DFS 处理该模块——这是回边。通过从该索引切片到栈顶收集沿途所有WaitForExit变体即得到活动 DFS 链上的模块构成一个环若id已执行但不在 map 中说明它早已完成——这是交叉边不构成环。环通过FxHashSetBox[ModuleIdx]去重最后在 pass 结束时以BuildDiagnostic::circular_dependency发出且以警告而非错误形式出现见 sort_modules.rsif !circular_dependencies.is_empty() { for cycle in circular_dependencies { let paths cycle .iter() .filter_map(|id| self.module_table[*id].as_normal().map(|module| module.id.to_string())) .collect::Vec_(); self.diagnostics.push(BuildDiagnostic::circular_dependency(paths).with_severity_warning()); } }复杂度O(N E) 总工作量每个模块恰好有一次真正访问——一次WaitForExit压栈、一次exec_order赋值。相比之下ToBeExecuted压栈是按边计数的模块每有一条入边静态导入、code splitting 禁用时的保留动态导入、或来自入口列表/运行时模块的初始种子就被压一次除首次外的压栈都会被executed_ids成员检查廉价地弹出并跳过。例如菱形依赖A → C, B → C中C 会收到两次ToBeExecuted压栈和一次WaitForExit压栈。总工作量 O(N) 次真实访问 O(E) 次依赖压栈与跳过 O(N E)配合常数时间的哈希集合成员检查。FxHashSet::with_capacity(module_count)预分配容量避免executed_ids在插入过程中的 rehash。不变量算法正确性的断言护栏sort_modules通过一组debug_assert在调试构建中守护自身的不变量见 sort_modules.rs 及文档module.exec_order u32::MAXpass 之前在赋值处用debug_assert!断言捕获双重排序和被绕过遍历的 bugsorted_modules.first() Some(runtime.id())pass 结束时运行时模块永远第一WaitForExit并发唯一性一个模块同一时刻至多有一个WaitForExit在栈上插入前断言stack_indexes_of_executing_id.contains_key(id)为 falsesorted_modules覆盖所有可达 normal 模块从入口或运行时可达的每个Module::Normal都会出现在sorted_modules中外部模块只拿exec_order不进列表。下游消费者exec_order 贯穿整个打包链路exec_order与sorted_modules被多处消费文档给出的消费者矩阵与当前仓库源码一一对应消费者用途generate_stage/code_splitting.rs遍历sorted_modules将模块分配到 chunk比较exec_order以确定性排序入口 chunk 与公共 chunkchunk_graph.rs按exec_order对 chunk 内模块排序也用作无副作用叶子分组后的决胜键generate_stage/compute_cross_chunk_links.rs排序跨 chunk 导入保证输出稳定generate_stage/manual_code_splitting.rs以执行顺序作为用户自定义 chunk 分组的稳定种子ecmascript/ecma_generator.rs将exec_order带到渲染阶段保证输出模块序列确定stages/link_stage/cross_module_optimization.rs遍历sorted_modules实现对整个模块图的确定性迭代以下是几个关键消费点的源码佐证chunk 组装。在 code_splitting.rs 中assign_chunk_exec_orders依据各 chunk 首模块lead module的执行顺序为每个存活的 chunk 分配渲染期exec_order——Commonchunk 以modules[0]即exec_order最低的成员为键Entry与Commonchunk 的相对顺序比较也落在exec_order上let a_module_exec_order self.link_output.module_table[*a_module_id].exec_order(); let b_chunk_first_module_exec_order self.link_output.module_table[b.modules[0]].exec_order();chunk 内模块排序。在 chunk_graph.rs 中chunk 内的模块同样按exec_order排序chunk.modules.sort_unstable_by_key(|idx| link_output.module_table[*idx].exec_order());跨 chunk 链接。在 compute_cross_chunk_links.rs 中跨 chunk 导入按 chunk 的exec_order排序保证输出稳定。渲染阶段。在 ecma_generator.rs 中RenderedModuleSource::new(m.idx, m.id.clone(), m.exec_order, render.sources)将exec_order直接带入渲染数据结构最终进入输出模块序列。此外 esm_init_obligations.rs 也以exec_order对 ESM 初始化义务排序确保 chunk 间初始化代码的确定性。因为如此多的下游阶段都以exec_order为键任何对遍历规则的改动都会造成整个打包器可观察的输出变化——这正是sort_modules必须位于 LinkStage::link 第一步的根本原因。未决问题与设计权衡文档明确指出了一个已知的取舍循环报告粒度。每个进入 SCC 的入口点只报告一个环——通过不同路径共享同一条回边的多个环可能被合并折叠。如果用户需要完整的 SCC 报告Tarjan 算法才是正确的工具但它更重、且极少被需要。当前的机会式回边检测以 O(N E) 的开销换来了足够实用的诊断信息同时保持打包主路径的轻量。相关资源sort_modules.rs —— 本文核心算法的完整实现link_stage/mod.rs ——LinkStage::link管线顺序与入口规范化逻辑code-splitting 实现说明 —— 消费exec_order进行 chunk 组装runtime-helpers 实现说明 —— 由本 pass 提供永远第一保证的运行时模块chunk_graph.rs —— chunk 内模块的exec_order排序code_splitting.rs —— chunk 级执行顺序的赋值与比较小结sort_modules以一段紧凑的迭代式后序 DFS用运行时第一、入口保序、依赖先于依赖者、require 视为静态导入、动态导入默认跳过六条规则为整个链接阶段乃至生成阶段提供了确定性的执行顺序基准。其显式双状态栈设计规避了深模块图的递归栈溢出风险O(N E) 的复杂度保证了大型项目的可扩展性而贯穿 chunk 组装、跨 chunk 链接与渲染的exec_order消费链则让确定性输出这一打包器的核心承诺从链接阶段的第一步一直落实到最终产物。对于任何想要修改或扩展 Rolldown 链接逻辑的开发者而言理解sort_modules就是理解 Rolldown 输出可复现性的第一块基石。【免费下载链接】rolldownFast Rust bundler for JavaScript/TypeScript with Rollup-compatible API.项目地址: https://gitcode.com/GitHub_Trending/ro/rolldown创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表