本文将展开分析 webpack 内的优化项 SplitChunks,注意 SplitChunks 和 Code Splitting 有所不同,常说的 Code Splitting 一般指代码分割,通过 动态导入 dyn import() 实现。
名词解释
-
Chunk: 由 modules 集合 + modules 加载后会执行的方法 组成 ,可理解为 构建输出的 js 产物。
Each chunk has a corresponding **asset**. The assets are the output files - the result of bundling.
如何产生?
Entry Points (webpack config entry 配置项)
java// webpack.config.js module.exports = { entry: './path/to/my/entry/file.js', };
动态导入语句
goimport('./path/to/my/entry/file.js')
如何执行?
javascript// 模拟下 module system 里的 jsonp 方法,jsonp 具体做 缓存模块 + 执行模块 // require 等实现不做展开 window.mock_jsonp( // chunk id "0", // chunk 包含的模块集合 { 0: (module, exports, require) => { console.log(`entry module`); }, }, // 执行 mock_jsonp 方法时,将执行该函数 (require) => { require(0); } );
-
SplitChunks:中文为 拆分 chunks,拆分前 chunk-1 到 拆分后 chunk-1-1、chunk-1-2...
大多数情况下 chunks 的拆分 意味着 modules 的合并,所以对 chunks 进行拆分 与 module 的关系密不可分。
用途
随着业务规模/复杂度上升,Webapp 项目的体积也越来越大,对于用户而言,页面首屏加载速度尤为关键,加载依赖网络,开发者需要构建出一个适配不同网络情况/业务特性的应用产物。
对于一些不支持 http2 的应用,我们需限制首屏最大并行请求数量。但对于支持 http2 的应用,我们需充分发挥多路复用的特性。
对于用户流量大 & 需求经常迭代变更的应用,每次发版,相关改动的 bundle 都将变成冷资源,热起来需一段时间,故需「擅用缓存」,从构建层面,开发者可以辅助用户「使用缓存」,如 chunkHash/contentHash,hashModule,将不经常变更三方依赖抽离成单独的 vendor chunk,将 体积较大的三方依赖 & 几乎不变更的依赖 (react/react-dom 等)做 external。
配置项解释
-
chunks :规定了什么类型的 chunk 将被拆分,可选 all 、initial、async,其中 initial 由 EntryPoints 产生,async 由 dyn import() 产生,all 则表示所有 chunk 都可被拆分。
-
name:规定拆分后的 chunk 的名称。应避免使用常量字符串 or 返回相同的字符串,若 cacheGroup 未指定 name ,这将导致不同的 cacheGroup 使用相同 name,最终导致所有模块合并到 同一个 chunk 中。
-
maxAsyncRequests:动态加载 最大的并行请求数。
-
maxInitialRequests:首屏加载 最大的并行请求数。
-
minChunks:规定了拆分前的模块至少存在几个chunk内。
-
minSize:规定了拆分后的chunk最小体积。
-
maxSize:规定了拆分后的chunk最大体积,该规则可能会被打破(为何会被打破,下文分析会讲解)。
-
cacheGroup:定义单个 chunk 的拆分规则,会继承、覆盖 splitChunks.* 的任何选项,额外多出 test、priority 和 reuseExistingChunk 属性。
- test:模块匹配规则
- priority:组别优先级,如开发者新增一个组别来匹配 node_modules 里具体的某些模块,同时 webpack 内部有 vender 组别,开发者可通过 priority 属性提升优先级进行拆分。
- reuseExistingChunk:是否复用已有 chunk,splitChunks 默认行为是通过 addChunk() 新增chunk,若拆分出的 chunk 的模块集合 === 已有 chunk 的模块集合,则不新增,相当于 「拆了个寂寞」。
如何配置
选择默认配置是为了适应 Web 性能最佳实践,但您的项目的最佳策略可能有所不同。如果您要更改配置,则应该衡量更改的效果,以确保带来真正的好处。
-
默认预设
-
webpack 4: v4.webpack.js.org/plugins/spl...
-
webpack 5: webpack.js.org/plugins/spl...
-
-
nextjs github.com/vercel/next...
思考
-
拆分 chunks 由构建工具完成,这在用户侧是如何表现的?
javascript// main.js import(/* webpackChunkName: "async-a" */'./a') // a.js import bigFile from '30kb' console.log(bigFile); // 30kb.js // ... // 30kb 字符串,这里不展开
使用 webpack 默认配置,会将 node_modules 的模块拆分到新 chunk 内。
构建产物如下
- async-a.chunk.js
- main.js
- vendors~async-a.chunk.js
less// main.js // ... Promise.all(/* import() | async-a */[__webpack_require__.e(0), __webpack_require__.e(1)]).then(__webpack_require__.bind(null, 1)) // ...
webpack_require.e 内部维护了一个chunkId → url 的 map 来动态加载 script 脚本,函数返回一个 Promise ,这里不做具体展开。
源码中对 async-a 模块的加载执行,被编译为同时加载 2 个chunk(async-a + vendor)后 执行 async-a 模块。
-
如何确保「拆分」不影响原有的 chunkGraph 各个 chunk 节点关系?
- webpack4 之前 「父 chunk → 子 chunk 」关系
- webpack4 及之后「父 chunkGroup(chunks 集合) → 子 chunkGroup(chunks 集合) 」关系
为什么要对数据结构进行优化?采用 chunksGroup, 而不是 chunk?
-
如果 依赖关系是 chunk-a → chunk-b(chunk b 依赖 chunk a),从 chunk-a 拆分出 chunk-a-a。此时将面临一个问题: chunk-a-a 该作为 chunk-a 的父还是子?父子关系意味着模块加载的顺序,比如 「chunk-a 的加载 依赖着 chunk-a-a 的加载」,两者都将导致额外的性能开销,即 并行加载 变成 串行加载。
简单代码解释:
load(chunk-a).then(()⇒{load(chunk-a-a)})
-
如果 依赖关系为 chunkGroup-a → chunkGroup-b(chunkGroup-b 依赖 chunkGroup-a),此时从 chunk-a 拆分出 chunk-a-a,并不影响 chunkGroup 的依赖关系,要做的只是往 chunk-a 所在的 chunkGroup.chunks 数组 push 进 chunk-a-a。chunkGroup.chunks 里的每个 chunk 仍并行加载。
简单代码解释:
chunkGroup.chunks.forEach(load)
可见,上文思考提到的内容,async-a + vendor 同属于 一个 chunkGroup。
javascript// lib/RuntimeTemplate.js blockPromise({ block, message }) { // ... const chunks = block.chunkGroup.chunks.filter( chunk => !chunk.hasRuntime() && chunk.id !== null ); // ... if (chunks.length === 1) { const chunkId = JSON.stringify(chunks[0].id); return `__webpack_require__.e(${comment}${chunkId})`; } else if (chunks.length > 0) { const requireChunkId = chunk => `__webpack_require__.e(${JSON.stringify(chunk.id)})`; return `Promise.all(${comment.trim()}[${chunks .map(requireChunkId) .join(", ")}])`; } else { return `Promise.resolve(${comment.trim()})`; } }
前置
- 功能的输入和输出都为 chunks,输入的 chunks 是基于模块依赖关系初步形成的。
- 一个模块可能命中多个 cacheGroups,最终通过 priority 、test 等属性,决定模块处于哪个cacheGroups 中,即模块被拆分到 哪个 chunk 中。
- 单个 module 实例 记录了其所在的所有 chunks 集合。
- 单个 chunk 实例 记录了其包含的所有 module 集合与 其所在的所有 chunkGroups 集合。
简略设计
思考一下 如果目标是 「将命中 splitChunks.cacheGroups.{cacheGroup}.test
的模块 都抽离到 新 chunk 内」,我们需要怎么做?
- 首先,找到 所有匹配中的模块,并依次找到 模块所在的 chunks 集合,若模块被复用,则 chunks 长度 ≥1。(以下的 chunks 指 模块所在的 chunks 集合)
- 判断 chunks 的长度是否 ≥ minChunk,若不满足,则过滤该模块(表示 < minChunk 的 chunk 数量 依赖此模块)。
- 判断 剩余模块的总体积 是否 ≥ minSize,若不满足,则退出功能。
- 判断 是否能复用已有 chunk(判断依据为 是否存在一个 chunk 包含所有剩余模块),若不满足,则后续会新增 chunk,否则复用 chunk(即新 chunk 为自身)。
- 判断 每个 chunk 实例所在的每个 chunkGroups 中的 chunks[] 数量 是否有 < maxRequset,若不满足,则过滤该 chunk(表示 存在某个 import/entry 会导致 ≥maxRequest 的 js 请求数)。(以下的 chunkGroups 指 chunk 实例所在的 chunkGroups 集合)
- 再次判断 chunks 的长度是否 ≥ minChunk,若不满足,则过滤该模块。
- 遍历剩余模块:模块所在 chunk 实例中 记录的模块集合中,删除模块自身,同时向所在的 chunkGroups 中添加 新 chunk,并向 新 chunk 添加该模块。
- 判断 chunk 的总体积 是否 <maxSize,若不满足,则对 新 chunk 进行拆分。
- 至此所有拆分规则皆满足,且 新 chunk 已存在。
深入源码
webpack (v4.44.2)是如何实现 splitChunks 功能的?
ini
// lib/optimize/SplitChunksPlugin.js
module.exports = class SplitChunksPlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap("SplitChunksPlugin", compilation => {
let alreadyOptimized = false;
compilation.hooks.unseal.tap("SplitChunksPlugin", () => {
alreadyOptimized = false;
});
compilation.hooks.optimizeChunksAdvanced.tap(
"SplitChunksPlugin",
chunks => {
// ...
}
}
}
向 optimizeChunksAdvanced 事件钩子注册事件,并接收到初步形成的 chunks。设立标志位 alreadyOptimized 避免重复执行功能,除非编译阶段接收到新模块。
ini
const indexMap = new Map();
let index = 1;
for (const chunk of chunks) {
indexMap.set(chunk, index++);
}
const chunkSetsInGraph = new Map();
for (const module of compilation.modules) {
const chunksKey = getKey(module.chunksIterable);
if (!chunkSetsInGraph.has(chunksKey)) {
chunkSetsInGraph.set(chunksKey, new Set(module.chunksIterable));
}
}
const chunkSetsByCount = new Map();
for (const chunksSet of chunkSetsInGraph.values()) {
const count = chunksSet.size;
let array = chunkSetsByCount.get(count);
if (array === undefined) {
array = [];
chunkSetsByCount.set(count, array);
}
array.push(chunksSet);
}
初始化 三个数据结构 indexMap/chunkSetsInGraph/chunkSetsByCount。
WIP...