原文链接:www.atriiy.dev/blog/rolldo…
作者:Atriiy
引言
Rolldown 是一款高性能 JavaScript 打包器,Vite 计划将其整合为未来的默认方案。打包过程包含三个主要阶段:模块扫描、符号链接和最终代码生成。我已撰写两篇文章详细阐述前两个阶段,您可在此查阅:
完成上述阶段后,我们已获得项目精确的"映射图"——涵盖从高阶模块图到底层符号关系的完整结构。在最终阶段,我们将利用这些信息生成实际打包输出。该阶段直接影响应用程序的加载速度与打包体积。
代码生成策略
Rolldown采用两种主要代码生成策略:保留模式与常规模式。在保留模式下,打包器为每个模块创建独立代码块,并保留原始模块名称作为文件名。这种相对简单的1:1映射常用于库分发。相比之下,常规模式才是核心复杂性所在——Rolldown在此将模块图转换为一系列优化后的代码块及合并代码块。
本文将重点探讨驱动高性能生成的代码拆分算法。最后我们将深入后处理阶段,解析Rolldown如何对输出结果实施最终的结构优化。
生成chunks
本节重点阐述片段生成阶段的核心逻辑。由于完整的生成过程涉及众多动态组件——例如命名空间处理、片段链接及封装器——我们将聚焦于chunk生成算法本身。
该函数的基本结构清晰明了:首先初始化若干关键数据结构:
let mut chunk_graph = ChunkGraph::new(self.link_output.module_table.modules.len());
// BitSet for each module
let mut index_splitting_info: IndexSplittingInfo = ...;
let mut bits_to_chunk = FxHashMap::with_capacity(self.link_output.entries.len());
let input_base = ArcStr::from(self.get_common_dir_of_all_modules(...));
在此基础上,该函数会根据用户的配置进行分支处理。在「保留模式(preserve mode)」下,算法会直接遍历由link_output提供的模块表,创建代码块(chunk)并将其添加至代码块图谱(chunk_graph)中。库开发者通常都会使用该模式来保持文件的独立性(而非将文件合并)—— 背后存在若干特定且高价值的原因:
- 更完善的摇树优化(Tree-shaking):允许用户仅导入库中所需的部分,确保不会将 “死代码(dead code)” 打包到最终产物中交付给终端用户。
- 深度导入(Deep imports):支持用户直接导入特定模块(例如:
import Button from 'ui/Button'),因为输出文件的结构与源码目录结构完全一致。 - 调试更便捷:输出文件夹的结构与源码文件夹完全一致,大幅降低了在浏览器中阅读代码和排查错误的难度。
- 原子化更新(Atomic updates):若修改了某个文件,仅有该特定文件会发生变更。这对于缓存优化和按需加载模块的运行环境而言更为友好。
而在普通模式(normal mode)下,Rolldown 则采用更为复杂的策略,将模块图谱(module graph)转换为一组经过优化的打包产物(bundle)。
普通打包模式(Normal bundling mode)
这是绝大多数 JavaScript 项目的标准打包模式,在该模式下,Rolldown 会将模块图谱转换为一系列经过优化的代码块(chunk)。整个流程的执行逻辑如下:
首先根据用户配置确定入口点(entry points) ,随后遍历模块图谱,找出所有可访问的模块;为平衡流程可控性与打包性能,Rolldown 会先执行手动拆分(manual splitting) ,根据用户自定义规则抽离出高优先级模块;待这些自定义的拆分要求满足后,剩余未被指定拆分规则的 “无归属” 模块,会通过自动拆分算法进行处理;最后进入结构优化阶段:Rolldown 会合并重复模块、移除冗余的门面代码块(facade chunks),对打包结果做最终清理,确保输出产物尽可能精简。
入口点创建(Entry point creation)
由于模块遍历从入口点开始,因此初始化入口点是整个打包流程的首个关键步骤。在打包语境下,入口点本质上是一类特殊的模块,充当着依赖图谱(dependency graph)的根节点。在该阶段,Rolldown 会遍历链接输出(link output)中定义的所有入口点(entry_points),并为每个入口点初始化对应的代码块(chunk)。
这些入口代码块(entry chunks)最重要的元数据是位字段(bits field) 。尽管我们后续会详细剖析其底层算法,但核心结论是:bits 本质上是一个封装的数据结构,功能等同于位掩码(bitmask)。这个位集合(bitset)的总长度与项目中入口点的数量一一对应,每个入口点都会被分配一个唯一的位位置(bit position)。
这一逻辑会延伸至图谱中的所有普通模块:Rolldown 通过位位置记录 “哪些入口点能够访问到某个特定模块”,进而为每个模块生成一份「可达性指纹(reachability fingerprint)」。这份指纹是代码拆分算法(code splitting algorithm)的核心基础 —— 借助它,打包工具能够精准判断哪些模块是多个入口点共享的,哪些模块仅归属于单个入口点(私有模块)。
代码块拆分(Split chunks)
split_chunks 函数是将原始模块依赖图谱转换为高效代码块分配策略的核心引擎。其首要目标是平衡自动化效率与用户显式控制,确保最终的打包产物结构既符合性能最佳实践,又满足特定的项目需求(注:原文 reuqirements 为笔误,正确拼写为 requirements)。
该流程的执行步骤如下:
入口分析阶段:determine_reachable_modules_for_entry 函数会对模块图谱执行广度优先搜索(Breadth-First Search,BFS)。算法从每个入口点出发,遍历所有可访问的模块,并将对应的入口索引(entry_index)分配至该模块的位集合(bitset)中。这一过程会生成前文提及的 “可达性指纹”,让 Rolldown 能够精准识别哪些入口点依赖哪些模块。
手动拆分阶段:由于开发者通常掌握着算法无法推断的业务逻辑上下文,因此 Rolldown 会优先处理用户自定义的模块分组(可通过正则表达式规则或自定义函数配置)。在自动拆分逻辑启动前,先将这些高优先级模块抽离至指定代码块中,确保用户的定制化需求始终优先得到满足。
自动分配阶段:手动拆分完成后,剩余模块会进入自动分配流程。此时 Rolldown 会根据模块的位集合模式对其分组,确保最终生成的代码块既不会因体积过大影响加载效率,也不会因体积过小而降低 HTTP 传输性能。
清理优化阶段:函数最后会执行一轮清理操作,合并并优化已生成的代码块,消除所有结构性冗余。
归根结底,这种多阶段处理方式将用户配置视为核心蓝图,仅让算法负责解决剩余 “共享依赖” 的分配难题。
第一阶段:定义可达性(位集合指纹)
代码拆分算法的第一步是分析可达性:确定哪些模块可从哪些入口点被访问。在 Rolldown 中,入口点(entry)是一类根模块 —— 其中包含三种不同类型📝—— 同时也是代码块(chunk)的起始点。通过从这些入口点追溯模块图谱,Rolldown 会标记所有可访问的模块,以确保代码拆分后模块间的拓扑关系仍保持完整。这一步至关重要,因为在最终的打包产物中,每个代码块都是浏览器异步加载的独立文件;一旦可达性关系被破坏,应用在运行时将无法解析其依赖项。
为了梳理这种可达性关系,Rolldown 会在模块图谱上执行经典的广度优先搜索(Breadth-First Search,BFS)。算法从入口点出发,遍历所有被包含的模块,并在遍历过程中更新它们的「可达性数据」。这些数据存储在一个由 SplittingInfo 结构体组成的数组中,数组以模块的唯一 ID 作为索引:
// Type definition for SplittingInfo
pub struct SplittingInfo {
pub bits: BitSet,
pub share_count: u32,
}
// Updating info during traversal
index_splitting_info[module_idx].bits.set_bit(entry_index);
index_splitting_info[module_idx].share_count += 1;
该结构体的核心是 bits 字段 —— 这是一个位掩码(bitmask),其长度与项目中入口点(entry points)的总数一一对应。每个入口点都会被分配一个专属的位位置(bit position)。例如,在一个包含三个入口点的项目中,其映射关系可能如下:
| 入口 | bit位置 |
|---|---|
| 入口 A | 0 |
| 入口 B | 1 |
| 入口 C | 2 |
该算法的具体逻辑如下:
若某个模块可从入口点 A 访问,则第 0 位会被设为
1;若该模块同时可从入口点 C 访问,则第 2 位也会被设为 1,最终形成如 101 这样的二进制表示。这份 “可达性指纹” 会成为该模块的唯一标识,后续将精准决定该模块归属哪个代码块(chunk),或是是否需要被移入共享代码块(shared chunk)。
第二阶段:用户驱动的分片(手动拆分)
可达性定义完成后,Rolldown 会启动第一轮代码拆分。该阶段由手动拆分规则(具体为「匹配组(match groups)」📝)驱动,允许用户显式定义特定模块的打包方式。由于这些规则直接体现开发者的定制化意图,因此其优先级高于自动拆分算法,会被优先处理。
该阶段的核心引擎依赖两种主要数据结构:一是用于关联分组名称与索引的映射表(map),二是用于存储实际分组数据的向量(vector)。
// Maps user-defined group names to their internal index
let mut name_to_module_groups: FxHashMap<(usize, ArcStr), ModuleGroupIdx>
// Stores the actuall module group data
let mut index_module_groups: IndexVec<ModuleGroupIdx, ModuleGroup>
你可以把 ModuleGroup 理解为一个 “容器(bucket)”。它并非仅存储名称和一组模块这么简单;同时还会追踪优先级(priority)和总大小(total size),为后续的代码块拆分决策提供依据。
struct ModuleGroup {
name: ArcStr,
match_group_index: usize,
// Modules belonging to this group
modules: FxHashSet<ModuleIdx>,
priority: u32,
sizes: f64,
}
分组分配流程(The assignment process)
Rolldown 会遍历所有模块,并根据用户定义的「匹配组配置(match group configurations)」对每个模块进行校验。若某个模块匹配某条规则且满足既定约束条件(例如 allow_min_module_size(最小模块体积阈值)或 allow_min_share_count(最小共享次数阈值)),则该模块会被分配至对应的分组。关键在于,这并非仅做表层匹配:Rolldown 会调用 add_module_and_dependencies_to_group_recursively 方法,确保该模块的整个依赖树都被纳入该分组,以此保证手动拆分出的代码块(chunk)结构完整。
分配完成后,所有分组会按优先级存储。若分组优先级相同,算法会退而采用「原始索引→字典序」的排序规则,以保证排序结果稳定且可预测。最终的分组列表会被反转,为后续的 “贪心式” 拆分逻辑做好准备。
分组优化(Refining the groups)
即便用户已定义好分组,生成的这个 “模块桶(bucket)” 仍可能因体积过大或过小影响性能。Rolldown 会执行一套 “贪心式优化流程”,确保每个分组的体积维持在合理区间 —— 这一过程并非简单的 “一刀切”,而是通过一系列逻辑步骤实现。
预排序:分组内的模块会先按「体积大小 + 执行顺序」排序。将体积较小的模块置于数组头部,Rolldown 能更轻松地拆分出满足最小体积要求的代码段,避免因单个超大模块阻碍拆分。
贪心式拆分校验:算法会从分组的两端扫描,寻找两个均超过 allow_min_size(最小代码块体积)的代码段。左侧代码段会成为潜在的新代码块,右侧代码段则用于验证校验;若两段存在重叠,说明拆分后会产生过小的低效文件(会降低浏览器加载性能),此时分组会保持完整不拆分。
拆分执行与优化:若拆分有效,Rolldown 会尽可能扩展左侧代码段(持续添加模块直至达到 allow_max_size(最大代码块体积)上限),以此减少代码块总数(这通常更利于 HTTP/2 多路复用)。分组中未拆分的剩余模块会被推回栈中,等待下一轮迭代重新评估、可能再次拆分。
去重处理:一旦某个代码块最终确定,其包含的所有模块会从其他所有分组中移除,避免模块重复打包。
第三阶段:自动分配(核心循环)
手动分配完成高优先级模块的处理后,Rolldown 会对剩余代码库执行自动分配流程。该算法会根据模块的位集合(bits,即可达性指纹)将模块分配至对应代码块(chunk),并为被多个入口点(entry)共享的模块智能创建共享代码块。
位运算 “热身”
要理解该算法的精妙之处,我们先看一个看似不相关的问题:力扣(LeetCode)上的「寻找前缀公共数组(Find the Prefix Common Array)」问题。
该问题要求我们对比两个排列数组 A 和 B,找出在每个索引位置 i 处二者共有的元素数量。尽管使用哈希表(hash map)也能解决,但位运算的实现方式要优雅且高效得多。通过将数字的存在状态表示为位掩码(bitmask)中的某一位(例如,数字 3 对应将第 3 位设为 1),我们只需通过一个简单的按位与(&)运算就能对比两个集合。
运算结果中被置
1 的位的数量(bit_count,即位计数),能精准告诉我们两个数组共有多少个相同元素。该操作的执行速度极快,因为它直接利用了硬件级指令。
def findThePrefixCommonArray(self, A: List[int], B: List[int]) -> List[int]:
a, b = 0, 0
ans = []
for x, y in zip(A, B):
a |= 1 << x
b |= 1 << y
ans.append((a & b).bit_count())
return ans
将该逻辑应用到 Rolldown 中
这和打包流程有什么关联呢?在第一阶段,我们为每个入口点分配了唯一的位位置(bit position)。正如力扣(LeetCode)问题中的位掩码代表一组数字那样,Rolldown 中的位字段(bit field)代表的是「能够访问到某个特定模块的所有入口点的集合」。
这意味着,位集合值(bits value)是模块可达性的唯一标识。如果两个模块的位集合值完全相同,说明它们被完全相同的一组入口点所访问,因此理应被归到同一个代码块(chunk)中。
let bits = &index_splitting_info[normal_module.idx].bits;
if let Some(chunk_id) = bits_to_chunk.get(bits).copied() {
// Add module to chunk
}
// ...
是不是相当直观?这套逻辑让 Rolldown 能够自动将模块分组到共享代码块中。不过,生产环境的实现版本还处理了若干特殊场景,以保证最优性能:
- 场景 A(通用场景):若某模块的位集合(bitset)与已有代码块匹配,则该模块会被添加至这个代码块中。
- 场景 B(门面入口规避):对于用户自定义的入口点,即便某些模块在技术层面属于共享模块,Rolldown 也会尽量将其保留在原始入口代码块中。这一设计是为了避免生成「门面入口(facade entries)」📝—— 这类极小的文件本身不包含任何业务逻辑,仅用于从共享代码块中重新导出代码。减少这类门面文件的数量能降低 HTTP 请求总数,这对应用的初始加载性能至关重要。
- 场景 C(待优化处理):部分模块会被标记为 “待处理(pending)” 状态。这些模块会被暂存,留到后续更智能的合并阶段再处理,因为它们需要结合整个打包产物的结构上下文,才能被分配到最优位置。
- 场景 D(默认场景):若某模块的位集合是全新的(无匹配的现有代码块),Rolldown 会创建一个新的公共代码块,并记录该位集合与新代码块的映射关系,以便后续拥有相同位集合的模块能直接加入。
第四阶段:结构优化(合并与门面处理)
这些优化算法涉及的复杂边界场景,单独写一篇文章都绰绰有余,因此我们仅从宏观层面理解其对打包产物的优化逻辑:
优化 1:公共模块合并。该优化会将公共模块合并至已有代码块中。优化器不会对每一个共享依赖都机械地创建新共享代码块,而是扫描所有 “待处理” 的公共模块,尝试将它们 “嵌入” 到已有入口代码块中 —— 且仅当该操作被判定为 “安全” 时才会执行。此处的 “安全” 需满足:合并操作不得破坏依赖顺序、跨越异步边界、打乱动态导入逻辑,也不能改变代码块预期的 API 结构。通过整合这些模块,Rolldown 减少了代码块的总数,从而降低浏览器中管理多个文件的开销。
优化 2:门面动态入口代码块优化。这一优化由 optimize_facade_dynamic_entry_chunks 函数负责。在前期的拆分阶段,部分入口代码块可能因内部所有模块都被移至共享代码块而变成 “空块”,仅留下一个 “门面”—— 这类文件自身无任何逻辑,仅用于指向其他代码块。该优化会识别这些冗余文件并将其移除,同时修补符号引用和运行时逻辑,确保应用的行为与门面文件仍存在时完全一致。这是保证输出产物整洁的关键步骤,尤其在包含复杂动态导入的大型项目中。
后处理阶段(Post processing phase)
打包流程的最后阶段包含一系列优化调整,旨在确保生成的代码块(chunk)整洁规范、逻辑有序,且性能达到最优。该阶段的核心工作主要包括:合并外部命名空间、对模块和代码块进行排序,以及精简入口级别的外部模块。
代码块级别的外部导入命名空间合并
在链接阶段(link stage),Rolldown 会收集「外部命名空间(external namespace)」✨的导入符号(import symbols),并按其所属的外部模块进行分组。如果你感兴趣,可以阅读我之前的文章。当代码块最终确定后,我们便可开始在代码块级别合并并关联这些符号。
这一过程的执行效率极高:Rolldown 首先会根据这些符号所属的代码块重新分组。在典型项目中,多个模块可能会导入同一个外部命名空间(例如 React);若这些模块最终被归入同一个代码块,那么冗余的引用便无保留必要。在确认模块确实被包含在最终打包产物中,并按执行顺序完成排序后,Rolldown 会将单个代码块内所有相同的命名空间符号,关联至该符号首次被使用的实例上。
合并前:
// Module A (in Chunk 1)
import * as React_1 from 'react';
// Module B (in Chunk 1)
import * as React_2 from 'react';
合并后:
// Rolldown keeps only one namespace reference
import * as React_Namespace from 'react';
// All code that originally used React_1 or React_2
// is rewritten to use React_Namespace
console.log(React_Namespace.useState);
排序与入口级外部模块查找
一致性是构建流程稳定的核心关键。为实现这一点,每个代码块内的所有模块都会按执行顺序排序;而代码块本身则会按类型划分优先级排序:入口代码块(entry chunks)优先级最高,其次是静态代码块(static chunks),最后是动态代码块(dynamic chunks)。这一规则确保不同入口点对应的代码块始终保持稳定、可预测的顺序。
最后还有一项精妙的优化针对「入口级外部模块(entry-level external modules)」—— 这类外部模块的定义是:从某个入口点出发,能通过无中断的 export * 语句链访问到的外部依赖模块。
示例场景:
// entry.js
export * from './a.js'
// a.js
export * from 'external'
通常情况下,Rolldown 需要生成复杂的互操作代码(借助 __reExport 运行时辅助函数)来衔接这些文件。但如果这个引用链路保持完整无断裂,该函数会识别出其「入口级」的状态,并输出一条简洁、干净的导入语句:
import * from 'external';
但这一规则存在一个关键例外:若代码是显式引用命名空间对象本身—— 而非仅转发(pass through)其导出内容 ——Rolldown 必须跳过该优化。此举能确保命名空间对象始终保持完整,且完全符合语言规范(spec-compliant),避免因直接重导出(direct re-export)可能导致的符号缺失问题。
总结
从原始模块图谱到可投入生产环境的打包产物,整个过程是一场「开发者定制意图」与「算法执行效率」的平衡艺术。Rolldown 借助基于位集合(BitSet)的可达性指纹,将复杂的依赖关系转化为高性能的位掩码,使其能够以硬件级的速度识别共享模块。
这一最终阶段的真正精妙之处,在于它如何平衡开发者的定制需求与算法的强大能力:当你需要时,可通过手动拆分获得完全的控制权;而那些令人头疼的门面代码块、命名空间合并等问题,则由 “智能” 默认逻辑在底层自动处理。无论你是发布一个类库,还是构建一个大型 Web 应用,核心目标始终不变:更少的冗余代码、更快的加载速度、更整洁的输出产物。
以上就是我们对 Rolldown 打包流程的深度解析。从模块扫描、链接到最终的产物生成阶段,其核心目标始终清晰:将 Rust 带来的极致性能,一点一滴地融入灵活的 JavaScript 生态体系中。
但这绝不意味着我们的探索已至终点!😉 这只是我们深入 Rolldown 代码库的开端,还有诸多精彩的技术细节等待挖掘。我很快会推出更多文章,拆解这些 “隐藏的宝藏”,分享我对这款打包工具核心运行逻辑的理解。敬请期待!