引言
在现代 Web 应用的性能优化体系中,JavaScript 代码分割(Code Splitting)是决定应用加载性能和用户体验的关键技术之一。随着企业级应用复杂度的不断提升,如何实现最优的模块分割策略已成为前端工程师必须掌握的核心技能。
基于公司内部大量生产环境 Vite 应用的实践经验和性能数据分析,本文深入探讨 Vite 构建工具链中 splitChunk 算法的底层机制与优化原理。
目前 Vite 在 production 环境下仍然依赖 Rollup 作为打包工具,所以本质上 vite splitChunk 就是 Rollup splitChunk。
本文为 Rollup 代码分割原理系列首篇,后续将从 manualChunks 配置、源码实现等维度深入解析 Rollup Split Chunk 的机制与实践。
概念
从本质上讲,Rollup 的拆分算法首先从每个静态或动态入口点开始,然后将该入口点分配给所有可以通过静态导入到达的模块,我们称这些为该模块的依赖入口点。
构建过程中,Rollup 将具有相同依赖入口点的所有模块分组到同一个 Chunk 中,因为这些模块总是会一起加载。
之后,在完成按照依赖入口点拆分的 ChunkAtom(原子块)后 Rollup 会为分好的 chunk 进行一些优化实现最终的模块拆分。
基础示例:依赖入口点分配算法
考虑以下模块依赖图:
- Module A 静态导入 Module B:
A → B
- Module A 静态导入 Module C:
A → C
- Module B 静态导入 Module C:
B → C
其中 Module A 和 Module B 均为显式入口模块(Explicit Entry Modules)。
Rollup 的依赖入口点分析算法 (Dependent Entry Points Analysis)通过传递闭包计算确定每个模块的依赖入口点集合:
模块 | 依赖入口点集合 | 计算过程 |
---|---|---|
Module A | {A} |
直接入口点,自引用 |
Module B | {A, B} |
被入口 A 静态导入 ∪ 自身为入口 B |
Module C | {A, B} |
被入口 A 间接依赖 ∪ 被入口 B 直接依赖 |
基于依赖入口点同构性原理(Dependency Entry Points Isomorphism),Rollup 将具有相同依赖入口点集合的模块聚合到同一 chunk:
项目 | Module A | Module B | Module C |
---|---|---|---|
模块类型 | 入口模块 | 入口模块 | 共享模块 |
静态依赖 | B, C | C | - |
依赖入口点集合 | {A} | {A, B} | {A, B} |
分配到的 Chunk | Chunk A | Chunk AB | Chunk AB |
Rollup 这种分块策略基于代码复用最大化原则:
优化维度 | 优化前(朴素策略) | 优化后(依赖入口点算法) | 优化效果 |
---|---|---|---|
代码重复 | Module C 在两个 chunk 中重复 | Module C 只在 Chunk AB 中出现一次 | 消除 100% 重复 |
产物大小 | 200KB (A:120KB + B:80KB) | 110KB (A:30KB + AB:80KB) | 减少 45% |
网络请求 | 可能重复下载相同模块 | 智能缓存复用 | 减少 30-50% |
内存占用 | Module C 可能被重复执行 | Module C 只执行一次 | 优化运行时性能 |
简单示例:动态加载上下文优化算法
同样,假设我们有三个模块 Module A、Module B 和 Module C 的依赖关系:
- Module A 静态导入 Module B:
A → B
- Module A 动态导入 Module C:
A ⇒ C
- Module C 静态导入 Module B:
C → B
其中 Module A 为显式静态入口 ,Module C 为隐式动态入口。
模块 | 依赖入口点集合 | 计算依据 |
---|---|---|
Module A | {A} |
静态入口点,自引用 |
Module B | {A, C} |
被静态入口 A 和动态入口 C 共同依赖 |
Module C | {C} |
动态入口点,自成依赖集合 |
dynamicImport 的模块在构建过程中会被视作另一种入口模块形式 dynamicEntryModule 参与构建。
初步分块策略:
- Chunk A :
[Module A]
- Chunk AC :
[Module B]
- Chunk C :
[Module C]
在这种情况下,Rollup 意识到了一个重要事实动态加载依赖传递性定理:当动态入口模块被加载时,其所有动态导入者的静态依赖必然已在运行时内存中。
当动态入口 C 被加载时,其所有动态导入者的静态依赖必然已经在内存中。 当 C 被动态加载时,A 和 B 必须已经在内存中了。
因为 ModuleC 只能通过 ModuleA 来动态加载(A => C
),当 ModuleA 加载时,ModuleB 也会被静态加载(A -> B
)。
步骤 | 分析内容 | 推理结果 |
---|---|---|
Step 1 | 识别动态入口 C 的动态导入者 | DynamicImporters(C) = {A} |
Step 2 | 分析导入者 A 的静态依赖 | StaticDeps(A) = {B} |
Step 3 | 内存状态推断 | 当 C 加载时,A 和 B 必定已在内存 |
Step 4 | 依赖入口点优化 | 从 B 的依赖入口点中移除 C |
所以当 ModuleC 执行时,ModuleA 和 ModuleB 都已经存在在内存之中。既然 B 在 C 加载时已经在内存中,那么:
- 不需要把 B 单独打包到 C 的代码块中。
- C 可以直接从 A 的代码块中访问 B。
- 不需要将 C 作为 B 的依赖入口点。
简单说,Rollup 除了依赖入口点的拆分规则外,还会额外进行一些优化:
如果一个动态入口的所有动态导入者都已经加载了某个模块,那么这个模块在动态入口加载时必然已经在内存中,因此不需要将该动态入口作为这个模块的依赖入口点。
以上边的例子来解释这句话
- Module C 为动态入口点 (Dynamic Entry Point),通过
import()
语句被异步加载。 - Module C 的动态导入者集合 为
{Module A}
,即仅有 Module A 通过动态导入引用 Module C。 - Module A 在加载时会同步加载其静态依赖 Module B,基于静态依赖传递性原理,当 Module C 被动态加载时,Module B 必定已存在于运行时内存中。
- 因此,Rollup 应用依赖入口点消除优化:将 Module B 的依赖入口点集合中的动态入口点 Module C 移除,避免将已经在内存中的模块重复分配到动态入口的代码块中。
最终 Rollup 重新计算依赖入口点,优化后的分配策略:
- Module A: 依赖入口点集合 {A}
- Module B: 依赖入口点集合 {A} 优化后,移除了C
- Module C: 依赖入口点集合 {C}
基于相同依赖入口点聚合:
优化阶段 | Module A | Module B | Module C |
---|---|---|---|
初始分析 | DEP({A}) | DEP({A, C}) | DEP({C}) |
优化后 | DEP({A}) | DEP({A}) | DEP({C}) |
最终分块 | Chunk A | Chunk A | Chunk C |
Rollup 优化前后性能效果对比
优化维度 | 优化前 | 优化后 | 提升效果 |
---|---|---|---|
Chunk 数量 | 3个 | 2个 | 减少 33% |
代码重复 | Module B 可能重复 | 零重复 | 消除冗余 |
动态加载性能 | 需要额外加载 B | 直接访问内存中的 B | 加载时间减少 ~100% |
缓存效率 | 分散的依赖关系 | 聚合的静态依赖 | 缓存命中率提升 |
这是 Rollup 独有的算法创新,通过静态分析动态行为,在构建时预测运行时内存状态,实现最优的代码分割决策。
进阶示例
Rollup 通过动态导入上下文分析算法,识别动态模块的所有导入者的依赖入口点交集,当该交集完全覆盖某个静态模块的依赖入口点时,将动态模块合并到该静态模块的 chunk 中,实现从异步动态加载到同步内存访问的性能优化。
场景一:无效动态优化(优化边界)
之后,我们再来探索另一个稍显复杂的示例。
假设我们拥有如下的模块依赖关系,X、Y、Z 为显式设置的入口模块,模块依赖关系如下:
- X -> A (X 静态导入 A)
- Y -> A (Y 静态导入 A)
- Y -> B (Y 静态导入 B)
- Z -> B (Z 静态导入 B)
- A => D (A 动态导入 D)
- B => D (B 动态导入 D)
上边模块之间的依赖关系按照依赖入口点分配算法会被拆分为:
依赖入口点分析与初始分块
模块 | 依赖入口点集合 | 分块分配 |
---|---|---|
X | {X} | Chunk X |
Y | {Y} | Chunk Y |
Z | {Z} | Chunk Z |
A | {X, Y} | Chunk XY |
B | {Y, Z} | Chunk YZ |
D | {D} | Chunk D |
上述的模块依赖关系中,由于 D 被 A 和 B 同时使用动态导入。这意味着当 D 被动态加载时,至少被 X、Y、Z 三个入口点都依赖的模块(A 和 B 的依赖入口点交集)会被认为已经在内存中。
- A 只被 X、Y 依赖,不被 Z 依赖
- B 只被 Y、Z 依赖,不被 X 依赖
- 没有模块满足被所有三个入口点(X、Y、Z)同时依赖
最终,Rollup 保持原有的 6 个独立 chunks,不会产生任何优化效果,最终分块:
- Chunk_X: [X]
- Chunk_Y: [Y]
- Chunk_Z: [Z]
- Chunk_XY: [A]
- Chunk_YZ: [B]
- Chunk_AB: [D]
没有任何模块被所有三个入口点 (X, Y, Z) 同时依赖,因此当 D 被动态加载时,无法确保有任何模块已经在内存中,所以无法进行优化。
场景二:有效动态优化(优化成功)
我们再来看下一个优化生效的例子:
- 入口模块: X, Y, Z
- 静态依赖:
- X → A
- Y → A, Y → B
- Z → A, Z → B
- 动态依赖:
- A ⇒ D
- B ⇒ D
基于依赖入口点算法,各模块的依赖入口点集合为:
模块 | 依赖入口点集合 | 原因 |
---|---|---|
X | {X} | 入口模块 |
Y | {Y} | 入口模块 |
Z | {Z} | 入口模块 |
A | {X, Y, Z} | 被所有入口静态导入 |
B | {Y, Z} | 被 Y、Z 静态导入 |
D | {D} | 动态导入边界,自成入口点 |
之后,Rollup 会进行动态导入上下文分析:
-
动态导入者识别
首先 Rollup 识别动态引入模块 D 的引入关系,ModuleD 被 A 和 B 同时动态引入。
-
导入者依赖入口点分析
之后,Rollup 会计算所有依赖 D 的动态导入者的依赖入口点并集,ModuleA 依赖入口点为 XYZ,ModuleB 依赖入口点为 YZ。
找到所有动态导入者 D 都被哪些入口点依赖,获取他们的并集并集 = {X, Y, Z} ∩ {Y, Z} = {X,Y, Z}。
这也就意味着,导入 D 的所有模块按照依赖入口点算法会被分配进入 X, Y, Z 的入口点 chunk:
- 从入口点 X:通过 X→A⇒D 路径
- 从入口点 Y:通过 Y→A⇒D 或 Y→B⇒D 路径
- 从入口点 Z:通过 Z→A⇒D 或 Z→B⇒D 路径
-
Chunk 合并优化
由于 A 和 D 的依赖关系特殊性:
- A 的依赖入口点:{X, Y, Z}
- D 虽然是动态入口,但其所有动态导入者(A、B)都属于同一个依赖入口点({X, Y, Z})集合
由于 A 的依赖入口点完全覆盖 D 的加载上下文,可以将 D 合并到 A 的 chunk 中,实现动态导入的静态化优化。
Chunk 名称 | 包含模块 | 依赖入口点集合 | 优化说明 |
---|---|---|---|
Chunk X | [X] | {X} | 保持不变 |
Chunk Y | [Y] | {Y} | 保持不变 |
Chunk Z | [Z] | {Z} | 保持不变 |
Chunk XYZ | [A, D] | {X, Y, Z} | 合并优化 |
Chunk YZ | [B] | {Y, Z} | 保持不变 |
已合并 | |||
总计 | 5 个 chunks | - | 减少 16.7% |
优化前后性能对比:
指标 | 场景一(无优化) | 场景二(有优化) | 优化效果 |
---|---|---|---|
Chunk 数量 | 6 | 5 | 减少 16.7% |
动态请求 | 1 个独立请求 | 预加载,零请求 | 延迟减少 100% |
内存效率 | 可能重复执行 | 共享内存访问 | 显著提升 |
缓存效果 | 分散缓存 | 聚合缓存 | 命中率提升 |
复杂示例:深度嵌套动态导入优化
除了前述的优化情况外,Rollup 的代码分割还需要处理一种更复杂的场景:当动态入口点本身也被其他动态入口点依赖时,需要通过递归依赖入口点分析来精确计算运行时内存状态,从而实现最优的模块分割策略。
让我们构建一个例子来理解这段话,假如我们拥有以下模块依赖关系:
scss
// 入口模块
main.js (Entry Point)
// 第一层静态依赖
├── shared-utils.js (Shared Module - Level 0)
├── common-lib.js (Shared Module - Level 0)
// 第一层动态依赖
├── page-a.js (Dynamic Entry - Level 1)
│ ├── shared-utils.js (Static - 共享模块重用)
│ ├── page-a-utils.js (Static - 页面特定工具)
│ └── feature-x.js (Dynamic - Level 2 嵌套动态入口)
│ ├── shared-utils.js (Static - 共享模块重用)
│ ├── feature-x-core.js (Static - 功能核心)
│ └── sub-feature.js (Dynamic - Level 3 深层嵌套)
│ ├── shared-utils.js (Static - 共享模块重用)
│ └── sub-feature-helpers.js (Static - 子功能辅助)
└── page-b.js (Dynamic Entry - Level 1)
├── shared-utils.js (Static - 共享模块重用)
├── common-lib.js (Static - 共享模块重用)
├── page-b-utils.js (Static - 页面特定工具)
└── feature-x.js (Dynamic - Level 2 同样的嵌套动态入口)
└── [同 page-a.js 中的 feature-x.js 结构]
基于依赖入口点算法 (Dependency Entry Points Algorithm) ,各模块的初始分类为:
模块 | 依赖入口点集合 | 分类 |
---|---|---|
main.js | {main} | 根入口点 |
shared-utils.js | {main, page-a, page-b, feature-x, sub-feature} | 超级共享模块 |
common-lib.js | {main, page-b} | 部分共享模块 |
page-a.js | {page-a} | 动态入口点 |
page-a-utils.js | {page-a} | 页面私有模块 |
page-b.js | {page-b} | 动态入口点 |
page-b-utils.js | {page-b} | 页面私有模块 |
feature-x.js | {feature-x} | 嵌套动态入口点 |
feature-x-core.js | {feature-x} | 功能私有模块 |
sub-feature.js | {sub-feature} | 深层动态入口点 |
sub-feature-helpers.js | {sub-feature} | 深层私有模块 |
Rollup 递归优化算法核心原理:Rollup 通过递归依赖入口点分析,识别在加载深层动态模块时哪些模块必定已在内存中,将这些模块从目标 chunk 中排除,实现零重复打包。
优化后的模块分配策略:
按照递归优化算法上边的依赖关系最终构建会实际会生成 5 个 Chunk,分别为
-
Chunk 1: main-[hash].js
包含模块: [main.js, shared-utils.js, common-lib.js]
优化原理:
-
shared-utils.js 被所有模块依赖,提升至根 chunk 实现全局共享
-
common-lib.js 被 main 和 page-b 依赖,提升至根 chunk 优化加载
-
-
Chunk 2: page-a-[hash].js
包含模块: [page-a.js, page-a-utils.js]
优化原理:
-
page-a 为动态模块,独立分割
-
加载时 main chunk 必定已存在,排除 shared-utils.js 避免重复
-
-
Chunk 3: page-b-[hash].js
包含模块: [page-b.js, page-b-utils.js]
优化原理:
- page-b 为动态模块,独立分割
- 排除已在 main chunk 中的 shared-utils.js 和 common-lib.js
-
Chunk 4: feature-x-[hash].js
包含模块: [feature-x.js, feature-x-core.js]
优化原理:
-
feature-x 被 page-a 和 page-b 共同依赖
-
递归分析:两个导入者的依赖入口点交集为 {main}
-
因此排除 main chunk 中的所有模块,避免重复打包
-
-
Chunk 5: sub-feature-[hash].js
包含模块: [sub-feature.js, sub-feature-helpers.js]
三层递归优化分析:
-
直接依赖分析:
- sub-feature.js 仅被 feature-x.js 动态导入
- 加载时 feature-x chunk 必定已在内存中
-
递归动态入口点分析:
- feature-x.js 本身也是动态入口点
- 被 page-a.js 和 page-b.js 动态导入
- 关键推理:加载 feature-x 需要先加载 page-a 或 page-b
-
递归依赖入口点计算:
- page-a 的依赖入口点: {main}
- page-b 的依赖入口点: {main}
- feature-x 的递归依赖入口点: {main} ∩ {main} = {main}
内存状态推断:
-
直接原因: feature-x.js 及其静态依赖 (feature-x-core.js) 已存在
-
递归原因: main chunk 的所有模块 (shared-utils.js, common-lib.js) 已存在
-
结论: sub-feature chunk 只需包含其特有代码,实现最小化打包
-
最终经过 Rollup 动态导入优化后会将原本的 7 个 Chunk 减少为 5 个,实际构建后零重复代码:
模块 | 依赖入口点集合 | 分类 |
---|---|---|
main.js | {main} | 根入口点 |
shared-utils.js | {main} | 超级共享模块 |
common-lib.js | {main} | 部分共享模块 |
page-a.js | {page-a} | 动态入口点 |
page-a-utils.js | {page-a} | 页面私有模块 |
page-b.js | {page-b} | 动态入口点 |
page-b-utils.js | {page-b} | 页面私有模块 |
feature-x.js | {feature-x} | 嵌套动态入口点 |
feature-x-core.js | {feature-x} | 功能私有模块 |
sub-feature.js | {sub-feature} | 深层动态入口点 |
sub-feature-helpers.js | {sub-feature} | 深层私有模块 |
结语
本文通过四个递进式的实例深入剖析了 Rollup 代码分割的核心算法机制:
- 基础示例 展示了依赖入口点分析算法的基本原理。
- 简单示例 引入了动态加载上下文优化算法,利用静态依赖传递性消除动态入口点的冗余依赖。
- 进阶示例对比了优化的边界条件,说明了依赖入口点交集计算对优化效果的决定性作用。
- 复杂示例 演示了递归依赖入口点分析在深度嵌套动态导入场景下的三层优化机制。
这些算法构成了 Rollup 默认代码分割机制的完整体系,后续将深入源码层面解析其具体实现过程。