浅析 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...

相关推荐
我是小路路呀6 分钟前
vue开始时间小于结束时间,时间格式:年月日时分
前端·javascript·vue.js
虾球xz8 分钟前
游戏引擎学习第201天
前端·学习·游戏引擎
我自纵横202319 分钟前
JavaScript 中常见的鼠标事件及应用
前端·javascript·css·html·计算机外设·ecmascript
li_Michael_li20 分钟前
Vue 3 模板引用(Template Refs)详解与实战示例
前端·javascript·vue.js
excel23 分钟前
webpack 核心编译器 十五 节
前端
excel28 分钟前
webpack 核心编译器 十六 节
前端
雪落满地香2 小时前
css:圆角边框渐变色
前端·css
风无雨4 小时前
react antd 项目报错Warning: Each child in a list should have a unique “key“prop
前端·react.js·前端框架
人无远虑必有近忧!4 小时前
video标签播放mp4格式视频只有声音没有图像的问题
前端·video
记得早睡~8 小时前
leetcode51-N皇后
javascript·算法·leetcode·typescript