webpack 懒加载(异步加载)原理
在 JS 模块中,我们可以通过 import()
语法动态导入某个模块,也就是懒加载模块,那么 webpack 是如何处理懒加载的呢?
demo 演示
我们先来写一个 demo
// .src/a.js
export default () => {
console.log("Jolyne");
};
// .src/main.js
const buttonEle = document.getElementById("button");
buttonEle.onclick = function () {
//懒加载 a 模块
import("./a").then((module) => {
const callback = module.default;
callback();
});
};
// .src/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<button id="button">点击按钮懒加载</button>
</body>
<script src="../dist/main.js"></script>
</html>
webpack 配置:
// webpack.config.js
module.exports = {
mode: "development",
entry: "./src/main.js",
devtool: "source-map",
}
package.json 脚本
// package.json
{
"scripts": {
"build": "webpack --config ./webpack.config.js --env production",
},
}
我们执行 npm run build
后,得到如下的文件:
其中,src_a_js.js
就是通过 import("./a")
懒加载的模块
我们在浏览器中打开 index.html
,打开控制台:
此时只加载了 main.js,且控制台没有任何输出。当我点击按钮
后,才会执行 src_a_js.js
模块,控制台打印 Jolyne
原理解析
我们打包代码后,在 ./dist/main.js
里面能找到如下的代码:
我们发现他会分为三个步骤:
- 调用
__webpack_require_.e("src_a_js")
,返回Promise
- 调用
webpack\_require.bind(webpack\_require, "./src/a.js")
- 通过
module.default
拿到 () => { console.log("Jolyne") } 并执行该 default 函数
我们来拆解他们分别做了什么
_webpack_require.e
这个函数做的事情可以概括为:
- 第一部分:通过
Jsonp
的形式去加载 懒加载的模块 - 第二部分:
执行该模块
,将该模块的代码合并
到同步模块中
我们来看他是如何通过 JSONP 加载异步模块的:
//这个变量代表已经加载完毕的模块
var installChunks = {
main: 0 // ./dist/main.js 这个模块已经加载完毕了
}
//这个变量存储:所有同步模块,demo里面没有同步模块,所以当前是个空对象
var modules = {}
const __webpack_require_.e = (chunkId) => {
/**
第一部分:JSONP 加载模块
**/
const promises = []
const currentPromise = new Promise((resolve, reject) => {
installChunks[chunkId] = [resove, reject]
})
promises.push(currentPromise)
const url = require.publicPath + chunkId //这个 url 就是拼接了 ./dist/src_a_js.js 的路径
const srcipt = document.createElement("script")
srcipt.src = url
document.head.appendChild(script)
/**
第二部分:执行模块,合并到同步模块中
**/
//这个函数我们分析第二步的时候再细说
webpackJsonpCallback()
return Promise.all(promises)
}
首先,第一部分的代码的逻辑是:
- 创建一个 promises 数组
- 创建一个 promise 对象,以
chunkId
(当前要去加载的这个异步模块的Id)为 key,将 promise 对象 的resolve
、reject
回调记录在installChunks[chunkId]
上,此时: installChunks = { main: 0, src_a_js: [resolve, reject] } - 拼接 url,创建 script 标签,设置 srcipt.src = url,然后加载 url 指向的模块
至于为什么要创建一个 Promise 对象,又要记录它的 resolve、reject 回调,我们在 第二部分
里面分析
第一部分加载回来的模块长这样:
(self["webpackChunkwebpack_module"] =
self["webpackChunkwebpack_module"] || []).push([
["src_a_js"],
{
"./src/a.js": (
__unused_webpack_module,
__webpack_exports__,
__webpack_require__
) => {
__webpack_require__.r(__webpack_exports__);
__webpack_require__.d(__webpack_exports__, {
default: () => __WEBPACK_DEFAULT_EXPORT__,
});
const __WEBPACK_DEFAULT_EXPORT__ = () => {
console.log("Jolyne");
};
},
},
]);
然后需要执行该模块,那么如何执行呢?此时就会调用一个叫做 webpackJsonpCallback
的函数
//...第一部分的代码
/**
chunkIds 就是上面 ["src_a_js"]
moreModules 就是上面的:
{
"./src/a.js": (
__unused_webpack_module,
__webpack_exports__,
__webpack_require__
) => {
__webpack_require__.r(__webpack_exports__);
__webpack_require__.d(__webpack_exports__, {
default: () => __WEBPACK_DEFAULT_EXPORT__,
});
const __WEBPACK_DEFAULT_EXPORT__ = () => {
console.log("Jolyne");
};
},
},
**/
const webpackJsonpCallback = (chunkIds, moreModules) => {
const resolves = []
for (moduleId in moreModules) {
// modules 是第一部分里面存储所有同步模块的那个变量
// 相当于把异步模块的代码合并到 modules 上
modules[moduleId] = moreModules[moduleId]
}
for (let i = 0; i < chunkIds.length; i++) {
// 还记得之前我们将 Promise 对象的 resolve、reject 回调记录在 installChunks 上吗
// 这里我们就是去取出 resolve 回调,放到 resolves 数组中去
resolves.push(installChunks[chunkIds[i]][0])
// 标识当前这个懒加载的模块已经执行完毕了
installChunks[chunkIds[i]] = 0
}
while (resolves.length) {
// 第一部分我们不是 return Promise.all(promises) 吗?
// 这里相当于去执行 promises 里面所有的 resolve 回调
// 当 所有的 resolve 回调执行完毕之后,表明所有的懒加载模块都加载且合并完毕了
resolves.shift()()
}
// 执行到这里,__webpack_require_.e 这个函数才执行完毕
}
上面的代码的思路为:
- 创建
resolves
数组,用来存放第一部分我们创建的 Promise 对象的被记录的resolve 回调
- 将懒加载的模块代码
合并到 modules
这个变量上 - 标识 installChunks[moduleId] = 0,表明懒加载的模块已经合并完成了
执行 resolves 数组里面所有的 resolve 回调
,这样第一部分中return Promise.all(promises)
才会继续下一步
此时 modules
变量为:
const modules = {
"./src/a.js": (modules, exports, require) => {
require.defineProperty(exports, {
default: () => WEBPACK_DEFAULT_EXPORT,
});
const WEBPACK_DEFAULT_EXPORT = () => {
console.log("按钮点击了");
};
},
};
看完第二部分的操作后,我们来回答一下第一部分我们抛出的问题: 为什么要创建一个 Promise 对象,又要记录它的 resolve、reject 回调
创建 Promise 对象是
为了能跟踪懒加载模块的状态
(是否正在加载?是否加载完毕?),记录 resolve、reject 回调 是为了确保懒加载模块加载完毕且已经合并了代码
,此时执行 resolve() 改变 Promise 的状态,第一部分的 return Promise.all(promises) 成功后,才继续下一步操作
至此,__webpack_require_e 函数执行完毕
webpack_require.bind(webpack_require, "./src/a.js")
这一步其实就是对 exports 对象做代理,然后 return exports 对象
const webpack_cache = {};
function require(moduleId) {
var cache = webpack_cache[moduleId];
if (cache !== undefined) {
return cache.exports;
}
var module = (webpack_cache[moduleId] = {
exports: {},
});
// 对 exports 对象做代理
modules[moduleId](module, module.exports, require);
// 返回 exports 对象
return module.exports;
}
require.defineProperty = (exports, definition) => {
for (var key in definition) {
Object.defineProperty(exports, key, {
enumerable: true,
get: definition[key],
});
}
};
这一部分可以去看 # webpack模块化原理解析,这里不细说了
然后抛出的 exports 对象就可以在第三步拿到了,也就是:
const buttonEle = document.getElementById("button");
buttonEle.onclick = function () {
//第一步
__webpack_require__.e(/*! import() */ "src_a_js")
.then(
__webpack_require__.bind(__webpack_require__, /*! ./a */ "./src/a.js")
)
.then((module) => {
//在这里,就能拿到 exports 对象了
const callback = module.default;
callback(); // Jolyne
});
};
总结
webpack 模块懒加载的原理:
- 创建
Promise
对象(用来跟踪懒加载模块的状态
),以模块Id 为 key,记录[resolve、reject]
回调(确保模块加载合并完毕
) - 拼接 url,创建 script 标签,
以 JSONP 的形式加载模块
,然后执行webpackJsonpCallback函数
,合并模块代码到 modules`(modules是记录所有同步模块的变量),完毕后执行 resolve 回调 对 exports 对象做代理
,返回 exports 对象- 最后从 exports 对象身上拿到 default 函数,执行并打印结果