想必常用基于webpack打包工具的框架的同窗们,不管是使用React仍是Vue在性能优化上使用最多的应该是分包策略(按需加载)。按需加载的方式使咱们的每个bundle变的更小,在每个单页中只须要引入当前页所使用到的JavaScript代码,从而提升了代码的加载速度。可是,因为webpack目前版本自身的缘由分包策略虽然能够提升咱们的加载速度,然而在线上缓存方面却给了咱们极大的破坏(webpack5中解决了这个问题)。node
本文主要经过如下四个方面,来深刻剖析chunkId:webpack
webpack是一个基于模块化的打包工具,其整体打包流程可分为:web
初始化阶段算法
webpack初始化阶段主要在webpack.js中完成,有如下方面:shell
编译阶段npm
初始化完成以后,cli获得compiler实例,执行compiler.run()开始编译,编译的过程主要分为如下步骤:数组
输出阶段缓存
上面简单了解一下打包流程,固然最主要的目的不是为了了解打包流程,而是其中的一个点:chunk是怎么生成的性能优化
从编译阶段中能够看出,chunk是由多个module合并生成的,每个chunk生成的时候都会有一个对应的chunkId,chunkId的生成策略是本节讨论的重点。app
chunkId的生成策略能够在官网中找到,主要有五种规则:
不一样的生成规则所打包出来的chunkId是不一样的。可是,其实内部生成方式是同样的,不一样的规则只是对chunks中的chunk排序规则不一样(说的什么玩意,什么一会相同,一会不一样的)。不要着急,接下来就来看一下这东西究竟是怎么生成的。
咱们都知道webpack的optimization中有个chunkIds的配置,上面五种值,就是它的可选值。在开发环境下默认值为named,在生产环境下默认值为size。
在webpack初始化阶段会挂载内部插件,咱们直接定位到WebpackOptionsApply.js
这个文件的第437行。
if (chunkIds) {
const NaturalChunkOrderPlugin = require("./optimize/NaturalChunkOrderPlugin");
const NamedChunksPlugin = require("./NamedChunksPlugin");
const OccurrenceChunkOrderPlugin = require("./optimize/OccurrenceChunkOrderPlugin");
switch (chunkIds) {
case "natural":
new NaturalChunkOrderPlugin().apply(compiler);
break;
case "named":
new OccurrenceChunkOrderPlugin({
prioritiseInitial: false
}).apply(compiler);
new NamedChunksPlugin().apply(compiler);
break;
case "size":
new OccurrenceChunkOrderPlugin({
prioritiseInitial: true
}).apply(compiler);
break;
case "total-size":
new OccurrenceChunkOrderPlugin({
prioritiseInitial: false
}).apply(compiler);
break;
default:
throw new Error(`webpack bug: chunkIds: ${chunkIds} is not implemented`);
}
}
复制代码
上面代码中能够看到,在初始化阶段不一样的chunkIds的值会加载不一样的插件,而且进入这个插件内部你会发现他们都是挂载到compilation.hooks.optimizeChunkOrder
这个钩子上。那么疑问来了,这个钩子是在什么时机执行的呢?定位到``compilation.js`的第1334行会获得答案。
//这个钩子主要作的是肯定以什么规则生成chunkId
this.hooks.optimizeChunkOrder.call(this.chunks);
//生成前所要作的事,注:咱们能够在这里作手脚
this.hooks.beforeChunkIds.call(this.chunks);
//生成chunkId
this.applyChunkIds();
this.hooks.optimizeChunkIds.call(this.chunks);
this.hooks.afterOptimizeChunkIds.call(this.chunks);
复制代码
在执行流程中能够看出,chunkId在生成前肯定生成规则。可能你的疑问又来了,它是怎么根据chunkId的值的不一样生成规则呢?其实全部的chunk都存放在一个数组里面(也就是chunks),在optimizeChunkOrder
中根据规则的不一样对chunk进行相应的排序,而后再applyChunkIds
统一的对chunk.id
进行赋值。眼见为实,咱们先来看一下applyChunkIds中是怎么赋值的,定位到compilation.js中的1754行。
let nextFreeChunkId = 0;
for (let indexChunk = 0; indexChunk < chunks.length; indexChunk++) {
const chunk = chunks[indexChunk];
if (chunk.id === null) {
if (unusedIds.length > 0) {
chunk.id = unusedIds.pop();
} else {
chunk.id = nextFreeChunkId++;
}
}
if (!chunk.ids) {
chunk.ids = [chunk.id];
}
}
复制代码
这生成过程当中判断chunk.id是否为null,若是为null,对id赋值nextFreeChunkId。没错,不管是什么生成规则,都是这样赋值的。明白了全部的生成规则都是使用相同的赋值规则以后,咱们如今的疑问应该就是每一个规则中是怎么对chunks进行排序的?接下来就来看一下每一个规则是怎么作的。
在WebpackOptionsApply.js
中咱们能够知道,chunkIds值为natural的时候,挂载的是NaturalChunkOrderPlugin
这个插件。
compilation.hooks.optimizeChunkOrder.tap(
"NaturalChunkOrderPlugin",
chunks => {
//排序
chunks.sort((chunkA, chunkB) => {
//获得modulesIterable的iterator遍历器
const a = chunkA.modulesIterable[Symbol.iterator]();
const b = chunkB.modulesIterable[Symbol.iterator]();
while (true) {
const aItem = a.next();
const bItem = b.next();
if (aItem.done && bItem.done) return 0;
if (aItem.done) return -1;
if (bItem.done) return 1;
//获取到module的id
const aModuleId = aItem.value.id;
const bModuleId = bItem.value.id;
if (aModuleId < bModuleId) return -1;
if (aModuleId > bModuleId) return 1;
}
});
}
);
复制代码
首先,在每个chunk中都有一个modulesIterable
这个属性,它是一个Set
,里面存放的是全部合并当前的module,每一个module的id属性表示当前module的相对路径
。NaturalChunkOrderPlugin
主要作的事就是根据moduleId来最为排序规则进行排序。
named的生成规则比较简单,根据chunk的name取值
class NamedChunksPlugin {
static defaultNameResolver(chunk) {
return chunk.name || null;
}
constructor(nameResolver) {
this.nameResolver = nameResolver || NamedChunksPlugin.defaultNameResolver;
}
apply(compiler) {
compiler.hooks.compilation.tap("NamedChunksPlugin", compilation => {
compilation.hooks.beforeChunkIds.tap("NamedChunksPlugin", chunks => {
for (const chunk of chunks) {
if (chunk.id === null) {
chunk.id = this.nameResolver(chunk);
}
}
});
});
}
}
复制代码
named与其余方式的区别在于,named不是在optimizeChunkOrder
中对chunkId操做,而是在beforeChunkIds阶段。NamedChunksPlugin
所作的事是遍历全部的chunk,判断chunk的id值是否为null,若是为null,取到chunk的name值赋予id。
当执行applyChunkIds
的时候,因为当前的id值已经不是null了,因此跳过赋值规则,直接使用已存在的值。
size和total-size规则因为调用的是相同的插件,只是参数的不一样,因此咱们就一块儿看一下它是怎么作的。打开OccurrenceChunkOrderPlugin.js
文件。
size和total-size调用插件的区别:
apply(compiler) {
const prioritiseInitial = this.options.prioritiseInitial;
compiler.hooks.compilation.tap("OccurrenceOrderChunkIdsPlugin",compilation => {
compilation.hooks.optimizeChunkOrder.tap("OccurrenceOrderChunkIdsPlugin",chunks => {
const occursInInitialChunksMap = new Map();
const originalOrder = new Map();
let i = 0;
for (const c of chunks) {
let occurs = 0;
//获得chunk的chunkGroup
for (const chunkGroup of c.groupsIterable) {
//查看当前模块有没有被其它模块引用
for (const parent of chunkGroup.parentsIterable) {
//isInitial方法始终返回true
if (parent.isInitial()) occurs++;
}
}
occursInInitialChunksMap.set(c, occurs);
originalOrder.set(c, i++);
}
//排序
chunks.sort((a, b) => {
//若是规则是size,prioritiseInitial为true,经过父模块的数量来排序。若是父模块相同,则按照和total-size相同的规则排序。
if (prioritiseInitial) {
const aEntryOccurs = occursInInitialChunksMap.get(a);
const bEntryOccurs = occursInInitialChunksMap.get(b);
if (aEntryOccurs > bEntryOccurs) return -1;
if (aEntryOccurs < bEntryOccurs) return 1;
}
//获得groups的大小,内部调用this._group.size
const aOccurs = a.getNumberOfGroups();
const bOccurs = b.getNumberOfGroups();
if (aOccurs > bOccurs) return -1;
if (aOccurs < bOccurs) return 1;
//依据chunk在chunks中的索引位置排序
const orgA = originalOrder.get(a);
const orgB = originalOrder.get(b);
return orgA - orgB;
});
});
});
}
复制代码
OccurrenceChunkOrderPlugin经过prioritiseInitial区分是size仍是total-size:
说到破坏,咱们心中可能又会有疑问,这东西怎么会破坏线上缓存呢?咱们来模拟一个场景。
想必业务思想很好的你,有时候也会让业务的快速变动搞的很是烦恼,假设一个blog项目三个功能模块:文章列表页、文章标签页、关于页,而且三个功能模块都是异步的。咱们来简写一下代码。
首先入口文件为index.js,三个功能模块代码为articleList.js、articleTag.js、about.js。
//三个功能模块的代码以下:
//articleList.js
const ArticleList = () => {
console.log('ArticleList')
}
export default ArticleList
//articleTag.js
const ArticleTag = () => {
console.log('ArticleTag')
}
export default ArticleTag
//about.js
const About = () => {
console.log('About')
}
export default About;
复制代码
在index.js中异步引入这三个功能模块。
// 引入articleList
import('./articleList').then(_ => {
_.default()
})
// 引入articleTag
import('./articleTag').then(_ => {
_.default()
})
// 引入about
import('./about').then(_ => {
_.default()
})
复制代码
咱们使用生产环境打包一下,获得dist目录中的文件以下:
很完美,打包成功,结果也确定和咱们想的同样。
假若有一天,需求变了,关于咱们页不想要了,让它暂时不存在项目里面了(为了方便文件的diff,咱们先把当前的代码作一个备份),咱们能够先把About的代码在index.js中的代码注释。
// 引入articleList
import('./articleList').then(_ => {
_.default()
})
// 引入articleTag
import('./articleTag').then(_ => {
_.default()
})
// 引入about
//import('./about').then(_ => {
// _.default()
//})
复制代码
注释以后,从新打包。从新生成的文件和备份的以下
打包结果如咱们所想,一切都很平静,可是却不知平静的背后正在掀起大浪。咱们来使用一个比较工具文件内容比较工具---Beyond Compare,选取dist中和备份中的1.js文件来作一下比较。
能够你会说,这不小意思吗?我有webpack的魔法注释,不让文件名变不就得了。(此时做者只能呵呵一笑)咱们来验证一下。
咱们来把index中引入的三个模块都加上魔法注释:
// 引入articleList
import( /* webpackChunkName: "articleList" */'./articleList').then(_ => {
_.default()
})
// 引入articleTag
import( /* webpackChunkName: "articleTag" */ './articleTag').then(_ => {
_.default()
})
// 引入about
import( /* webpackChunkName: "about" */ './about').then(_ => {
_.default()
})
复制代码
打包结果以下
相信上述问题,早已被社区的同窗们发现,笔者在也曾找了一会插件,但都没有如愿,内心不服,干脆本身写一个。
webpack-fixed-chunk-id-plugin 这个插件已经被笔者发布到npm,代码极简,可能会存在不足,还望社区大佬多多提建议,共同成长。
根据上文咱们能够得出,万物的罪魁祸首是chunkId,因此必需要固定它,才能让文件内容不会变。那如何固定呢?
第一点:根据上文第一部分分析chunkId生成原理的时候,咱们从named这个规则中得出只要在beforeChunkIds
,这个地方给chunkId一个值,在applyChunKId
阶段就不会对chunkId执行定义的规则。
第二点:上一点得出在webpack什么阶段来控制chunkId,那么这点就应该讨论控制chunkId要基于什么来控制? 第一个想到的确定是内容,基于内容来控制chunkId,当内容变chunkId变、内容不变chunkId不变。
基于上面两点,插件代码以下:
const crypto = require('crypto');
const pluginName = "WebpackFixedChunkIdPlugin";
class WebpackFixedChunkIdPlugin {
constructor({hashLength = 8} = {}) {
//todo
this.hashStart = 0;
this.hashLength = hashLength;
}
apply(compiler) {
compiler.hooks.compilation.tap(pluginName, (compilation) => {
compilation.hooks.beforeChunkIds.tap(pluginName, (chunks) => {
chunks.forEach((chunk,idx) => {
let modulesVal,
chunkId;
if(![...chunk._modules].length) {
modulesVal = chunk.name;
} else {
const modules = chunk._modules;
for(let module of modules) {
modulesVal += module._source._value;
}
}
const chunkIdHash = crypto.createHash('md5').update(modulesVal).digest('hex');
chunkId = chunkIdHash.substr(this.hashStart, this.hashLength);
chunk.id = chunkId;
})
})
})
}
}
module.exports = WebpackFixedChunkIdPlugin;
复制代码
经过挂载到beforeChunkIds钩子上,拿到全部的chunk,遍历每个chunk获得全部合并当前chunk的module的内容,使用node的crypto加密模块,对内容计算hash值,设置chunk.id
。下面咱们来测试一下,这个插件好很差用。
//下载插件:npm install webpack-fixed-chunk-id-plugin
const WebpackFixedChunkIdPlugin = require('webpack-fixed-chunk-id-plugin');
module.exports = {
plugins: [
new WebpackFixedChunkIdPlugin()
]
}
复制代码
打包一下查看结果:
chunkId事故问题可谓webpack自身留下的坑,chunkId方便了开发者,一样chunkId也对咱们形成了极大的破坏,正所谓:成也chunkId、败也chunkId。
webpack4中遗留的问题,在还未现世的webpack5中获得了完美的解决。
接下来开始尝鲜webpack5。因为webpack5还未发版,咱们能够经过一些方法来使用它。
//下载webpack5
npm init -y
npm install webpack@next --save-dev
npm install webpack-cli --save-dev
复制代码
把webpack4中的src下的代码拷贝到webpack5中打包,结果以下:
可能
(接下来论证)是不受其余chunk影响的。
咱们来按照以前的方式验证一下,把about模块注释,并使用Beyond Compare比较一下。
虽然webpack5能够执行以上操做,可是因为目前还未发布,以cli的配合并不完善。目前的版本,只要写webpack.config.js使用cli启动就会报错,若是要使用配置文件的话就只能使用node来启动webpack。
而且若是要使用webpack5完美的chunkId,还须要在webpack配置文件中配置一下内容:
module.exports = {
optimization: {
chunkIds: 'deterministic',
}
}
复制代码
目前的webpack5已经有了不少优秀的特性,包括代码也变的更加简介,总之,拥抱webpack5吧。
咱们在开发过程当中关注甚少的chunkId竟能引起这个大的问题,因此我的认为不只是在学习仍是在深刻研究的过程当中都要抱有疑问或是怀疑态度,促使咱们去挖掘原理,只有明白真正内部实现的时候,才能彻底的相信它,这也是个人一种自我提高的方式。