由浅至深了解webpack异步加载背后的原理
- 2020 年 1 月 14 日
- 笔记
源自最近对业务项目进行 webpack 异步分包加载一点点的学习总结
提纲如下:
- 相关概念
- webpack 分包配置
- webpack 异步加载分包如何实现
相关概念
- module、chunk、bundle 的概念
先来一波名词解释。先上网上一张图解释:

通过图可以很直观的分出这几个名词的概念:
1、module:我们源码目录中的每一个文件,在 webpack 中当作module来处理(webpack 原生不支持的文件类型,则通过 loader 来实现)。module组成了chunk。 2、chunk。webpack打包过程中的产物,在默认一般情况下(没有考虑分包等情况),x 个webpack的entry会输出 x 个bundle。 3、bundle。webpack最终输出的东西,可以直接在浏览器运行的。从图中看可以看到,在抽离 css(当然也可以是图片、字体文件之类的)的情况下,一个chunk是会输出多个bundle的,但是默认情况下一般一个chunk也只是会输出一个bundle
hash、chunkhash、contenthash
这里不进行 demo 演示了,网上相关演示已经很多。
hash。所有的 bundle 使用同一个 hash 值,跟每一次 webpack 打包的过程有关
chunkhash。根据每一个 chunk 的内容进行 hash,同一个 chunk 的所有 bundle 产物的 hash 值是一样的。因此若其中一个 bundle 的修改,同一 chunk 的所有产物 hash 也会被修改。
contenthash。计算与文件内容本身相关。
tips:需要注意的是,在热更新模式下,会导致chunkhash和contenthash计算错误,发生错误(Cannot use [chunkhash] or [contenthash] for chunk in '[name].[chunkhash].js' (use [hash] instead) )。因此热更新下只能使用hash模式或者不使用hash。在生产环境中我们一般使用contenthash或者chunkhash。
说了这么多,那么使用异步加载/分包加载有什么好处呢。简单来说有以下几点
1、更好的利用浏览器缓存。如果我们一个很大的项目,不使用分包的话,每一次打包只会生成一个 js 文件,假设这个 js 打包出来有 2MB。而当日常代码发布的时候,我们可能只是修改了其中的一行代码,但是由于内容变了,打包出来的 js 的哈希值也发生改变。浏览器这个时候就要重新去加载这个 2MB 的 js 文件。而如果使用了分包,分出了几个 chunk,修改了一行代码,影响的只是这个 chunk 的哈希(这里严谨来说在不抽离 mainifest 的情况下,可能有多个哈希也会变化),其它哈希是不变的。这就能利用到 hash 不变化部分代码的缓存
2、更快的加载速度。假设进入一个页面需要加载一个 2MB 的 js,经过分包抽离后,可能进入这个页面变成了加载 4 个 500Kb 的 js。我们知道,浏览器对于同一域名的最大并发请求数是 6 个(所以 webpack 的maxAsyncRequests默认值是 6),这样这个 4 个 500KB 的 js 将同时加载,相当于只是穿行加载一个 500kb 的资源,速度也会有相应的提高。
3、如果实现的是代码异步懒加载。对于部分可能某些地方才用到的代码,在用到的时候才去加载,也能很好起到节省流量的目的。
webpack 分包配置
在这之前,先强调一次概念,splitChunk,针对的是chunk,并不是module。对于同一个 chunk 中,无论一个代码文件被同 chunk 引用了多少次,它都还是算 1 次。只有一个代码文件被多个 chunk 引用,才算是多次。
webpack 的默认分包配置如下
module.exports = { optimization: { splitChunks: { // **`splitChunks.chunks: 'async'`**。表示哪些类型的chunk会参与split。默认是异步加载的chunk。值还可以是`initial`(表示入口同步chunk)、`all`(相当于`initial`+`async`)。 chunks: "async", // minSize 表示符合代码分割产生的新生成chunk的最小大小。默认是大于30kb的才会生成新的chunk minSize: 30000, // maxSize 表示webpack会尝试将大于maxSize的chunk拆分成更小的chunk,拆解后的值需要大于minSize maxSize: 0, // 一个模块被最少多少个chunk共享时参与split minChunks: 1, // 最大异步请求数。该值可以理解为一个异步chunk,被抽离出同时加载的chunk数不超过该值。若为1,该异步chunk将不会抽离出任意代码块 maxAsyncRequests: 5, // 入口chunk最大请求数。在多entry chunk的情况下会用到,表示多entry chunk公共代码抽出的最大同时加载的chunk数 maxInitialRequests: 3, // 初始chunk最大请求数。 // 多个chunk拆分出小chunk时,这个chunk的名字由多个chunk与连接符组合成 automaticNameDelimiter: "~", // 表示chunk的名字自动生成(由cacheGroups的key、entry名字) name: true, // cacheGroups 表示分包分组规则,每一个分组会继承于default // priority表示优先级,一个chunk可能被多个分组规则命中时,会使用优先级较高的 // test提供时 表示哪些模块会被抽离 cacheGroups: { vendors: { test: /[\/]node_modules[\/]/, priority: -10 }, default: { minChunks: 2, priority: -20, // 复用已经生成的chunk reuseExistingChunk: true } } } } };
还有一个很重要的配置是output.jsonpFunction(默认是webpackJsonp)。这是用于异步加载 chunk 的时候一个全局变量。如果多 webpack 环境下,为了防止该函数命名冲撞产生问题,最好设置成一个比较唯一的值。
一般而言,没有最完美的分包配置,只有最合适当前项目场景需求的配置。很多时候,默认配置已经足够可用了。
通常来说,为了保证 hash 的稳定性,建议:
1、使用webpack.HashedModuleIdsPlugin。这个插件会根据模块的相对路径生成一个四位数的 hash 作为模块 id。默认情况下 webpack 是使用模块数字自增 id 来命名,当插入一个模块占用了一个 id(或者一个删去一个模块)时,后续所有的模块 id 都受到影响,导致模块 id 变化引起打包文件的 hash 变化。使用这个插件就能解决这个问题。
2、chunkid 也是自增的,同样可能遇到模块 id 的问题。可以通过设置optimization.namedChunks为 true(默认 dev 模式下为 true,prod 模式为 false),将chunk的名字使用命名chunk。
1、2 后的效果如下。

3、抽离 css 使用mini-css-extract-plugin。hash 模式使用contenthash。
这里以腾讯云某控制台页面以下为例,使用 webpack 路有异步加载效果后如下。可以看到,第一次访问页面。这里是先请求到一个总的入口 js,然后根据我们访问的路由(路由 1),再去加载这个路由相关的代码。这里可以看到我们异步加载的 js 数为 5,就相当于上面提到的默认配置项maxAsyncRequests,通过waterfall可以看到这里是并发请求的。如果再进去其它路由(路由 2)的话,只会加载一个其它路由的 js(或者还有当前没有加载过的 vendor js)。这里如果只修改了路由 1 的自己单独业务代码,vendor 相关的 hash 和其它路由的 hash 也不是不会变,这些文件就能很好的利用了浏览器缓存了

webpack 异步加载分包如何实现
我们知道,默认情况下,浏览器环境的 js 是不支持import和异步import('xxx').then(...)的。那么 webpack 是如何实现使得浏览器支持的呢,下面对 webpack 构建后的代码进行分析,了解其背后原理。
实验代码结构如下
展开查看
// webpack.js const webpack = require("webpack"); const path = require("path"); const CleanWebpackPlugin = require("clean-webpack-plugin").CleanWebpackPlugin; const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = { entry: { a: “./src/a.js”, b: “./src/b.js” }, output: { filename: “[name].[chunkhash].js”, chunkFilename: “[name].[chunkhash].js”, path: **dirname + “/dist”, jsonpFunction: “_**jsonp” }, optimization: { splitChunks: { minSize: 0 } // namedChunks: true }, plugins: [ new CleanWebpackPlugin(), new HtmlWebpackPlugin() //new webpack.HashedModuleIdsPlugin() ], devServer: { contentBase: path.join(__dirname, “dist”), compress: true, port: 8000 } };
// src/a.js import { common1 } from “./common1”; import { common2 } from “./common2”; common1(); common2(); import(/_ webpackChunkName: “asyncCommon2” _/ “./asyncCommon2.js”).then( ({ asyncCommon2 }) => { asyncCommon2(); console.log(“done”); } );
// src/b.js import { common1 } from “./common1”; common1(); import(/_ webpackChunkName: “asyncCommon2” _/ “./asyncCommon2.js”).then( ({ asyncCommon2 }) => { asyncCommon2(); console.log(“done”); } );
// src/asyncCommon1.js export function asyncCommon1(){ console.log(‘asyncCommon1’) } // src/asyncCommon2.js export function asyncCommon2(){ console.log(‘asyncCommon2’) }
// ./src/common1.js export function common1() { console.log(“common11”); } import(/_ webpackChunkName: “asyncCommon1” _/ “./asyncCommon1”).then( ({ asyncCommon1 }) => { asyncCommon1(); } );
// src/common2.js export function common2(){ console.log(‘common2’) }
在分析异步加载机制之前,先看下 webpack 打包出来的代码结构长啥样(为了便于阅读,这里使用 dev 模式打包,没有使用任何 babel 转码)。列出与加载相关的部分
// 入口文件 a.js (function() { //..... function webpackJsonpCallback(data){ //.... } // 缓存已经加载过的module。无论是同步还是异步加载的模块都会进入该缓存 var installedModules = {}; // 记录chunk的状态位 // 值:0 表示已加载完成。 // undefined : chunk 还没加载 // null :chunk preloaded/prefetched // Promise : chunk正在加载 var installedChunks = { a: 0 }; // 用于根据chunkId,拿异步加载的js地址 function jsonpScriptSrc(chunkId){ //... } // 同步import function __webpack_require__(moduleId){ //... } // 用于加载异步import的方法 __webpack_require__.e = function requireEnsure(chunkId) { //... } // 加载并执行入口js return __webpack_require__((__webpack_require__.s = "./src/a.js")); })({ "./src/a.js": function(module, __webpack_exports__, __webpack_require__) { eval( ...); // ./src/a.js的文件内容 }, "./src/common1.js": ...., "./src/common2.js": ... });
可以看到,经过 webpack 打包后的入口文件是一个立即执行函数,立即执行函数的参数就是为入口函数的同步import的代码模块对象。key 值是路径名,value 值是一个执行相应模块代码的eval函数。这个入口函数内有几个重要的变量/函数。
webpackJsonpCallback函数。加载异步模块完成的回调。installedModules变量。 缓存已经加载过的 module。无论是同步还是异步加载的模块都会进入该缓存。key是模块 id,value是一个对象{ i: 模块id, l: 布尔值,表示模块是否已经加载过, exports: 该模块的导出值 }。installedChunks变量。缓存已经加载过的 chunk 的状态。有几个状态位。0表示已加载完成、undefinedchunk 还没加载、null:chunkpreloaded/prefetched加载的模块、Promise: chunk 正在加载jsonpScriptSrc变量。用于返回异步 chunk 的 js 地址。如果设置了webpack.publicPath(一般是 cdn 域名,这个会存到__webpack_require__.p中),也会和该地址拼接成最终地址__webpack_require__函数。同步import的调用__webpack_require__.e函数。异步import的调用
而每个模块构建出来后是一个类型如下形式的函数,函数入参module对应于当前模块的相关状态(是否加载完成、导出值、id 等,下文提到)、__webpack_exports__就是当前模块的导出(就是 export)、__webpack_require__就是入口 chunk 的__webpack_require__函数,用于import其它代码
function(module, __webpack_exports__, __webpack_require__) { "use strict"; eval(模块代码...);// (1) }
eval内的代码如下,以a.js为例。
// (1) // 格式化为js后 __webpack_require__.r(__webpack_exports__); var _common1__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__( "./src/common1.js" ); var _common2__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__( "./src/common2.js" ); // _common1__WEBPACK_IMPORTED_MODULE_0__是导出对象 // 执行导出的common1方法 // 源码js: // import { common1 } from "./common1"; // common1(); Object(_common1__WEBPACK_IMPORTED_MODULE_0__["common1"])(); Object(_common2__WEBPACK_IMPORTED_MODULE_1__["common2"])(); __webpack_require__ .e("asyncCommon2") .then(__webpack_require__.bind(null, "./src/asyncCommon2.js")) .then(({ asyncCommon2 }) => { asyncCommon2(); console.log("done"); });
于是,就可知道
- 同步
import最终转化成__webpack_require__函数 - 异步
import最终转化成__webpack_require__.e方法
整个 流程执行就是。
入口文件最开始通过__webpack_require__((__webpack_require__.s = "./src/a.js"))加载入口的 js,(上面可以观察到installedChunked变量的初始值是{a:0},),并通过eval执行 a.js 中的代码。
__webpack_require__可以说是整个 webpack 构建后代码出现最多的东西了,那么__webpack_require__做了啥。
function __webpack_require__(moduleId) { // 如果一个模块已经import加载过了,再次import的话就直接返回 if (installedModules[moduleId]) { return installedModules[moduleId].exports; } // 之前没有加载的话将它挂到installedModules进行缓存 var module = (installedModules[moduleId] = { i: moduleId, l: false, exports: {} }); // 执行相应的加载的模块 modules[moduleId].call( module.exports, module, module.exports, __webpack_require__ ); // 设置模块的状态为已加载 module.l = true; // 返回模块的导出值 return module.exports; }
这里就很直观了,这个函数接收一个moduleId,对应于立即执行函数传入参数的key值。若一个模块之前已经加载过,直接返回这个模块的导出值;若这个模块还没加载过,就执行这个模块,将它缓存到installedModules相应的moduleId为 key 的位置上,然后返回模块的导出值。所以在 webpack 打包代码中,import一个模块多次,这个模块只会被执行一次。还有一个地方就是,在 webpack 打包模块中,默认import和require是一样的,最终都是转化成__webpack_require__。
完成同步加载后,入口 chunk 执行a.js。
回到一个经典的问题,webpack环境中如果发生循环引用会怎样?a.js有一个import x from './b.js'、b.js有一个import x from 'a.js'。经过上面对__webpack_require__的分析就很容易知道了。一个模块执行之前,webpack就已经先将它挂到installedModules中。例如此时执行a.js它引入b.js,b.js中又引入a.js。此时b.js中拿到引入a的内容只是在a.js当前执行的时候已经export出的东西(因为已经挂到了installedModules,所以不会重新执行一遍a.js)。
接下来回到eval内执行的a.js模块代码片段,异步加载 js 部分。
// a.js模块 __webpack_require__ .e("asyncCommon2") .then(__webpack_require__.bind(null, "./src/asyncCommon1.js")) // (1) 异步的模块文件已经被注入到立即执行函数的入参`modules`变量中了,这个时候和同步执行`import`调用`__webpack_require__`的效果就一样了 .then(({ asyncCommon2 }) => { //(2) 就能拿到对应的模块,并且执行相关逻辑了(2)。 asyncCommon2(); console.log("done"); });
__webpack_require__.e做的事情就是,根据传入的chunkId,去加载这个chunkId对应的异步 chunk 文件,它返回一个promise。通过jsonp的方式使用script标签去加载。这个函数调用多次,还是只会发起一次请求 js 的请求。若已加载完成,这时候异步的模块文件已经被注入到立即执行函数的入参modules变量中了,这个时候和同步执行import调用__webpack_require__的效果就一样了(这个注入由webpackJsonpCallback函数完成)。此时,在promise的回调中再调用__webpack_require__.bind(null, "./src/asyncCommon1.js")(1) 就能拿到对应的模块,并且执行相关逻辑了(2)。
// __webpack_require__.e 异步import调用函数 // 再回顾下上文提到的 chunk 的状态位 // 记录chunk的状态位 // 值:0 表示已加载完成。 // undefined : chunk 还没加载 // null :chunk preloaded/prefetched // Promise : chunk正在加载 var installedChunks = { a: 0 }; __webpack_require__.e = function requireEnsure(chunkId) { //...只保留核心代码 var promises = []; var installedChunkData = installedChunks[chunkId]; if (installedChunkData !== 0) { // chunk还没加载完成 if (installedChunkData) { // chunk正在加载 // 继续等待,因此只会加载一遍 promises.push(installedChunkData[2]); } else { // chunk 还没加载 // 使用script标签去加载对应的js var promise = new Promise(function(resolve, reject) { installedChunkData = installedChunks[chunkId] = [resolve, reject]; }); promises.push((installedChunkData[2] = promise)); // start chunk loading // var script = document.createElement("script"); var onScriptComplete; script.src = jsonpScriptSrc(chunkId); document.head.appendChild(script); //..... } // promise的resolve调用是在jsonpFunctionCallback中调用 return Promise.all(promises); };
再看看异步加载 asyncCommon1 chunk(也就是异步加载的 js) 的代码大体结构。它做的操作很简单,就是往jsonpFunction这个全局数组push(需要注意的是这个不是数组的 push,是被重写为入口 chunk 的webpackJsonpCallback函数)一个数组,这个数组由 chunk名和该chunk的 module 对象 一起组成。
// asyncCommon1 chunk (window["jsonpFunction"] = window["jsonpFunction"] || []).push([["asyncCommon1"],{ "./src/asyncCommon1.js": (function(module, __webpack_exports__, __webpack_require__) { eval(module代码....); }) }]);
而执行webpackJsonpCallback的时机,就是我们通过script把异步 chunk 拿回来了(肯定啊,因为请求代码回来,执行异步 chunk 内的push方法嘛!)。结合异步 chunk 的代码和下面的webpackJsonpCallback很容易知道,webpackJsonpCallback主要做了几件事:
1、将异步chunk的状态位置 0,表明该 chunk 已经加载完成。installedChunks[chunkId] = 0;
2、对__webpack_require__.e 中产生的相应的 chunk 加载 promise 进行 resolve
3、将异步chunk的模块 挂载到入口chunk的立即执行函数参数modules中。可供__webpack_require__进行获取。上文分析 a.js 模块已经提到了这个过程
// function webpackJsonpCallback(data) { var chunkIds = data[0]; var moreModules = data[1]; var moduleId, chunkId, i = 0, resolves = []; for (; i < chunkIds.length; i++) { chunkId = chunkIds[i]; if ( Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId] ) { resolves.push(installedChunks[chunkId][0]); } // 将当前chunk设置为已加载 installedChunks[chunkId] = 0; } for (moduleId in moreModules) { if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) { // 将异步`chunk`的模块 挂载到入口`chunk`的立即执行函数参数`modules`中 modules[moduleId] = moreModules[moduleId]; } } // 执行旧的jsonPFunction // 可以理解为原生的数组Array,但是这里很精髓,可以防止撞包的情况部分模块没加载! if (parentJsonpFunction) parentJsonpFunction(data); while (resolves.length) { // 对__webpack_require__.e 中产生的相应的chunk 加载promise进行resolve resolves.shift()(); } }
简单总结:
1、经过 webpack 打包,每一个 chunk 内的模块文件,都是组合成形如
{ [moduleName:string] : function(module, __webpack_exports__, __webpack_require__){ eval('模块文件源码') } }
2、同一页面多个 webpack 环境,output.jsonpFunction尽量不要撞名字。撞了一般也是不会挂掉的。只是会在立即执行函数的入参modules上挂上别的 webpack 环境异步加载的部分模块代码。(可能会造成一些内存的增加?)
3、每一个 entry chunk 入口都是一个类似的立即执行函数
(function(modules){ //.... })({ [moduleName:string] : function(module, __webpack_exports__, __webpack_require__){ eval('模块文件源码') } })
4、异步加载的背后是用script标签去加载代码
5、异步加载没那么神秘,对于当项目大到一定程度时,能有较好的效果
(本文地址因水平有限,如有错误欢迎拍砖)

