深入理解 Webpack5【二】:揭开前端编译优化的利刃 SplitChunkPlugin
为了深入学习 Webpack,作者想从对于前端程序员来说最熟悉的 Loaders 和 Plugins 来进行深入剖析,从而踏入 Webpack 世界的大门。本文也是这个系列中的第二篇文章。
Plugins 可以在 Webpack 构建生命周期的各个阶段中运行。插件的核心是"钩子",这是 Webpack 的关键概念,整个插件机制就是基于钩子回调来影响编译状态的。Webpack 使用 tapable
来实现灵活多样的钩子机制,这里就不做多介绍。可见我之前发过的关于 tapable
源码解析的文章。
Webpack 构建的生命周期内存在着 200+ 个 Hooks,本文不可能对其倾尽笔墨,就以前端编译性能优化的第一大利刃 SplitChunkPlugin
为例子来窥探一下 Plugins 的威力究竟有多大吧。
Hi~我是盐焗乳鸽还要香锅。这是【深入理解 Webpack5 系列】文章的第二篇,希望这篇文章能给所有读者一些收获和感悟,本人也会继续深入学习 Webpack,继续输出更多有价值有深度的文章。
重要概念
首先我们要知道,在 Webpack 中,所有原始资源都以 Module
作为单元进行流转,但它只是在 Webpack 前半段解决资源【如何读】的问题,而在打包阶段,Chunk
则是输出阶段的基础组织单位。
SplitChunkPlugin
根据配置的优化规则,将一系列的 Chunks
分离并合并,重新生成一批性能更高的 chunks(拆成更细的 chunks 更加充分利用浏览器并行加载的特性、避免重复打包公共 chunk)。
Chunk
:封装一个或者多个Module
ChunkGroup
:由一个或者多个Chunk
组成,一个ChunkGroup
可以是其它ChunkGroup
的 parent 或者 childEntryPoint
:是入口类型的ChunkGroup
,包含了入口Chunk
开始
首先,我们要明白 SplitChunkPlugin
的默认规则,通过webpack5 SplitChunkPlugin 官网我们可以知道,默认规则如下:
js
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,
},
},
},
},
};
简单来说,默认情况(Webpack 的默认配置)下 Webpack 会根据下述条件自动进行代码块分割:
- 共享模块(至少被引用 2 次)或者
node_modules
模块 - 新代码块大于
20kb
- 按需加载块时的最大并行请求数将低于或等于
30
- 初始页面加载时的最大并行请求数将低于或等于
30
「注意」 :默认情况下只有 【按需加载模块】 会根据上方条件进行打包优化。而且异步加载的文件会被拆分出一个独立 chunk。
全部是同步加载
js
// a.js
export const a = 1;
// index.js
import { a } from "./a.js";
import chunk from "lodash-es/chunk.js";
console.log(a);
console.log(chunk([1, 2, 3], 2));
由于没有按需加载的模块,因此全部打包入一个 Chunk 中:
异步加载 loadsh/chunk
js
// a.js
export const a = 1;
// index.js
import { a } from "./a.js";
import("lodash-es/chunk.js").then(res => {
console.log(res.default([1, 2, 3]));
});
console.log(a);
由于loadsh
是异步加载的,并且成功匹配上cacheGroup.test
中的node_modules
,因此被拆分出来了独立的 Chunk。
我们平时使用 React.Lazy
异步加载的模块会被拆分出来。
公共模块(大小大于 20KB)
js
// a.js
import common from "./common";
console.log("===a.js", common);
export const a = 2;
// b.js
import common from "./common";
console.log("===b.js", common);
export const b = 2;
// index.js
// 默认情况下只会对异步加载模块生效,这里必须要异步加载。
import("./a").then(res => {
console.log(res);
});
import("./b").then(res => {
console.log(res);
});
原理
这里以:例三 公共模块作为基础,在 index.js
中引入一个 lodash
作为例子进行分析 SplitChunksPlugin 的原理。首先了解了上面的知识后,先抛出一个问题:【以下例子最终会生成多少个 chunks 呢? 】
javascript
// a.js
import common from "./common";
console.log("===a.js", common);
export const a = 2;
// b.js
import common from "./common";
console.log("===b.js", common);
export const b = 2;
// index.js
import "lodash"; // 引入lodash
// 默认情况下只会对异步加载模块生效,这里必须要异步加载。
import("./a").then(res => {
console.log(res);
});
import("./b").then(res => {
console.log(res);
});
配置文件:
js
// webpack.config.js
optimization: {
splitChunks: {
cacheGroups: {
lodashVender: {
test: /[\/]node_modules[\/]lodash/,
reuseExistingChunk: true,
chunks: "all",
filename: "lodashVender.js"
}
}
}
}
稍微思考一下可以知道:一共会生成 5 个 Chunks!!! 下面将深入浅出地讲解一下原理:
在这个例子中,在seal
阶段首先会生成 3 个 Chunk
:
【目的】:接下来分析一下,SplitChunkPlugin
是如何把 Chunk
a 和 b 中的 common 以及 initial chunk
中的 lodash
给分割出来 Chunk
吧。
开始
原理图如上,具体的链接可以点击这里查看:xfjhqij9zq.feishu.cn/docx/ZLwcdC...
首先,SplitChunkPlugin
绑定在了 optimizeChunks
Hooks 中:
js
apply(compiler) {
compiler.hooks.thisCompilation.tap("SplitChunksPlugin", compilation => {
let alreadyOptimized = false;
compilation.hooks.optimizeChunks.tap(
{
name: "SplitChunksPlugin",
stage: STAGE_ADVANCED
},
chunks => {}
)
}
}
我们可以整体把 SplitChunkPlugin
按照 log.time 分成 4 大部份:
js
chunks => {
logger.time("prepare");
//...
logger.timeEnd("prepare");
logger.time("modules");
for (const module of compilation.modules) {
//...
}
logger.timeEnd("modules");
logger.time("queue");
for (const [key, info] of chunksInfoMap) {
//...
}
while (chunksInfoMap.size > 0) {
//...
}
logger.timeEnd("queue");
logger.time("maxSize");
for (const chunk of Array.from(compilation.chunks)) {
//...
}
logger.timeEnd("maxSize");
}
modules 阶段
prepare 阶段是初始化一些后面需要用到的变量以及方法,跳过即可。直接来看到 modules 阶段。
对于 modules 阶段而言,一句话概括就是:「检测出符合切割条件的 chunk,记录进chunksInfoMap
中」。接下来我们分步来解析一下在 modules 阶段中,到底发生了什么。
第一步:获取符合当前遍历到的module的cacheGroups。
js
for (const module of compilation.modules) {
// 获取有效的CacheGroups,比如没有匹配到cacheGroup的缓存组过滤掉
let cacheGroups = this.options.getCacheGroups(module, context);
}
const normalizeCacheGroups = (cacheGroups, defaultSizeTypes) => {
if (typeof cacheGroups === "object" && cacheGroups !== null) {
const handlers = [];
for (const key of Object.keys(cacheGroups)) {
const option = cacheGroups[key];
// 省略了一些情况,这里只看option是对象是情况!!!
// option = cacheGroup[key] 如果是一个对象,先标准化获得source
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);
}
});
}
/**
* 默认会有两个cacheGroup,default是一定会被push进results的,第二个是node_modules,如果test没有匹配上resource则不会被push进来
* @param {Module} module the current module
* @param {CacheGroupsContext} context the current context
* @returns {CacheGroupSource[]} the matching cache groups
*/
const fn = (module, context) => {
/** @type {CacheGroupSource[]} */
let results = [];
for (const fn of handlers) {
fn(module, context, results);
}
return results;
};
return fn;
}
return () => null;
};
这里的this.options.getCacheGroups
在初始化阶段被赋予了normalizeCacheGroups
方法,逻辑并不复杂,简单来说是将满足当前 module 的 CacheGroup 推入数组中。
而这的条件有3个,就是 CacheGroup.test
、layer
和 type
。他们分别与当前 module 的绝对路径 module.matchResource || module.resource
、类型 module.type
、以及 module.layer
进行判断。一般来说,正常情况下只会配置 test
属性,因此如果 CacheGroup.layer
为空,直接返回true
,type
同理。
按照我们上述的配置而言,cacheGroups一共有三个:
js
cacheGroups: {
defaultVendors: {
test: /[\/]node_modules[\/]/,
priority: -10,
reuseExistingChunk: true,
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
lodashVender: {
test: /[\/]node_modules[\/]lodash/,
reuseExistingChunk: true,
chunks: "all",
filename: "lodashVender.js"
}
},
而对于 index.js 模块而言,只有 default
符合,对于 lodash 模块而言,则有 defaultVendors
以及 lodashVender
符合。
(这里实际上只有
lodashVender
会生效,因为后面会对chunks
的类型做校验,而前面说了默认只会对异步模块做分割,因此defaultVendors
会被过滤掉~)
第二步:遍历有效的cacheGroup,获取chunk combination
js
for (const module of compilation.modules) {
// 获取有效的CacheGroups,比如没有匹配到cacheGroup的缓存组过滤掉
let cacheGroups = this.options.getCacheGroups(module, context);
for (const cacheGroupSource of cacheGroups) {
// 标准化cacheGroupItem 子项
const cacheGroup = this._getCacheGroup(cacheGroupSource);
// ================阶段二:获取当前module的所用到的chunks
/**
* usedExports = true的时候开启Tree Shaking,没有引用的exports不会打包进来
* 返回值:得到当前modules对应Chunk的数组
*/
const combs = cacheGroup.usedExports
? getCombsByUsedExports()
: getCombs();
}
}
什么是chunk combination呢?直观地来看可以理解为当前 module 所被包含的 chunks 们。这里获取的 chunks 会根据 cacheGroup.usedExports
有所不同。
cacheGroup.usedExports
的作用:引用文档原话( Figure out which exports are used by modules to mangle export names, omit unused exports and generate more efficient code. )说白了就是分析导出的变量是否有被使用,给 Tree Shaking 使用。
我们分两个场景来看一下:(代码有删减,想了解更多自行看源码)
- 首先是开发环境(dev)下,
usedExports
默认是false
:
js
// Prepare some values (usedExports = false)
const getCombs = memoize(() => {
const chunks = chunkGraph.getModuleChunksIterable(module);
// 遍历chunks,获取其中的chunk,
// 这里的chunksKey实际上就是一个Chunk。
//(因为chunks是一个Set,这里使用Iterator来取)
const chunksKey = getKey(chunks);
return getCombinations(chunksKey);
});
const getCombinations = key => getCombinationsFactory()(key);
const getCombinationsFactory = memoize(() => {
// 获取所有 modules 的 chunks集合
const { chunkSetsInGraph, singleChunkSets } = getChunkSetsInGraph();
return createGetCombinations(
chunkSetsInGraph,
singleChunkSets,
getChunkSetsByCount()
);
});
const createGetCombinations = (
chunkSets,
singleChunkSets,
chunkSetsByCount
) => {
/** @type {Map<bigint | Chunk, (Set<Chunk> | Chunk)[]>} */
const combinationsCache = new Map();
// 这里的key是chunkKey
return key => {
const cacheEntry = combinationsCache.get(key);
if (cacheEntry !== undefined) return cacheEntry;
// 如果是简单的Chunk,则包裹一层数组返回
if (key instanceof Chunk) {
const result = [key];
combinationsCache.set(key, result);
return result;
}
const chunksSet = chunkSets.get(key);
/** @type {(Set<Chunk> | Chunk)[]} */
const array = [chunksSet];
for (const [count, setArray] of chunkSetsByCount) {
// "equal"
is not needed because they would have been merge in the first step
if (count < chunksSet.size) {
for (const set of setArray) {
if (isSubset(chunksSet, set)) {
array.push(set);
}
}
}
}
for (const chunk of singleChunkSets) {
if (chunksSet.has(chunk)) {
array.push(chunk);
}
}
combinationsCache.set(key, array);
return array;
};
};
这里先介绍一下较重要的 getKey(chunk)
和 getChunkSetsInGraph()
方法。
js
const getChunkSetsInGraph = memoize(() => {
const chunkSetsInGraph = new Map();
const singleChunkSets = new Set();
for (const module of compilation.modules) {
//一般只有一个Chunks,如果是公共模块,会被多个Chunks引用
const chunks = chunkGraph.getModuleChunksIterable(module);
const chunksKey = getKey(chunks);
if (typeof chunksKey === "bigint") {
if (!chunkSetsInGraph.has(chunksKey)) {
chunkSetsInGraph.set(chunksKey, new Set(chunks));
}
} else {
singleChunkSets.add(chunksKey);
}
}
return { chunkSetsInGraph, singleChunkSets };
});
/**
* 遍历chunks,获取其中的chunk。(因为chunks是一个Set,这里使用Iterator来取)
* 如果只有一个Chunk,则直接返回该Chunk
* 如果是多个Chunks,则返回他们Index的或作为新Key
*/
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;
};
getKeys
:比较简单,作用是接收当前 module 被包含的所有 chunks,如果只有一个则直接返回chunk;如果有多个,则返回它们 index 的异或作为新 index。getChunkSetsInGraph
:返回{ chunkSetsInGraph, singleChunkSets }
,其中singleChunkSets
是所有 chunks 的去重 chunks,chunkSetsInGraph
则是如果有 module 被包含在多个chunks中,则以Map<index, Set<Chunk>>
形式记录下来。
为了描述方便,下文开始我们把例子中的 3 个 chunk 分别命名为 initChunk
、chunkA
和 chunkB
。
举个栗子,entry module
只获取了 1 个 initChunk
,返回的 chunkKey
也是 initChunk
,此时将会返回的 combination 是 [initChunk]
js
if (key instanceof Chunk) {
const result = [key];
combinationsCache.set(key, result);
return result;
}
而 common module
获取的是 chunkA
和 chunkB
,返回的 chunksKey
是每一个 chunks index 的异或。(在 prepare 阶段中会给每一个 chunks 赋值一个唯一 index
),此时将会返回的 combination 是 [Set<chunkA,chunkB>, chunkA, chunkB]
js
const chunksSet = chunkSets.get(key);
const array = [chunksSet];
for (const chunk of singleChunkSets) {
if (chunksSet.has(chunk)) {
array.push(chunk);
}
}
return array;
- 其次是生产环境(prod)下,
usedExports
默认是true
,因此来到了getCombsByUsedExports
方法:
js
const getCombsByUsedExports = memoize(() => {
// fill the groupedByExportsMap
getExportsChunkSetsInGraph();
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
,逻辑这里就不细说了,以 Module: Array[Chunk]
的方式收集好全部的 modules。
然后对当前 module 的 chunks 进行遍历,通过 getExportsCombinations
获取 comb。让我们继续看看这次的 comb 跟开发环境下有什么区别吧:
js
const getExportsCombinations = key => getExportsCombinationsFactory()(key);
const getExportsCombinationsFactory = memoize(() => {
const { chunkSetsInGraph, singleChunkSets } =
getExportsChunkSetsInGraph();
return createGetCombinations(
chunkSetsInGraph,
singleChunkSets,
getExportsChunkSetsByCount()
);
});
兜兜转转还是回到 createGetCombinations
,似乎也没什么区别😑。。。说笑了,其实区别在于 getExportsChunkSetsInGraph
的过程中,在收集 modules 和 chunks 的过程中,用到了 moduleGraph.getExportsInfo(module)
方法,实际上是获取了 mgm.exports
,它记录了每一个 module 导出的对象,这里了解即可。
【重要】第三步:遍历chunk combination,记录下需要拆分的chunk、modules信息
js
for (const module of compilation.modules) {
// 获取有效的CacheGroups,比如没有匹配到cacheGroup的缓存组过滤掉
let cacheGroups = this.options.getCacheGroups(module, context);
for (const cacheGroupSource of cacheGroups) {
// 标准化cacheGroupItem 子项
const cacheGroup = this._getCacheGroup(cacheGroupSource);
// ================阶段二:获取当前module的所用到的chunks
/**
* usedExports = true的时候开启Tree Shaking,没有引用的exports不会打包进来
* 返回值:得到当前modules对应Chunk的数组
*/
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;
// 普通&默认情况是2,因此modules只有被引用次数至少是2,才会被拆分
// 对于node_modules以及其他匹配到的cacheGroup,minChunks是1
if (count < cacheGroup.minChunks) continue;
// 根据配置项中的chunks,过滤掉不和预期的Chunk。
// Select chunks by configuration
const { chunks: selectedChunks, key: selectedChunksKey } =
getSelectedChunks(
chunkCombination,
cacheGroup.chunksFilter
);
addModuleToChunksInfoMap(
cacheGroup,
cacheGroupIndex,
selectedChunks,
selectedChunksKey,
module
);
}
}
}
看到了这里那么恭喜你已经半只脚踏入 webpack 拆包的领域了/
前面我们提到,当 combs 只有一个 initChunk
的时候(entry module
),此时 count = 1
,而此时的 cacheGroup.minChunks = 2
,因此直接被过滤掉不需要拆包。
js
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
这就是为什么默认情况下,模块被引用的数量必须大于 2 才会被拆分。 如果是遍历到 module = lodash
的时候,匹配到的 cacheGroup 并没有设置 minChunks
,因此会直接采用默认的 1
,因此也可以顺利通过过滤。
让我们切换视角来到 common module
,此时 combs 包含了 chunkA
和 chunkB
,因此顺利通过第一道门槛,进入到 getSelectedChunks
:
js
const getSelectedChunks = (chunks, chunkFilter) => {
let entry = selectedChunksCacheByChunksSet.get(chunks);
if (entry === undefined) {
entry = new WeakMap();
selectedChunksCacheByChunksSet.set(chunks, entry);
}
let entry2 =
entry.get(chunkFilter)
if (entry2 === undefined) {
const selectedChunks = [];
if (chunks instanceof Chunk) {
if (chunkFilter(chunks)) selectedChunks.push(chunks);
} else {
for (const chunk of chunks) {
if (chunkFilter(chunk)) selectedChunks.push(chunk);
}
}
entry2 = {
chunks: selectedChunks,
key: getKey(selectedChunks)
};
entry.set(chunkFilter, entry2);
}
return entry2;
};
这里的关键其实就是去过滤符合条件的 chunks。还记得我们一开始说的 默认情况下只会对【异步模块】进行拆分,过滤逻辑就是在这里。
默认情况下cacheGroup.chunks = splitChunks.chunks = 'async'
,所有对应的 chunkFilter
如下:
js
// chunks = initial
const INITIAL_CHUNK_FILTER = chunk => chunk.canBeInitial();
// chunks = async
const ASYNC_CHUNK_FILTER = chunk => !chunk.canBeInitial();
// chunks = all
const ALL_CHUNK_FILTER = chunk => true;
由于 common module 依赖的 chunkA
和 chunkB
都是 async chunk
,因此还是有惊无险的通过了第二道门槛。得到了 selectedChunks
和 selectedChunksKey
。
最后迎面而来的是第三道大门 addModuleToChunksInfoMap
:
js
const addModuleToChunksInfoMap = (
cacheGroup,
cacheGroupIndex,
selectedChunks,
selectedChunksKey,
module
) => {
// 继续对 minChunks 过滤
if (selectedChunks.length < cacheGroup.minChunks) return;
const name = cacheGroup.getName(module, selectedChunks, cacheGroup.key);
const key =
cacheGroup.key +
(name
? ` name:${name}`
: ` chunks:${keyToString(selectedChunksKey)}`);
// chunksInfoMap:所有需要拆分的 chunks 信息
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;
// 添加 module
info.modules.add(module);
// 更新 size
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);
// 更新 chunks
if (oldChunksKeysSize !== info.chunksKeys.size) {
for (const chunk of selectedChunks) {
info.chunks.add(chunk);
}
}
}
这段逻辑比较简单,将需要拆分的 module 和 所包含的 chunkA
和 chunkB
的信息放入map中。此时 chunksInfoMap
的数据如下:
再加上我们手动拆的 lodashVender
,最终的数据如下:
queue 阶段
对于 queue 阶段而言,一句话概括就是:「创建新Chunk
,断开链接,建立链接」。
根据chunksInfoMap
,创建新Chunk
,新 Chunk 需要与 ChunkGroup
、modules
建立连接,旧Chunk
与ChunkGroup
、modules
断开链接,做完了这些,后续打包的时候 webpack 就可以根据 ChunkGroup
的新结构进行文件生成。
第一步:进行chunk minSize的过滤
即使满足了前面众多条件被成功记录进了 chunksInfoMap
中,也难免会有不必要的拆分。例如拆出来的包的体积微乎其微,这也算是一种过度浪费了。
js
/**
* 如果拆分过chunk的话,chunksInfoMap将记录下来。如果info.size < minSize则remove掉
* 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);
}
}
const removeMinSizeViolatingModules = info => {
if (!info.cacheGroup._validateSize) return false;
// 实际上是遍历 cacheGroup 中规定的 minSize,与当前 info.sizes 比较,如果有小于 minSize 则 remove。
const violatingSizes = getViolatingMinSizes(
info.sizes,
info.cacheGroup.minSize
);
if (violatingSizes === undefined) return false;
// remove module from infoMaps
removeModulesWithSourceType(info, violatingSizes);
return info.modules.size === 0;
};
这里是去检测该 info.size
是否小于 cacheGroup.minSize
,如果小于则不拆包。默认的minSize
如下:
js
{
unknown: 10000,
javascript: 10000
}
除了这种情况,如果我们配置了 cacheGroup.minSizeReduction
,也会进行过滤。什么是 minSizeReduction
呢?官网解释为:被拆分包的最小体积缩减。如果拆包后对原有主包的体积缩减并没有达到预期,那还不如不拆。
js
const checkMinSizeReduction = (sizes, minSizeReduction, chunkCount) => {
for (const key of Object.keys(minSizeReduction)) {
const size = sizes[key];
if (size === undefined || size === 0) continue;
if (size * chunkCount < minSizeReduction[key]) return false;
}
return true;
};
因为后续拆包会将 module 从当前包含它的 chunks 中断开链接,因此 size * chunkCount
计算出来的则是 chunk 们的缩减体积。
问题1:
info.size
是如何计算出来的呢?
在上文中的 addModuleToChunksInfoMap
中可以发现,info.size
是 info 中所有 module.size 的总和。案例中所有的 module 都是 NormalModule
,因此直接来到 lib/NormalModule.js
中查看:
js
// /lib/NormalModule.js
size(type) {
// 由于是js文件,因此genertor是 JavascriptGenerator
const size = Math.max(1, this.generator.getSize(this, type));
return size;
}
// /lib/javascript/JavascriptGenerator.js
getSize(module, type) {
const originalSource = module.originalSource();
return originalSource.size();
}
// node_modules/webpack-sources/lib/Source.js
buffer() {
// 这里的 source 就是用 fs 读取到的文件字符串
const source = this.source();
if (Buffer.isBuffer(source)) return source;
return Buffer.from(source, "utf-8");
}
size() {
return this.buffer().length;
}
由其可见, module.size
实际上就是模块文本字符串的字节数大小。
第二步:遍历ChunkInfoMap,寻找本次拆分的bestEntry,创建新的Chunk
js
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 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;
// ...省略
};
在上述的 modules 阶段 中我们最终得到了要进行拆包两个 module:common module
和 lodash module
。但是,他们之间在拆包过程中存在谁在前谁在后的顺序关系,这时候就需要判断优先级了。
compareEntries
函数在 SplitChunksPlugin
中起到了挑选最优 cache group 的作用。其中,a
和 b
都代表一个 cache group,它们都包含了许多 Chunks 。这个函数用几个步骤来比较和区分 cache groups:
- 根据 cache group 的优先级
priority
比较。优先级高的 cache group 会被优先选择。 - 如果优先级相同,会比较 cache group 所得 chunk 的数目。数目多的优先。
- 如果数目也相同,比较的是经过此 cache group 后,能减少的总 module 大小。能减少更多的优先。
- 如果大小也相同,就根据 cache group 在配置中的顺序来决定优先级,顺序越前面优先级越高。
由于 common module
是默认配置,其 priority = -20
远比 lodash module
的 priority = 0
要低,因此这里的 bestEntry
是 lodash info
。
js
// 下面开始拆,先把原来的 key 删除防止重复拆包
const item = bestEntry;
chunksInfoMap.delete(bestEntryKey);
let newChunk;
/* 创建一个备份,这是原有的chunks,因为下面addChunks会改变compilation.chunks**/
const usedChunks = new Set(item.chunks);
// 开始创建新的Chunk
if (newChunk === undefined) {
newChunk = compilation.addChunk(chunkName);
}
// Walk through all chunks
// newChunk和chunks之间通过ChunkGroup建立链接
for (const chunk of usedChunks) {
// Add graph connections for splitted chunk
chunk.split(newChunk);
}
// Compilation.js
/**
* 如果有名字的话,先判断是否已经有了,如果有的话直接复用。之后新增Chunk
* ```js
* // 通过以下代码来新增Chunk
* this.chunks.add(newChunk)
* ```
*/
addChunk(name) {
if (name) {
const chunk = this.namedChunks.get(name);
if (chunk !== undefined) {
return chunk;
}
}
const chunk = new Chunk(name, this._backCompat);
this.chunks.add(chunk);
if (this._backCompat)
ChunkGraph.setChunkGraphForChunk(chunk, this.chunkGraph);
if (name) {
this.namedChunks.set(name, chunk);
}
return chunk;
}
接下来省略了一系列过滤条件后,开始创建新的 Chunk。_backCompat
这个变量99%的情况都是 ture
因为它是为了兼容 Webpack4 而存在的(Webpack4 不存在 ChunkGroup
)。因此在新建完 Chunk 后,还会绑定在 ChunkGroup
上。
js
split(newChunk) {
for (const chunkGroup of this._groups) {
chunkGroup.insertChunk(newChunk, this);
newChunk.addGroup(chunkGroup);
}
for (const idHint of this.idNameHints) {
newChunk.idNameHints.add(idHint);
}
newChunk.runtime = mergeRuntime(newChunk.runtime, this.runtime);
}
chunk.split(newChunk)
顾名思义,将原本的 initChunk
分割出来两个 chunks。但实际逻辑只是把 initChunk
以及 newChunk
通过 ChunkGroup
绑定起来。
真正的将 initChunk
以及 newChunk
更新到 ChunkGraph
上的逻辑在下面:
js
// Add all modules to the new chunk
// 要分隔的modules全部链接到newChunk中,而断开跟原本用到的各个chunks的链接
for (const module of item.modules) {
// Add module to new chunk
chunkGraph.connectChunkAndModule(newChunk, module);
// Remove module from used chunks
for (const chunk of usedChunks) {
chunkGraph.disconnectChunkAndModule(chunk, module);
}
}
// ChunkGraph.js
connectChunkAndModule(chunk, module) {
const cgm = this._getChunkGraphModule(module);
const cgc = this._getChunkGraphChunk(chunk);
cgm.chunks.add(chunk);
cgc.modules.add(module);
}
disconnectChunkAndModule(chunk, module) {
const cgm = this._getChunkGraphModule(module);
const cgc = this._getChunkGraphChunk(chunk);
cgc.modules.delete(module);
// No need to invalidate cgc._modulesBySourceType because we modified cgc.modules anyway
if (cgc.sourceTypesByModule) cgc.sourceTypesByModule.delete(module);
cgm.chunks.delete(chunk);
}
完成了着一切,意味着 lodash module
已经和 initChunk
彻底分开,并且和 newChunk
建立起来了新链接。
到此为止,在 ChunkGroup
中,已经成功的拆出了一个 lodash 的 newChunk
。接下来继续回到 chunkInfoMap
的循环,继续拆出 common chunk
,最后一共拆成了 5 个 Chunks:
在拆分出来了 Chunks 后,在 Webpack 后续的 [seal 生命周期] 中就可以根据新的 ChunkGraph
结构去进行代码生成啦。
MaxSize 阶段
这个阶段主要是起到控制 chunk 分片大小的作用。详细来说,maxSize
参数告诉 Webpack 尝试将大于 maxSize
个字节的 chunks 分割成更小的部分,每个部分在大小上至少为 minSize
(仅次于 maxSize
)。
这里由于不是重要逻辑,就不对其进行分析了。
总结
原理图如上,具体的链接可以点击这里查看:xfjhqij9zq.feishu.cn/docx/ZLwcdC...
本文以解析 SplitChunksPlugin
的原理入手,让前端开发者了解到了 Webpack Plugins 的功能强大,也深入浅出地解读了作为前端优化编译性能的利器的原理。
SplitChunksPlugin
默认配置以及默认的作用对象:async chunk。- 如何根据
cacheGroup
来获取需要拆分的 chunks 和 modules。 - 如何新建 Chunk 以及将 chunk、modules 和 ChunkGraph 关联在一起。
SplitChunksPlugin
是 Webpack 性能优化的一个重要工具,适当地使用可以帮助提高前端应用的加载速度,提升用户体验。需要注意的是,虽然模块分割的目标是提高应用的性能,但是过度的代码分割可能会导致大量的 HTTP 请求,从而造成性能下降,因此在使用时需要平衡考量。
Hi~我是盐焗乳鸽还要香锅。这是【深入理解 Webpack5 系列】文章的第二篇,希望这篇文章能给所有读者一些收获和感悟,本人也会继续深入学习 Webpack,继续输出更多有价值有深度的文章。