精读 Webpack SplitChunksPlugin 插件源码

·  阅读 1264
精读 Webpack SplitChunksPlugin 插件源码

前言

最近在做 B 端性能优化的工作,通过一些工具分析,发现我们项目构建出来的 JS Chunks 有数量偏多和部分 Chunks 体积偏大的情况。于是想要了解下通过 splitChunks 的配置是否能做到一些优化,经过阅读 Webpack 官网和网上的一些文章,虽然对 SplitChunksPlugin 有些基本的认识,但是对于一些配置项,特别是 cacheGroups 还是有些懵逼。于是,通过本地写 demo,debugger 等方式把插件的源码基本看了一遍,这里通过本文做个总结。

想要比较好地理解本文提到的内容,需要掌握以下一些基本的知识:

  • 对 Webpack 插件机制有基本的了解,对 tapable 的常用 hooks 有一定的了解;
  • 对 Webpack 构建时一些核心的数据结构,例如 compilationmodulechunk

chunkGraph 等有基本的认知。

从本文中你能了解到:

  • SplitChunksPlugin 插件的作用和常见的一些配置;
  • 怎么利用 splitChunksPlugin 插件优化项目中构建的产物;
  • 理解 Webpack 插件是怎么影响构建后的产物的。

SplitChunksPlugin 介绍

为什么需要 SplitChunksPlugin 插件

如果认真看过 Webpack 文档的小伙伴,就会知道,在 Webpack 默认构建流程中,只有以下两种情况会生成 chunks

  • Webpack 中的 entry 配置,一个 entry 配置构建后生成一个 chunk
  • 动态 import 一个模块默认也会生成独立的 chunk

那么问题来了,在一般的项目中,特别是以 SPA 项目为例,我们构建项目的入口都是从一个 index.ts 出发,如果使用默认的 Webpack 分割 chunks 的策略,那么我们整个项目的大部分模块构建都会在集中在少部分 的 chunks 里面,这明显不合理。

于是,有了 SplitChunksPlugin,它就是解决我们前面提到的问题。有些小伙伴问,但是一般我们使用Webpack 时不会配这个插件或者跟这个插件相关的配置,那么为什么我们构建出来的产物看起来比较正常?

因为 SplitChunksPlugin 目前是内置在 Webpack 构建流程中的插件,只要我们不设置 optimization.splitChunksfalse,那么 Webpack 在生产模式构建的时候(设置 mode 选项为 production)就有默认的策略帮我们做 split chunks 的工作。

SplitChunksPlugin 常用配置

这部分内容在 Webpack 官网上有详细的介绍,想了解更多可以点击这里的链接:SplitChunksPlugin,这里只介绍默认的配置和一些常见的配置项。

