浅析 Webpack SplitChunks

本文将展开分析 webpack 内的优化项 SplitChunks,注意 SplitChunks 和 Code Splitting 有所不同,常说的 Code Splitting 一般指代码分割,通过 动态导入 dyn import() 实现。

名词解释

  1. 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',
    };

    动态导入语句

    go 复制代码
    import('./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);
        }
    );
  2. 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。

配置项解释

  1. chunks :规定了什么类型的 chunk 将被拆分,可选 all 、initial、async,其中 initial 由 EntryPoints 产生,async 由 dyn import() 产生,all 则表示所有 chunk 都可被拆分。

  2. name:规定拆分后的 chunk 的名称。应避免使用常量字符串 or 返回相同的字符串,若 cacheGroup 未指定 name ,这将导致不同的 cacheGroup 使用相同 name,最终导致所有模块合并到 同一个 chunk 中。

  3. maxAsyncRequests:动态加载 最大的并行请求数。

  4. maxInitialRequests:首屏加载 最大的并行请求数。

  5. minChunks:规定了拆分前的模块至少存在几个chunk内。

  6. minSize:规定了拆分后的chunk最小体积。

  7. maxSize:规定了拆分后的chunk最大体积,该规则可能会被打破(为何会被打破,下文分析会讲解)。

  8. cacheGroup:定义单个 chunk 的拆分规则,会继承、覆盖 splitChunks.* 的任何选项,额外多出 test、priority 和 reuseExistingChunk 属性。

    1. test:模块匹配规则
    2. priority:组别优先级,如开发者新增一个组别来匹配 node_modules 里具体的某些模块,同时 webpack 内部有 vender 组别,开发者可通过 priority 属性提升优先级进行拆分。
    3. reuseExistingChunk:是否复用已有 chunk,splitChunks 默认行为是通过 addChunk() 新增chunk,若拆分出的 chunk 的模块集合 === 已有 chunk 的模块集合,则不新增,相当于 「拆了个寂寞」。

如何配置

选择默认配置是为了适应 Web 性能最佳实践,但您的项目的最佳策略可能有所不同。如果您要更改配置,则应该衡量更改的效果,以确保带来真正的好处。

  1. 默认预设

    1. webpack 4: v4.webpack.js.org/plugins/spl...

    2. webpack 5: webpack.js.org/plugins/spl...

  2. nextjs github.com/vercel/next...

思考

  1. 拆分 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 内。

    构建产物如下

    1. async-a.chunk.js
    2. main.js
    3. 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 模块。

  2. 如何确保「拆分」不影响原有的 chunkGraph 各个 chunk 节点关系?

    1. webpack4 之前 「父 chunk → 子 chunk 」关系
    2. webpack4 及之后「父 chunkGroup(chunks 集合) → 子 chunkGroup(chunks 集合) 」关系

    为什么要对数据结构进行优化?采用 chunksGroup, 而不是 chunk?

    1. 如果 依赖关系是 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)})

    2. 如果 依赖关系为 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()})`;
    		}
    	}

    相关文章:medium.com/webpack/web...

前置

  1. 功能的输入和输出都为 chunks,输入的 chunks 是基于模块依赖关系初步形成的。
  2. 一个模块可能命中多个 cacheGroups,最终通过 priority 、test 等属性,决定模块处于哪个cacheGroups 中,即模块被拆分到 哪个 chunk 中。
  3. 单个 module 实例 记录了其所在的所有 chunks 集合。
  4. 单个 chunk 实例 记录了其包含的所有 module 集合与 其所在的所有 chunkGroups 集合。

简略设计

思考一下 如果目标是 「将命中 splitChunks.cacheGroups.{cacheGroup}.test 的模块 都抽离到 新 chunk 内」,我们需要怎么做?

  1. 首先,找到 所有匹配中的模块,并依次找到 模块所在的 chunks 集合,若模块被复用,则 chunks 长度 ≥1。(以下的 chunks 指 模块所在的 chunks 集合)
  2. 判断 chunks 的长度是否 ≥ minChunk,若不满足,则过滤该模块(表示 < minChunk 的 chunk 数量 依赖此模块)。
  3. 判断 剩余模块的总体积 是否 ≥ minSize,若不满足,则退出功能。
  4. 判断 是否能复用已有 chunk(判断依据为 是否存在一个 chunk 包含所有剩余模块),若不满足,则后续会新增 chunk,否则复用 chunk(即新 chunk 为自身)。
  5. 判断 每个 chunk 实例所在的每个 chunkGroups 中的 chunks[] 数量 是否有 < maxRequset,若不满足,则过滤该 chunk(表示 存在某个 import/entry 会导致 ≥maxRequest 的 js 请求数)。(以下的 chunkGroups 指 chunk 实例所在的 chunkGroups 集合)
  6. 再次判断 chunks 的长度是否 ≥ minChunk,若不满足,则过滤该模块。
  7. 遍历剩余模块:模块所在 chunk 实例中 记录的模块集合中,删除模块自身,同时向所在的 chunkGroups 中添加 新 chunk,并向 新 chunk 添加该模块。
  8. 判断 chunk 的总体积 是否 <maxSize,若不满足,则对 新 chunk 进行拆分。
  9. 至此所有拆分规则皆满足,且 新 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...

引用

medium.com/webpack/web...

web.dev/articles/gr...

相关推荐
安冬的码畜日常1 小时前
【D3.js in Action 3 精译_029】3.5 给 D3 条形图加注图表标签(上)
开发语言·前端·javascript·信息可视化·数据可视化·d3.js
太阳花ˉ1 小时前
html+css+js实现step进度条效果
javascript·css·html
小白学习日记2 小时前
【复习】HTML常用标签<table>
前端·html
john_hjy2 小时前
11. 异步编程
运维·服务器·javascript
风清扬_jd2 小时前
Chromium 中JavaScript Fetch API接口c++代码实现(二)
javascript·c++·chrome
丁总学Java2 小时前
微信小程序-npm支持-如何使用npm包
前端·微信小程序·npm·node.js
yanlele2 小时前
前瞻 - 盘点 ES2025 已经定稿的语法规范
前端·javascript·代码规范
It'sMyGo2 小时前
Javascript数组研究09_Array.prototype[Symbol.unscopables]
开发语言·javascript·原型模式
懒羊羊大王呀2 小时前
CSS——属性值计算
前端·css