ARTICLE DETAIL

资讯详情

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

Rolldown 自动代码分割(Automatic Code Splitting)原理与模块分组行为全解析

Rolldown 自动代码分割(Automatic Code Splitting)原理与模块分组行为全解析 Rolldown 自动代码分割Automatic Code Splitting原理与模块分组行为全解析【免费下载链接】rolldownFast Rust bundler for JavaScript/TypeScript with Rollup-compatible API.项目地址: https://gitcode.com/GitHub_Trending/ro/rolldown自动代码分割Automatic Code Splitting是 Rolldown 把模块modules组织成输出块chunks的核心机制打包器根据模块之间的静态引用关系自动决定哪些模块应该合并在同一个 chunk 中、哪些模块应当被抽取成独立 chunk。本文以 Rolldown 官方文档《Automatic Code Splitting》为主线结合crates/rolldown/src/stages/generate_stage/code_splitting.rs等源码实现系统讲解Entry chunks入口块、Common chunks公共块的生成规则、模块放置顺序Module Placing Order以及strictExecutionOrder严格执行顺序方案帮助你理解 Rolldown 输出 chunk 结构的全部决策逻辑。读完本文你将能够准确预判任意模块图在 Rolldown 下会产出哪些 chunk理解为什么共享依赖没有被打进同一个公共块这类反直觉结论并掌握执行顺序与模块单例singleton约束冲突时的取舍原理。什么是自动代码分割自动代码分割是从模块创建 chunk的过程。它不可被用户直接控制而是遵循一套固定规则运行因此称为自动automatic与之相对的是由codeSplitting配置驱动的手动代码分割manual code splitting。注意自动与手动代码分割并不互斥。使用手动分割不会禁用自动分割——一个模块要么被自动分割捕获要么被手动分割捕获取决于你的配置没有被手动分割捕获的模块依然会遵循本文介绍的规则被放进自动分割创建的 chunk 中。自动代码分割会生成两种类型的 chunkEntry chunks入口块——由入口模块及其静态连通的模块构成Common chunks公共块——被多个入口共享的模块组成的独立 chunk。Entry chunks入口块Entry chunks由通过静态方式连接在一起的模块组合而成。所谓静态指的是静态import ... from ...或require(...)这类在代码加载时即可确定的引用关系。Entry chunks 又分为两种Initial chunks初始块由用户配置直接产生的入口块。例如input: [./a.js, ./b.js]就定义了两个 initial chunks。Dynamic chunks动态块由动态import()产生的入口块。动态导入用于按需加载代码因此 Rolldown不会把被动态导入的代码与导入方importer合并到同一个 chunk。一个入口 一个动态入口的例子考虑如下代码// entry.js (included in input option) import foo from ./foo.js; import(./dyn-entry.js); // dyn-entry.js require(./bar.js); // foo.js export default foo; // bar.js module.exports bar;这里存在两个静态连通的模块组组 1entry.js通过静态import连接foo.js——构成一个initial chunk组 2dyn-entry.js通过require()连接bar.js——构成一个dynamic chunk。由于存在两个分组自动代码分割最终会生成两个 chunk。entry.js与foo.js的静态引用关系要求它们始终一起加载因此被打包为初始块而dyn-entry.js是被按需加载的动态入口它和它的require()依赖被单独打包为动态块。从源码上看这一逻辑对应 code_splitting.rs 中的init_entry_point()方法它遍历link_output.entries一个FxIndexMapModuleIdx, VecEntryPoint为每个入口分配一个唯一的 bit 位并创建对应的ChunkKind::EntryPointchunk。无论用户定义的入口还是动态import()产生的入口都会被视作入口点——动态导入会创建新的加载边界因此被导入的模块需要自己的 chunk或被合并进已存在的 chunk。Common chunks公共块Common chunks在一个模块至少被两个不同的入口静态导入时产生。这些被共享的模块会被抽取到独立的 chunk 中。这个行为的目的有两点保证每个 JavaScript 模块在最终 bundle 输出中是单例singleton——同一份模块代码不会被复制到多个 chunk 中重复执行当一个入口被执行时只执行它导入的那些模块——避免入口因为打包策略而执行到本不该执行的代码。需要特别强调的是两个模块能否放进同一个公共块取决于它们是否被同一组入口导入。共享关系不同就会被分到不同的公共块。三个入口 三个共享模块的例子考虑如下代码// entry-a.js (included in input option) import shared-by-ab.js; import shared-by-abc.js; console.log(globalThis.value); // entry-b.js (included in input option) import shared-by-ab.js; import shared-by-bc.js; import shared-by-abc.js; console.log(globalThis.value); // entry-c.js (included in input option) import shared-by-bc.js; import shared-by-abc.js; console.log(globalThis.value); // shared-by-ab.js globalThis.value globalThis.value || []; globalThis.value.push(ab); // shared-by-bc.js globalThis.value globalThis.value || []; globalThis.value.push(bc); // shared-by-abc.js globalThis.value globalThis.value || []; globalThis.value.push(abc);自动代码分割将产出六个 chunk其中三个入口 chunk、三个公共 chunk// entry-a.js import ./common-ab.js; import ./common-abc.js;// entry-b.js import ./common-ab.js; import ./common-bc.js; import ./common-abc.js;// entry-c.js import ./common-bc.js; import ./common-abc.js;// common-ab.js globalThis.value globalThis.value || []; globalThis.value.push(ab);// common-bc.js globalThis.value globalThis.value || []; globalThis.value.push(bc);// common-abc.js globalThis.value globalThis.value || []; globalThis.value.push(abc);下面的图展示了各入口如何共享依赖、模块如何被分组成 chunkentry-*.jschunk 的生成原因上文已述。common-*.js是公共块它们被创建是因为common-ab.jsshared-by-ab.js被entry-a.js和entry-b.js共同导入common-bc.jsshared-by-bc.js被entry-b.js和entry-c.js共同导入common-abc.jsshared-by-abc.js被全部 3 个入口导入。为什么共享模块不被打进同一个公共块你可能会问既然三个模块都被共享为什么自动代码分割不把shared-by-*.js全部放进一个公共块答案是那样做会违背原始代码的意图。如果只创建一个公共块输出会是这样// common-all.js globalThis.value globalThis.value || []; globalThis.value.push(ab); globalThis.value globalThis.value || []; globalThis.value.push(bc); globalThis.value globalThis.value || []; globalThis.value.push(abc);对于这种输出无论执行哪个入口结果都是[ab, bc, abc]。但原始代码中每个入口的输出各不相同entry-a.js[ab, abc]entry-b.js[ab, bc, abc]entry-c.js[bc, abc]可以看到shared-by-ab.js与shared-by-bc.js的副作用向globalThis.value推入字符串分别属于不同的入口集合。把它们强行合并进一个 chunk会让entry-a.js在执行时顺带执行了它本不该加载的shared-by-bc.js从而破坏模块单例与只执行导入模块这两个核心保证。这正是 Rolldown 采用BitSet 可达性模型的根本原因。在 internal-docs/code-splitting/implementation.md 中有详细说明每个入口获得一个 bit 位置模块被标记为能到达它的入口集合具有相同可达性集合bits的模块才被归入同一个 chunk。该模型能保证零代码重复zero duplication与输出的确定性shared-by-ab.js: bits 110 (可被 entry-a、entry-b 到达) shared-by-bc.js: bits 011 (可被 entry-b、entry-c 到达) shared-by-abc.js: bits 111 (可被全部入口到达)因为三者 bits 互不相同它们必然被分成三个公共块。这一实现对应 code_splitting.rs 中的determine_reachable_modules_for_entry()从每个入口模块出发做 BFS为每个可达模块设置对应 bit与split_chunks()按 bits 模式查表归组bits_to_chunk[module.bits]不存在则新建ChunkKind::Commonchunk。Module Placing Order模块放置顺序Rolldown 会尽量按照原始代码中声明的顺序放置模块。例如// entry.js import { foo } from ./foo.js; console.log(foo); // foo.js export var foo foo;Rolldown 会模拟执行过程来计算顺序从入口开始foo.js必须先于entry.js执行因为entry.js依赖foo的初始化。因此模拟出的执行顺序是[foo.js, entry.js]bundle 输出为// output.js // foo.js var foo foo; // entry.js console.log(foo);在源码层面这一尽量保持声明顺序的目标体现在两个环节sort_chunk_modules()按模块的执行顺序exec_order对 chunk 内模块排序assign_chunk_exec_orders()见 code_splitting.rs为每个存活 chunk 分配渲染用exec_order使sorted_chunk_idx_vec与跨 chunk 导入排序都反映真实的求值顺序。其中 EntryPoint chunk 以入口模块的exec_order为键Common chunk 以modules[0]排序后exec_order最低的模块为键从而保证入口块先于公共块、静态块先于动态块。尊重执行顺序并不总是优先然而Rolldown 有时会不按原始顺序放置模块。这是因为保证模块是单例优先于按声明顺序放置模块。考虑如下代码// entry.js (included in input option) import ./setup.js; import ./execution.js; import(./dyn-entry.js); // setup.js globalThis.value hello, world; // execution.js console.log(globalThis.value); // dyn-entry.js import ./execution.js;bundle 输出将是// entry.js import ./common-execution.js; // setup.js globalThis.value hello, world;// dyn-entry.js import ./common-execution.js;// common-execution.js console.log(globalThis.value);common-execution.js是一个公共块因为execution.js同时被entry.js和dyn-entry.js导入这个例子暴露了一个问题打包前代码输出hello, world但打包后输出undefined。原因在于为了把execution.js抽成被两个入口共享的公共块entry.js中的执行顺序被破坏了——execution.js打印globalThis.value被移出entry.js的 chunk导致console.log(globalThis.value)先于setup.js的赋值执行。目前这个问题没有简单的解决方案——其他输出 ESM 的打包器如 esbuild、Rollup同样存在该问题。esbuild 的 issue #399 与 Rollup 的 issue #4539 均有相关讨论。解法strictExecutionOrder社区对这一问题有多种讨论其中一个方向是一旦模块的原始顺序会被破坏就把模块挪进额外的公共块——但这会导致输出碎片化fragment。Rolldown 选择了另一条路提供strictExecutionOrder选项包装wrapESM 模块体使它们能够按源码顺序执行同时保持 ESM 输出。其工作方式如下当被包装的动态入口与其实现 chunk 被其他代码共享时重写后的import()会直接触发该实现的初始化严格模式下仅当确实需要一个真实文件时才会输出一个小的入口 facadeentry facade——例如当另一个 chunk 静态加载该入口的 chunk或该 chunk 由插件pluginemit 时——因此输出 chunk 的形状仍可能发生变化默认情况下严格模式会包装每一个符合条件的模块实验性的onDemandWrapping模式则从预测的 chunk 执行风险中推导出一个保守的模块子集只对这些模块进行包装。从源码可以确认相关选项语义normalized_bundler_options.rs 中strict_execution_order默认值为falsestrict_execution_order: false并通过is_strict_execution_order_enabled()暴露is_strict_on_demand_wrapping_enabled()则要求strict_execution_order与experimental.onDemandWrapping同时开启。执行顺序包装的实现在order_wrapping.rs与order_analysis.rs中OrderAnalysis负责构建带每条理由的OrderWrapPlan严格模式默认从期望的执行顺序直接播种计划并跳过预测on-demand 模式会运行涌现环emergent-cycle不动点分析逐轮投影包装后的init_*转发边直到有风险的模块集合不再增长。分析完成后apply_order_wraps()把计划落地为包装器与拓扑编辑并通过create_order_wrap_entry_facades()/restore_order_wrap_entry_facades()决定哪些动态入口需要保留真实 facade 文件。也可以说开启strictExecutionOrder后ensure_lazy_module_initialization_order()负责把惰性初始化的require_xxx()调用转移到正确位置会被整体跳过因为包装计划已经接管了惰性初始化顺序的保证。建议如果因模块执行顺序或循环依赖而遇到输出问题可以考虑开启strictExecutionOrder: true该选项对 ESM 与 CJS 混合场景下的初始化顺序有系统性保证。补充自动分割的完整管线为了更透彻地理解上述规则这里补充自动代码分割在 Rolldown 内部的关键处理管线详见 internal-docs/code-splitting/implementation.mdgenerate_chunks() ├─ init_entry_point() 分配 bit 位创建入口 chunk ├─ split_chunks() │ ├─ determine_reachable_modules_for_entry() BFS 可达性标记 │ ├─ apply_manual_code_splitting() 用户定义的 chunk 分组 │ ├─ 模块归组按相同 BitSet → chunk │ ├─ ChunkOptimizer 安全时把公共块合并回入口块、移除空 facade │ └─ try_merge_runtime_chunk() 可选把独立 runtime 合并进安全宿主 └─ assign_chunk_exec_orders() 分配 chunk 执行顺序 → ChunkGraphBitSet 可达性与 esbuild/Rollup 同源的基础模型保证零重复、确定性输出复杂度约为 O(modules × entries/64)。其 trade-off 是入口很多且共享模式各异时可能产生许多小 chunk由 chunk optimizer 兜底合并。外部模块external在源头就被过滤永远不会进入link_output.entries因此 bit 位置与 chunk 索引严格一一对应。chunk 优化器合并公共块时严格拒绝会制造 chunk 间循环依赖would_create_circular_dependency()检查或改变入口导出签名preserveEntrySignatures: strict时的合并——这比 Rollup警告但允许循环更严格与 esbuild 强制静态 chunk 图无环的做法一致。小结自动代码分割是 Rolldown 输出结构的地基Entry chunks由静态连通import/require的模块组成分为用户配置产生的initial chunks与动态导入产生的dynamic chunksCommon chunks抽取被多个入口共享的模块分组依据是是否被同一组入口导入BitSet 可达性目的是保证模块单例与按需执行Module Placing Order默认模拟执行来尽量保持源码声明顺序但在模块单例约束冲突时会让位于后者strictExecutionOrder通过包装 ESM 模块体来在保持 ESM 输出的前提下恢复源码执行顺序onDemandWrapping是其实验性的保守子集模式。理解这套规则后你可以进一步阅读手动代码分割文档学习如何用codeSplitting配置groups、minSize、maxSize、includeDependenciesRecursively 等在自动分割之上做缓存失效与加载性能优化。延伸阅读手动代码分割指南代码分割实现文档内部核心实现code_splitting.rs执行顺序包装order_wrapping.rs选项定义normalized_bundler_options.rs【免费下载链接】rolldownFast Rust bundler for JavaScript/TypeScript with Rollup-compatible API.项目地址: https://gitcode.com/GitHub_Trending/ro/rolldown创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表