首先看默认配置,前面也提到过,只要我们没有将 optimization.splitChunks 设置为 false,那么 Webpack 内部就有默认的 split 优化策略,主要是通过默认的 cacheGroups 配置完成的,这部分配置放在源码目录 lib/config/default.js中,搜索 splitChunks 关键字就能找到。这里直接贴出默认的配置:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'async',
      minSize: 20000,
      minRemainingSize: 0,
      minChunks: 1,
      maxAsyncRequests: 30,
      maxInitialRequests: 30,
      enforceSizeThreshold: 50000,
      cacheGroups: {
        defaultVendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          reuseExistingChunk: true,
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

从这里我们可以知道:

  • chunks 类型来看,默认情况下只有按需引入的即值为 async 的模块才会被 split chunks 优化;
  • chunks 分割的最小体积 minSize20000字节;
  • 最大的异步请求数 maxAsyncRequests 和最大的初始化加载 chunks 数量 maxInitialRequests 都为 30,注意这个配置,这里 Webpack 是按 HTTP2 的协议场景去设置这个默认值的,如果你的项目还在用 HTTP1.0 需要注意下
  • cacheGroups 有两个默认的配置项,默认情况下,所有从 node_modules 引用的模块会优先打包成一个 chunk;如果是非 node_modules 模块,则至少要被其它模块引用两次才会分割为一个 chunk。

关于 cacheGroups 首先要明确一点,如果 cacheGroups 中的一个项配了和外层一样的选项,那么 cacheGroups 中配置项的优先级会更高。这里再补充几个 cacheGroups 中配置项说明:

  • test,通过跟 module 的 resource 字段进行比较看是否命中,还可以传 boolean、function、string 等类型;
  • priority,cacheGroups 每个项匹配的优先级,值越大,匹配的优先级越高;
  • name,生成的 chunk 名称;
  • reuseExistingChunk,设置 true,表示如果 chunk 对应的 module 已经被分割了,那么就复用这个 chunk;
  • enforce ,忽略 minSizeminChunksmaxAsyncRequestsmaxInitialRequests 等配置,创建一个新的 chunk

了解了 SplitChunksPlugin 作用和基本配置后,下面我们进入源码分析的内容。

核心源码解析

考虑到插件的源码也有 1700行左右,所以这里只拿出我觉得比较核心的一些源码进行分析,其它的细节感兴趣的小伙伴可以打开源码目录下的 lib/optimize/SplitChunksPlugin.js 自行查看。虽然整体源码不算多,但是里面也有非常多的概念和细节,建议阅读的时候通过 demo 的形式,边 debugger 边进行阅读,这样对一些关键数据结构更加清楚。

流程图

在开始分析源码之前,我们先通过一个流程图了解下整个插件的执行过程: webpack-splitChunks (2).png 这个图可能会有点长,读者可以先看一遍留个大概的印象,然后结合后面的核心源码分析,多看几遍就能更加清晰。 在流程图中,我将整个插件的执行过程划分为三个阶段:

  1. 初始化阶段,主要是初始化插件的 optionsnormalizeCacheGroups 和注册插件需要介入的核心hook:compilation.hooks.optimizeChunk

  2. 准备阶段,主要是从 compilation 对象中获取 Webpack 编译阶段完成后的 moduleschunks,构造方便更新阶段获取需要优化的 chunks 信息和其它上下文的数据结构,例如 chunkIndexMapchunksInfoMap 等数据;

  3. 更新阶段,在准备阶段后,这个阶段就是将需要优化的 chunks 以及关联的 modules,主要是存放在 chunksInfoMap 中,根据 cacheGroups 配置进行 split 分割,最后将分割出来的 chunks 更新到 chunkGraphcompilation.chunks 中。

下面分阶段来分析核心的一些源码。

初始化阶段

首先我们知道 Webpack 插件其实就是一个带有 apply 方法的类,Webpack 在初始化阶段会将内置的插件和用户配置的进行初始化并执行插件的 apply 方法,对于 SplitChunksPlugin 插件同样也不例外,我们先看这部分代码:

class SplitChunksPlugin {
	/**
	 * @param {OptimizationSplitChunksOptions=} options plugin options
	 */
	constructor(options = {}) {
		// 省略一些代码

		/** @type {SplitChunksOptions} */
		this.options = {
			chunksFilter: normalizeChunksFilter(options.chunks || "all"),
			defaultSizeTypes,
			minSize,
			minSizeReduction,
			minRemainingSize: mergeSizes(
				normalizeSizes(options.minRemainingSize, defaultSizeTypes),
				minSize
			),
			enforceSizeThreshold: normalizeSizes(
				options.enforceSizeThreshold,
				defaultSizeTypes
			),
			maxAsyncSize: mergeSizes(
				normalizeSizes(options.maxAsyncSize, defaultSizeTypes),
				maxSize
			),
			maxInitialSize: mergeSizes(
				normalizeSizes(options.maxInitialSize, defaultSizeTypes),
				maxSize
			),
			minChunks: options.minChunks || 1,
			maxAsyncRequests: options.maxAsyncRequests || 1,
			maxInitialRequests: options.maxInitialRequests || 1,
			hidePathInfo: options.hidePathInfo || false,
			filename: options.filename || undefined,
			getCacheGroups: normalizeCacheGroups(
				options.cacheGroups,
				defaultSizeTypes
			),
			getName: options.name ? normalizeName(options.name) : defaultGetName,
			automaticNameDelimiter: options.automaticNameDelimiter,
			usedExports: options.usedExports,
			// 省略一些配置
		};

		/** @type {WeakMap<CacheGroupSource, CacheGroup>} */
		this._cacheGroupCache = new WeakMap();
	}
  
  apply(compiler) {
     compiler.hooks.thisCompilation.tap("SplitChunksPlugin", compilation => {
			const logger = compilation.getLogger("webpack.SplitChunksPlugin");
			let alreadyOptimized = false;
       
			// unseal 在 compilation 接收新的 modules 的时候触发
			compilation.hooks.unseal.tap("SplitChunksPlugin", () => {
				alreadyOptimized = false;
			});
       
			// tap optimizeChunks hook
			compilation.hooks.optimizeChunks.tap(
				{
					name: "SplitChunksPlugin",
					// 传入 stage,改变 tap 回调执行的顺序,数值越大,执行时间越晚
					// reference https://github.com/webpack/tapable/blob/master/lib/Hook.js
					stage: STAGE_ADVANCED
				},
				chunks => {
					// 设置标志位,提高性能,避免在当前编译流程中重复触发
					if (alreadyOptimized) return;
					alreadyOptimized = true;
          
          // 省略后续代码
       })
    }) 
  }
}  

我们首先看 constructor 中的初始化 options 逻辑,这里没有很复杂的逻辑,主要将传进来的 options 进行 normalize 或者给默认值的处理,这里我们主要关注 getCacheGroups 的初始化,它调用了 normalizeCacheGroups 方法,并将外部配置的 cacheGroups 传入。

看下normalizeCacheGroups 的实现:

/**
 * @param {GetCacheGroups | Record<string, false|string|RegExp|OptimizationSplitChunksGetCacheGroups|OptimizationSplitChunksCacheGroup>} cacheGroups the cache group options
 * @param {string[]} defaultSizeTypes the default size types
 * @returns {GetCacheGroups} a function to get the cache groups
 */
const normalizeCacheGroups = (cacheGroups, defaultSizeTypes) => {
  // 支持传入函数
	if (typeof cacheGroups === "function") {
		return cacheGroups;
	}
  // 大部分情况我们是传 object,关注这里的处理
	if (typeof cacheGroups === "object" && cacheGroups !== null) {
		/** @type {(function(Module, CacheGroupsContext, CacheGroupSource[]): void)[]} */
		const handlers = [];
    // 遍历我们传入的 cacheGroups
		for (const key of Object.keys(cacheGroups)) {
			const option = cacheGroups[key];
			if (option === false) {
				continue;
			}
      // cacheGroups item 也可以支持传 string 或者 regexp
			if (typeof option === "string" || option instanceof RegExp) {
				const source = createCacheGroupSource({}, key, defaultSizeTypes);
				handlers.push((module, context, results) => {
					if (checkTest(option, module, context)) {
						results.push(source);
					}
				});
        cacheGroups item 也可以支持传函数
			} else if (typeof option === "function") {
				const cache = new WeakMap();
				handlers.push((module, context, results) => {
					const result = option(module);
					if (result) {
						const groups = Array.isArray(result) ? result : [result];
						for (const group of groups) {
							const cachedSource = cache.get(group);
							if (cachedSource !== undefined) {
								results.push(cachedSource);
							} else {
								const source = createCacheGroupSource(
									group,
									key,
									defaultSizeTypes
								);
								cache.set(group, source);
								results.push(source);
							}
						}
					}
				});
			} else {
        // 我们一般传入 object,所以走到这里的逻辑
        // 这里创建一个 CacheGroupSource 对象
				const source = createCacheGroupSource(option, key, defaultSizeTypes);
				handlers.push((module, context, results) => {
					if (
						checkTest(option.test, module, context) &&
						checkModuleType(option.type, module) &&
						checkModuleLayer(option.layer, module)
					) {
						results.push(source);
					}
				});
			}
		}
		/**
		 * @param {Module} module the current module
		 * @param {CacheGroupsContext} context the current context
		 * @returns {CacheGroupSource[]} the matching cache groups
		 */
    // 这里设计得很巧妙,前面构造出 handlers 数组,方便使用的时候拿到 module 和 context 等上下文
		const fn = (module, context) => {
			/** @type {CacheGroupSource[]} */
			let results = [];
			for (const fn of handlers) {
				fn(module, context, results);
			}
			return results;
		};
		return fn;
	}
	return () => null;
};

从上面的代码我们了解到:

  • cacheGroups item 是可以传 string、regex、functionplain object 的;
  • 最后它返回的是一个函数,函数的作用就是根据传入的 module 看是否匹配到相应的 cacheGroup,然后调用createCacheGroupSource 方法根据配置或者其它信息创建 cacheGroupSource 并返回。

继续看 createCacheGroupSource 做了什么?

/**
 * @param {OptimizationSplitChunksCacheGroup} options the group options
 * @param {string} key key of cache group
 * @param {string[]} defaultSizeTypes the default size types
 * @returns {CacheGroupSource} the normalized cached group
 */
const createCacheGroupSource = (options, key, defaultSizeTypes) => {
	const minSize = normalizeSizes(options.minSize, defaultSizeTypes);
	const minSizeReduction = normalizeSizes(
		options.minSizeReduction,
		defaultSizeTypes
	);
	const maxSize = normalizeSizes(options.maxSize, defaultSizeTypes);
	return {
		key,
		priority: options.priority,
		getName: normalizeName(options.name),
		chunksFilter: normalizeChunksFilter(options.chunks),
		enforce: options.enforce,
		minSize,
		minSizeReduction,
		minRemainingSize: mergeSizes(
			normalizeSizes(options.minRemainingSize, defaultSizeTypes),
			minSize
		),
		enforceSizeThreshold: normalizeSizes(
			options.enforceSizeThreshold,
			defaultSizeTypes
		),
		maxAsyncSize: mergeSizes(
			normalizeSizes(options.maxAsyncSize, defaultSizeTypes),
			maxSize
		),
		maxInitialSize: mergeSizes(
			normalizeSizes(options.maxInitialSize, defaultSizeTypes),
			maxSize
		),
		minChunks: options.minChunks,
		maxAsyncRequests: options.maxAsyncRequests,
		maxInitialRequests: options.maxInitialRequests,
		filename: options.filename,
		idHint: options.idHint,
		automaticNameDelimiter: options.automaticNameDelimiter,
		reuseExistingChunk: options.reuseExistingChunk,
		usedExports: options.usedExports
	};
};

实际上就是根据我们配置的 cacheGroups,创建出一个类似的对象,方便后续消费。

相对来说这部分初始化逻辑还是很容易看懂的,下面我们看下注册 optimizeChunks 钩子的部分。

apply(compiler) {
  compiler.hooks.thisCompilation.tap("SplitChunksPlugin", compilation => {
		let alreadyOptimized = false;
       
		 //  unseal 在 compilation 接收新的 modules 的时候触发
     compilation.hooks.unseal.tap("SplitChunksPlugin", () => {
			 alreadyOptimized = false;
		 });
       
		 // tap optimizeChunks hook
		 compilation.hooks.optimizeChunks.tap(
       {
					name: "SplitChunksPlugin",
					// 传入 stage,改变 tap 回调执行的顺序,数值越大,执行时间越晚
					// reference https://github.com/webpack/tapable/blob/master/lib/Hook.js
					stage: STAGE_ADVANCED
				},
				chunks => {
					// 设置标志位,提高性能,避免在当前编译流程中重复触发
					if (alreadyOptimized) return;
					alreadyOptimized = true;
          
          // 省略后续代码
       })
    }) 
  }

关键的几个点,我在代码里也通过注释说明了。注册 thisCompilation hook 拿到 compilation 对象没什么好说的,这里值得注意的是:

  • 引入了 alreadyOptimized 标志位进行非必要的重复的优化阻断,在一个复杂的项目中,在每次构建过程中可能有成百上千的 modules,如果进行重复的优化势必会带来一定的性能问题。只要已经优化完成,只有在 compilation接收新的 modules 的时候重置该标志位;
  • 注册 optimizeChunks钩子进行 chunks 优化,这里值得我们注意的一个点是,这里传入了stage参数。在 tapable的设计中,调用 tap 时可以通过此参数改变回调执行的顺序,数值越大,执行时机越晚。感兴趣的小伙伴可以通过点击下面的链接查看对应的源码:tapable,这里就不展开了。

以上就是初始化阶段比较核心的一些源码介绍,整体来看,这个部分的源码相对来说还是比较好理解,下面我们继续看准备阶段的源码。

准备阶段

这个阶段的工作就是读取 compilation 对象中的 chunksmodules 构造出一系列用于更新阶段的数据结构,主要有以下几个比较重要的数据结构:

  • chunkIndexMap(Map<Chunk, bigint>),这个 Map 主要是存储为构建出来的每个 chunk 生成index 的数据,方面后面消费 index 值;
  • chunksInfoMap(Map<string, ChunksInfoItem>),这个 Map 主要存储需要被优化的 chunks、对应的 modulescacheGroup 等信息。

当然在整个过程中,还有一些过渡的数据结构,例如 groupedByExportsMapselectedChunksCacheByChunksSet 等,就不一一介绍了。我们的关注点主要放在 chunkIndexMapchunksInfoMap 上。

chunksIndexMap

首先来看 chunksIndexMap,在订阅 optimizeChunks hook 后,第一件事就是为 chunks 生成 index,并存储在 chunksIndexMap 中:

// Give each selected chunk an index (to create strings from chunks)
/** @type {Map<Chunk, bigint>} */
const chunkIndexMap = new Map();
const ZERO = BigInt("0");
const ONE = BigInt("1");
const START = ONE << BigInt("31");
let index = START;
for (const chunk of chunks) {
	chunkIndexMap.set(
		chunk,
		// 使用 BigInt 生成 index,可能是因为 index 会比较大,所以正常的 number 类型会溢出
		index | BigInt((Math.random() * 0x7fffffff) | 0)
	);
	index = index << ONE;
}
/**
 * @param {Iterable<Chunk>} chunks list of chunks
 * @returns {bigint | Chunk} key of the chunks
 */
const getKey = chunks => {
	const iterator = chunks[Symbol.iterator]();
	let result = iterator.next();
	if (result.done) return ZERO;
	const first = result.value;
	result = iterator.next();
	if (result.done) return first;
	let key =
		chunkIndexMap.get(first) | chunkIndexMap.get(result.value);
	while (!(result = iterator.next()).done) {
		const raw = chunkIndexMap.get(result.value);
		key = key ^ raw;
	}
	return key;
};
const keyToString = key => {
	if (typeof key === "bigint") return key.toString(16);
	return chunkIndexMap.get(key).toString(16);

这里比较有意思的点是,index 生成使用了 BigInt 这样的数据类型,平时项目一般很少用。在一个复杂的项目中,触发一次构建,chunks 的数量有可能超过 JS 最大的安全数吗?严谨一点,我觉得有可能,但是可能性不大。所以这里使用 BigInt 的意图我也没有完全明白,当然从生成 index 的算法来看,作者使用了大量的位操作符结合 Math.random 我们应该就能猜到这里也是为了生成唯一的 index。当然,也不用太纠结这个点,感兴趣可以再深入研究,我们继续往下看。

接下来是 getKey 的实现,这个函数的逻辑没有很复杂,它接收一个可迭代的 chunks list 参数,然后使用前面生成的 chunksIndexMap,不断执行迭代器的 next 方法,然后将 chunk 对应的 index 通过按位异或运算符进行计算。所以,简单总结函数的作用就是为一组 chunks 生成唯一的 key

keyToString 方法比较简单,这里不做过多介绍。后面逻辑消费的时候主要是调用 getKeykeyToString 方法,所以我们可以看完后续代码使用这两个方法的时候再去理解实现,可能会更加清晰。

看完这部分,中间有很多代码都是一些数据结构的定义和函数的封装,在没有看到使用场景的时候,我们盲目去看,非常低效。所以我们可以继续往下看,看到函数使用的地方时,可以根据是否需要再跳到函数看具体实现。

chunksInfoMap

实际上,跳过一系列封装的函数,我们就来到了 chunksInfoMap 定义的地方,实际上前面定义了很多的数据结构和方法,主要是为了构造出这个数据,这个数据结构里面的 moduleschunks 就是后面真正会触发 split chunks 优化策略的数据,也就是更新阶段主要是围绕这个数据结构进行处理。

我们直接看遍历所有 compilation.modules的代码:

const context = {
  moduleGraph,
	chunkGraph
};
// Walk through all modules
for (const module of compilation.modules) {
	// Get cache group
	let cacheGroups = this.options.getCacheGroups(module, context);
	if (!Array.isArray(cacheGroups) || cacheGroups.length === 0) {
		continue;
  }  
  
	// Prepare some values (usedExports = false)
	const getCombs = memoize(() => {
		const chunks = chunkGraph.getModuleChunksIterable(module);
		const chunksKey = getKey(chunks);
		return getCombinations(chunksKey);
  })  

	// Prepare some values (usedExports = true)
	const getCombsByUsedExports = memoize(() => {
		// fill the groupedByExportsMap
		getExportsChunkSetsInGraph();
		/** @type {Set<Set<Chunk> | Chunk>} */
		const set = new Set();
		const groupedByUsedExports = groupedByExportsMap.get(module);
		for (const chunks of groupedByUsedExports) {
			const chunksKey = getKey(chunks);
			for (const comb of getExportsCombinations(chunksKey))
				set.add(comb);
		}
		return set;
  })  

	let cacheGroupIndex = 0;
	for (const cacheGroupSource of cacheGroups) {
		const cacheGroup = this._getCacheGroup(cacheGroupSource)
		const combs = cacheGroup.usedExports
			? getCombsByUsedExports()
			: getCombs();
		// For all combination of chunk selection
		for (const chunkCombination of combs) {
			// Break if minimum number of chunks is not reached
			const count =
				chunkCombination instanceof Chunk ? 1 : chunkCombination.size;
				// 如果该 chunk 对用的 module 被引用的次数小于 cacheGroups 配置的 minChunks,则不单独 split
			if (count < cacheGroup.minChunks) continue;
			// Select chunks by configuration
			const { chunks: selectedChunks, key: selectedChunksKey } =
				getSelectedChunks(chunkCombination, cacheGroup.chunksFilt
			addModuleToChunksInfoMap(
				cacheGroup,
				cacheGroupIndex,
				selectedChunks,
				selectedChunksKey,
				module
			);
		}
		cacheGroupIndex++;
	}
}					
				

作者写了大量的注释,所以很多代码的意图还是容易看懂的。

首先是调用options.getCacheGroups 的方法,看模块是否有对应的 cacheGroups,没有则跳过本次循环。如果有匹配到,则继续执行,首先定义了两个内联函数,先跳过,看到使用的地方再回头看。

接着就是遍历匹配到的 cacheGroups,通过 cacheGroupSource 拿到 cacheGroup_getCacheGroup 这个方法作用就是生成 cacheGroup 并通过 WeakMap 缓存对应的 cacheGroup

继续看 cacheGroup 是否配置了 usedExport 参数调用不同的内联函数拿到 combs,那么这个配置是做什么的了?实际上它也是 Webpack Tree Shaking 的一个策略,设置这个值为 true,如果对于没使用的 export 模块,就会被 shaking 掉,默认是 true。具体可以看官网文档:Webpack Tree shaking

我们就按默认的 usedExport配置看下 getCombsByUsedExports的实现:

// Prepare some values (usedExports = true)
	const getCombsByUsedExports = memoize(() => {
		// fill the groupedByExportsMap
		getExportsChunkSetsInGraph();
		/** @type {Set<Set<Chunk> | Chunk>} */
		const set = new Set();
		const groupedByUsedExports = groupedByExportsMap.get(module);
		for (const chunks of groupedByUsedExports) {
			const chunksKey = getKey(chunks);
			for (const comb of getExportsCombinations(chunksKey))
				set.add(comb);
		}
		return set;
  }) 

这里首先调用了 getExportsChunkSetsInGraph 方法,我们要去看下它的实现,看注释主要是为了构造 groupedByExportsMap 数据:

/** @type {Map<Module, Iterable<Chunk[]>>} */
const groupedByExportsMap = new Map();

const getExportsChunkSetsInGraph = memoize(() => {
	/** @type {Map<bigint, Set<Chunk>>} */
	const chunkSetsInGraph = new Map();
	/** @type {Set<Chunk>} */
	const singleChunkSets = new Set();
	for (const module of compilation.modules) {
		const groupedChunks = Array.from(groupChunksByExports(module));
		groupedByExportsMap.set(module, groupedChunks);
		for (const chunks of groupedChunks) {
			if (chunks.length === 1) {
				singleChunkSets.add(chunks[0]);
			} else {
				const chunksKey = /** @type {bigint} */ (getKey(chunks));
				if (!chunkSetsInGraph.has(chunksKey)) {
					chunkSetsInGraph.set(chunksKey, new Set(chunks));
				}
			}
		}
	}
	return { chunkSetsInGraph, singleChunkSets };
})  

/**
 * @param {Module} module the module
 * @returns {Iterable<Chunk[]>} groups of chunks with equal exports
 */
const groupChunksByExports = module => {
	const exportsInfo = moduleGraph.getExportsInfo(module);
	const groupedByUsedExports = new Map();
	for (const chunk of chunkGraph.getModuleChunksIterable(module)) {
		const key = exportsInfo.getUsageKey(chunk.runtime);
		const list = groupedByUsedExports.get(key);
		if (list !== undefined) {
			list.push(chunk);
		} else {
			groupedByUsedExports.set(key, [chunk]);
		}
	}
	return groupedByUsedExports.values();
};

构造 groupedByExportsMap 本身的逻辑不是很复杂,但是里面使用到了大量的数据结构,例如 moudleGraphchunkGraphexportsInfo 等,而每一个数据结构在 Webpack 源码中对应了一个类,这里直接给出它们各自的作用,如果跳出 SplitChunksPlugin 去单独看这几个类的代码,还需要花上不少的时间:

  • moudleGraph,主要是存储 Webpack 构建过程中模块和模块依赖、模块和其关联的模块依赖图等数据结构;
  • chunkGraph,主要是存储 Webpack 编译阶段结束后,记录 chunk 和 对应 modules 的一些数据结构,在 seal 阶段后被赋值,可以通过 compilation 对象获取到。
  • exportInfos,记录 moduleexport 信息,方便找到 module 被引用的关系。

而这里的 groupedByExportsMap 实际上存储的是模块和其对应的 chunks 的集合关系,也就是说一个 module 可能是被多个 chunks 引用,所以这就会导致重复打包。对于大体积的一些 module,如果重复打包在多个 chunks 里面,实际上是不利于我们利用 HTTP 缓存的,这也是 splitChunksPlugin 的另外一个作用:减少重复打包,将大的模块单独 split,也便于我们做缓存优化

继续看代码:

for (const chunks of groupedByUsedExports) {
  const chunksKey = getKey(chunks);
	for (const comb of getExportsCombinations(chunksKey))
		set.add(comb);
}

这里就用到了我们前面提到的 getKey 方法,这里就是传入了 chunks 数组,然后为其生成 chunksKey。接着是遍历通过getExportsCombinations 根据 chunksKey 拿到 chunks 数组或者集合,然后存到新的集合中。

我们回到遍历 cacheGroups代码:

// For all combination of chunk selection
for (const chunkCombination of combs) {
	// Break if minimum number of chunks is not reached
	const count =
		chunkCombination instanceof Chunk ? 1 : chunkCombination.size;
		// 如果该 chunk 对应的 module 被引用的次数小于 cacheGroups 配置的 minChunks,则不单独 split
	if (count < cacheGroup.minChunks) continue;
	// Select chunks by configuration
	const { chunks: selectedChunks, key: selectedChunksKey } =
		getSelectedChunks(chunkCombination, cacheGroup.chunksFiter)
	addModuleToChunksInfoMap(
		cacheGroup,
		cacheGroupIndex,
		selectedChunks,
		selectedChunksKey,
		module
	);
}

这段代码需要注意的就是:

  • 对于minChunks 的判断,这是 cacheGroup 的一个配置项,表示一个模块被共享的最小次数,只有大于等于这个数,才会被 split 优化;
  • cacheGroup.chunksFiter 是一个比较少用的配置,所以关于 getSelectedChunks 的逻辑就不详细过了;
  • 最后调用 addModuleToChunksInfoMap 方法,这是更新 chunksInfoMap 的核心方法。

好家伙,看了这么久才进入到主题。我们看下addModuleToChunksInfoMap的实现:

/**
 * @param {CacheGroup} cacheGroup the current cache group
 * @param {number} cacheGroupIndex the index of the cache group of 
 * @param {Chunk[]} selectedChunks chunks selected for this module

 * @param {bigint | Chunk} selectedChunksKey a key of selectedChunk
 * @param {Module} module the current module
 * @returns {void}
 */
const addModuleToChunksInfoMap = (
	cacheGroup,
	cacheGroupIndex,
	selectedChunks,
	selectedChunksKey,
	module
) => {
	// Break if minimum number of chunks is not reached
	if (selectedChunks.length < cacheGroup.minChunks) return;
	// Determine name for split chunk
	const name = cacheGroup.getName(
		module,
		selectedChunks,
		cacheGroup.key
	);
	// Check if the name is ok
	const existingChunk = compilation.namedChunks.get(name);
	if (existingChunk) {
		const parentValidationKey = `${name}|${
			typeof selectedChunksKey === "bigint"
				? selectedChunksKey
				: selectedChunksKey.debugId
		}`;
		const valid = alreadyValidatedParents.get(parentValidationKey);
		if (valid === false) return;
		if (valid === undefined) {
			// Module can only be moved into the existing chunk if the exist
			// is a parent of all selected chunks
			let isInAllParents = true;
			/** @type {Set<ChunkGroup>} */
			const queue = new Set();
			for (const chunk of selectedChunks) {
				for (const group of chunk.groupsIterable) {
					queue.add(group);
				}
			}
			for (const group of queue) {
				if (existingChunk.isInGroup(group)) continue;
				let hasParent = false;
				for (const parent of group.parentsIterable) {
					hasParent = true;
					queue.add(parent);
				}
				if (!hasParent) {
					isInAllParents = false;
				}
			}
			const valid = isInAllParents;
			alreadyValidatedParents.set(parentValidationKey, valid);
			if (!valid) {
				if (!alreadyReportedErrors.has(name)) {
					alreadyReportedErrors.add(name);
					compilation.errors.push(
						new WebpackError(
							"SplitChunksPlugin\n" +
								`Cache group "${cacheGroup.key}" conflicts with existing ch
								`Both have the same name "${name}" and existing chunk is no
								"Use a different name for the cache group or make sure that
								'HINT: You can omit "name" to automatically create a name.\
								"BREAKING CHANGE: webpack < 5 used to allow to use an entry
								"This is no longer allowed when the entrypoint is not a par
								"Remove this entrypoint and add modules to cache group's 't
								"If you need modules to be evaluated on startup, add them t
								"See migration guide of more info."
						)
					);
				}
				return;
			}
		}
	}
	// Create key for maps
	// When it has a name we use the name as key
	// Otherwise we create the key from chunks and cache group key
	// This automatically merges equal names
	const key =
		cacheGroup.key +
		(name
			? ` name:${name}`
			: ` chunks:${keyToString(selectedChunksKey)}`);
	// Add module to maps
	let info = chunksInfoMap.get(key);
	if (info === undefined) {
		chunksInfoMap.set(
			key,
			(info = {
				modules: new SortableSet(
					undefined,
					compareModulesByIdentifier
				),
				cacheGroup,
				cacheGroupIndex,
				name,
				sizes: {},
				chunks: new Set(),
				reuseableChunks: new Set(),
				chunksKeys: new Set()
			})
		);
	}
	const oldSize = info.modules.size;
	info.modules.add(module);
	if (info.modules.size !== oldSize) {
		for (const type of module.getSourceTypes()) {
			info.sizes[type] = (info.sizes[type] || 0) + module.size(type);
		}
	}
	const oldChunksKeysSize = info.chunksKeys.size;
	info.chunksKeys.add(selectedChunksKey);
	if (oldChunksKeysSize !== info.chunksKeys.size) {
		for (const chunk of selectedChunks) {
			info.chunks.add(chunk);
		}
	}
};				

对于边界的逻辑判断,我们可以跳过,关注核心的一些处理和更新逻辑,主要分两步:

  • 第一会看下当前的 cacheGroup 配置的名称 name 是否已经跟目前的已经存在的 namedChunks 冲突,如果冲突了会进行一系列的校验合法性,然后最终决定是否要创建一个 WepackError 更新到 compilation.errors 中;
  • 第二就是更新 chunksInfoMap 逻辑,更新 sizesmoduleschunks 等信息。

到这里,chunksInfoMap 就有数据了,准备阶段也接近尾声。

继续看代码,发现后面还有 chunksInfoMap 更新逻辑:

/**
 * @param {ChunksInfoItem} info entry
 * @param {string[]} sourceTypes source types to be removed
 */
const removeModulesWithSourceType = (info, sourceTypes) => {
	for (const module of info.modules) {
		const types = module.getSourceTypes();
		if (sourceTypes.some(type => types.has(type))) {
			info.modules.delete(module);
			for (const type of types) {
				info.sizes[type] -= module.size(type);
			}
		}
	}

/**
 * @param {ChunksInfoItem} info entry
 * @returns {boolean} true, if entry become empty
 */
const removeMinSizeViolatingModules = info => {
	if (!info.cacheGroup._validateSize) return false;
	const violatingSizes = getViolatingMinSizes(
		info.sizes,
		info.cacheGroup.minSize
	);
	if (violatingSizes === undefined) return false;
	removeModulesWithSourceType(info, violatingSizes);
	return info.modules.size === 0;
};

// Filter items were size < minSize
for (const [key, info] of chunksInfoMap) {
	if (removeMinSizeViolatingModules(info)) {
		chunksInfoMap.delete(key);
	} else if (
		!checkMinSizeReduction(
			info.sizes,
			info.cacheGroup.minSizeReduction,
			info.chunks.size
		)
	) {
		chunksInfoMap.delete(key);
	}
}

这一步是为了处理 cacheGroupminSize配置,如果一个 module命中了 cacheGroup,但是 module的体积小于配置的 minSize,这样的模块也不会做 split 优化。

到这里,对于 chunksInfoMap的准备工作已经完成了,接下来进入更新阶段,开始消费 chunksInfoMap

更新阶段

更新阶段主要是消费 chunksInfoMap,然后更新 compilation.chunkschunkGraph等。 核心的代码也有三百多行,我们分小块处理逻辑看:

while (chunksInfoMap.size > 0) {
  // Find best matching entry
	let bestEntryKey;
	let bestEntry;
	for (const pair of chunksInfoMap) {
    const key = pair[0];
		const info = pair[1];
		if (
      bestEntry === undefined ||
			compareEntries(bestEntry, info) < 0
		) {
			bestEntry = info;
			bestEntryKey = key;
		}
  }
  
  const item = bestEntry;
  chunksInfoMap.delete(bestEntryKey);
  // 省略后面的代码
}  

整体处理就是开始通过 while 遍历 chunsInfoMap,然后对每一项进行处理。这段逻辑是找出最合适的 bestEntrybestEntryKey,找到后,会从 chunksInfoMap删除该项,核心的逻辑是 compareEntries(bestEntry, info),我们来看下 compareEntries的实现:

/**
 * @param {ChunksInfoItem} a item
 * @param {ChunksInfoItem} b item
 * @returns {number} compare result
 */
const compareEntries = (a, b) => {
	// 1. by priority
	const diffPriority = a.cacheGroup.priority - b.cacheGroup.priority;
	if (diffPriority) return diffPriority;
	// 2. by number of chunks
	const diffCount = a.chunks.size - b.chunks.size;
	if (diffCount) return diffCount;
	// 3. by size reduction
	const aSizeReduce = totalSize(a.sizes) * (a.chunks.size - 1);
	const bSizeReduce = totalSize(b.sizes) * (b.chunks.size - 1);
	const diffSizeReduce = aSizeReduce - bSizeReduce;
	if (diffSizeReduce) return diffSizeReduce;
	// 4. by cache group index
	const indexDiff = b.cacheGroupIndex - a.cacheGroupIndex;
	if (indexDiff) return indexDiff;
	// 5. by number of modules (to be able to compare by identifier)
	const modulesA = a.modules;
	const modulesB = b.modules;
	const diff = modulesA.size - modulesB.size;
	if (diff) return diff;
	// 6. by module identifiers
	modulesA.sort();
	modulesB.sort();
	return compareModuleIterables(modulesA, modulesB);
};

比较逻辑就是根据cacheGroupprioritypriority就是我们在介绍 SplitChunksPlugin 配置时提到的配置项)、chunks数量、chunks的 size、cacheGroupIndexmodules数量等按优先级顺序,依次对比,然后找出最合适的 bestEntry,然后赋值给 item,后续使用 item进行处理。

继续往下看:

let chunkName = item.name;
// Variable for the new chunk (lazy created)
/** @type {Chunk} */
let newChunk;
// When no chunk name, check if we can reuse a chunk instead of creating a new one
let isExistingChunk = false;
if (chunkName) {
	const chunkByName = compilation.namedChunks.get(chunkName);
	if (chunkByName !== undefined) {
		newChunk = chunkByName;
		const oldSize = item.chunks.size;
		item.chunks.delete(newChunk);
		isExistingChunk = item.chunks.size !== oldSize;
	}
} else if (item.cacheGroup.reuseExistingChunk) {
  // 省略代码
}

这里的逻辑主要是处理配置了 chunkName 的情况,如果已经存在相同的 nameChunk,则直接赋值给 newChunk,并将该 newChunkitemchunks 中移除,更新 isExistingChunk 的值。

看另外一个 else if 分支的逻辑:

if (chunkName) {
  // 省略代码
} else if (item.cacheGroup.reuseExistingChunk) {
  outer: for (const chunk of item.chunks) {
	if (
		chunkGraph.getNumberOfChunkModules(chunk) !==
		item.modules.size
	) {
		continue;
	}
	if (
		item.chunks.size > 1 &&
		chunkGraph.getNumberOfEntryModules(chunk) > 0
	) {
		continue;
	}
	for (const module of item.modules) {
		if (!chunkGraph.isModuleInChunk(module, chunk)) {
			continue outer;
		}
	}
	if (!newChunk || !newChunk.name) {
		newChunk = chunk;
	} else if (
		chunk.name &&
		chunk.name.length < newChunk.name.length
	) {
		newChunk = chunk;
	} else if (
		chunk.name &&
		chunk.name.length === newChunk.name.length &&
		chunk.name < newChunk.name
	) {
		newChunk = chunk;
	}
}
if (newChunk) {
	item.chunks.delete(newChunk);
	chunkName = undefined;
	isExistingChunk = true;
	isReusedWithAllModules = true;
}
}

reuseExistingChunk 配置在介绍 SplitChunksPlugin 的时候也介绍过,主要作用是:如果发现模块对应的 chunk 已经被 split 过了,则直接复用该 chunk

这里的核心逻辑是检查 item 中的 chunks,然后跟 newChunk 进行比较,然后最后发现要是 newChunk 不为空,然后跟从 item 中删除 newChunk,然后更新 isExistingChunkisReusedWithAllModules 的值为 true

接下来处理 maxRequest 的情况:

const enforced =
	item.cacheGroup._conditionalEnforce &&
	checkMinSize(item.sizes, item.cacheGroup.enforceSizeThreshold)
const usedChunks = new Set(item.chunks)
// Check if maxRequests condition can be fulfilled
if (
	!enforced &&
	(Number.isFinite(item.cacheGroup.maxInitialRequests) ||
		Number.isFinite(item.cacheGroup.maxAsyncRequests))
) {
	for (const chunk of usedChunks) {
		// respect max requests
		const maxRequests = chunk.isOnlyInitial()
			? item.cacheGroup.maxInitialRequests
			: chunk.canBeInitial()
			? Math.min(
					item.cacheGroup.maxInitialRequests,
					item.cacheGroup.maxAsyncRequests
			  )
			: item.cacheGroup.maxAsyncRequests;
		if (
			isFinite(maxRequests) &&
			getRequests(chunk) >= maxRequests
		) {
			usedChunks.delete(chunk);
		}
	}
}

前面介绍配置的时候介绍了 maxInitialRequestsmaxAsyncRequests 决定了一个 entry 对应的最大 chunks 数,也就是对用的 HTTP Request 数量。这里的逻辑就是处理这个 case,如果超过了,则需要删除 item 中的一些 chunks

接下来,是异常逻辑处理:

// Were some (invalid) chunks removed from usedChunks?
// => readd all modules to the queue, as things could have been changed
if (usedChunks.size < item.chunks.size) {
	if (isExistingChunk) usedChunks.add(newChunk);
	if (usedChunks.size >= item.cacheGroup.minChunks) {
		const chunksArr = Array.from(usedChunks);
		for (const module of item.modules) {
			addModuleToChunksInfoMap(
				item.cacheGroup,
				item.cacheGroupIndex,
				chunksArr,
				getKey(usedChunks),
				module
			);
		}
	}
	continue;
}

如果误删了一些 chunk,需要在这里通过 addModuleToChunksInfoMap重新更新回去,不知道什么时候可能出现这种情况,可能只是做一个防御编程,防止丢掉一些 chunk

接下来是处理如果只有一个 chunk被留下的情况:

// Validate minRemainingSize constraint when a single chunk is left over
if (
	!enforced &&
	item.cacheGroup._validateRemainingSize &&
	usedChunks.size === 1
) {
	const [chunk] = usedChunks;
	let chunkSizes = Object.create(null);
	for (const module of chunkGraph.getChunkModulesIterable(chunk)) {
		if (!item.modules.has(module)) {
			for (const type of module.getSourceTypes()) {
				chunkSizes[type] =
					(chunkSizes[type] || 0) + module.size(type);
			}
		}
	}
	const violatingSizes = getViolatingMinSizes(
		chunkSizes,
		item.cacheGroup.minRemainingSize
	);
	if (violatingSizes !== undefined) {
		const oldModulesSize = item.modules.size;
		removeModulesWithSourceType(item, violatingSizes);
		if (
			item.modules.size > 0 &&
			item.modules.size !== oldModulesSize
		) {
			// queue this item again to be processed again
			// without violating modules
			chunksInfoMap.set(bestEntryKey, item);
		}
		continue;
	}
}

就会更新 itemchunksInfoMap,这是特殊逻辑,这里不做详细的介绍。

再接下来就到了真正的更新逻辑

// Create the new chunk if not reusing one
// 如果不是重复利用已存在的 chunk,就会调用 addChunk 方法创建一个 chunk
if (newChunk === undefined) {
	newChunk = compilation.addChunk(chunkName);
}
// Walk through all chunks
// 核心调用,从使用了 newChunk 的 chunk 将 newChunk 分离出来
for (const chunk of usedChunks) {
	// Add graph connections for splitted chunk
	chunk.split(newChunk);
}  

// ============ 这部分只是更新一些 note 信息到 chunk 
// Add a note to the chunk
newChunk.chunkReason =
	(newChunk.chunkReason ? newChunk.chunkReason + ", " : "") +
	(isReusedWithAllModules
		? "reused as split chunk"
		: "split chunk");
if (item.cacheGroup.key) {
	newChunk.chunkReason += ` (cache group: ${item.cacheGroup.key})`;
}
if (chunkName) {
	newChunk.chunkReason += ` (name: ${chunkName})`;
}
if (item.cacheGroup.filename) {
	newChunk.filenameTemplate = item.cacheGroup.filename;
}
if (item.cacheGroup.idHint) {
	newChunk.idNameHints.add(item.cacheGroup.idHint);
}
// ===================  
// 存在 reused chunk 时才会为 true 
if (!isReusedWithAllModules) {
	// Add all modules to the new chunk
	for (const module of item.modules) {
		if (!module.chunkCondition(newChunk, compilation)) continue;
		// Add module to new chunk
		chunkGraph.connectChunkAndModule(newChunk, module);
		// Remove module from used chunks
		for (const chunk of usedChunks) {
			chunkGraph.disconnectChunkAndModule(chunk, module);
		}
	}
} else {
	// Remove all modules from used chunks
	for (const module of item.modules) {
		for (const chunk of usedChunks) {
			chunkGraph.disconnectChunkAndModule(chunk, module);
		}
	}
}

核心的逻辑分为三个步骤:

  • 第一,如果不是重用已经存在的 chunk,则需要调用 compilation.addChunk 新建一个 newChunk
  • 第二,从 usedChunksplitnewChunk,这就是插件核心的 split 动作的核心调用;
  • 第三,调用 chunkGraphconnectChunkAndModule 方法建立 modulenewChunk 的关系;调用 disconnectChunkAndModule 清除 usedChunkmodule 的关系,因为已经 split 新的 chunk,所以对应的 modules 关系需要被清理掉。

因为 compilation.addChunkchunk.split 等方法从语义上比较容易理解,这里就不详细介绍它们的详细实现,严格来说它们不属于 SplitChunkPlugin 的实现,感兴趣的小伙伴可以自行去看这部分源码。

到这里,更新阶段的核心实现已经基本讲完了。最后要提一下的就是我们流程图中的后面对于 maxSize的判断,实际上是要在 cacheGroup 中配置了才会有。如果配置了这个选项,它表示当我们 split 出的 chunk 的体积大于配置的 maxSize 时,就会重新走到 compilation.addChunkchunk.split 逻辑,其处理的逻辑是类似的,所以这里就不再介绍了。

更新的阶段的处理逻辑思路还是比较清晰的,但是对于一些具体的方法实现(命名的语义上还是比较易懂的),可能还需要结合 Webpack 其它的数据结构来看,在看这部分源码的时候可以根据个人兴趣选择性的看一些,因为如果按图索骥看完所有的数据结构,会需要花点时间

总结

Webpack 是基于 tapable 实现的插件架构,其插件架构通过 apply 方法暴露出 compiler 对象,让我们可以监听到各阶段的 hooks,从而介入对构建产物进行直接修改从而达到一些优化的目的。 SplitChuksPlugin 的设计,也让我们认识到 Webpack 的高扩展性。

最后总结下本文的一些内容:

  • SplitChunksPlugin 是 Webpack 内置的插件,只要不配置 optimization.splitChunksfalseWebpack 就提供了默认的 cacheGroups 帮助我们做更加合理的 split chunks 策略;
  • 我们也可以根据项目的实际情况,定制一些 cacheGroups 策略,分割一些比较大的 chunks 和通用的模块,例如图表库,这样也可以跨页面共享这些 chunks,充分利用 HTTP 缓存;
  • 在 Webpack 构建过程中的不同阶段,我们可以拿到 moduleschunks 信息,然后进行一些干预,做一些优化或者其它监控等功能,这个点也是我参与的性能优化专项后续的规划中想做的工作之一。

Reference

分类:
前端
分类:
前端
收藏成功!
已添加到「」, 点击更改