【Webpack】异步加载(懒加载)原理
一、前言
本文是 从零到亿系统性的建立前端构建知识体系✨ 中的第二篇,整体难度 ⭐️⭐️。
承接上文(从构建产物洞悉模块化原理),本文将继续从分析构建产物出发,探索 Webpack 中异步加载(懒加载)的原理,最后将彻底弄清楚懒加载是如何做到能够加快应用初始加载速度的,整体深度阅读时间约15分钟。
在正式开始之前我们先看看几个常见的相关面试题:
- 在Webpack搭建的项目中,如何达到懒加载的效果?
- 在Webpack中常用的代码分割方式有哪些?
- Webpack中懒加载的原理是什么?
- ......
相信读完本文,你对上面的一系列问题都能够轻松解答。
二、前置知识
在正式内容开始之前,先来学一些预备小知识点,以免影响后面的学习。
懒加载或者按需加载,是一种很好的优化网页或应用的方式。这种方式实际上是先把你的代码在一些逻辑断点处分离开,然后在一些代码块中完成某些操作后,立即引用或即将引用另外一些新的代码块。这样加快了应用的初始加载速度,减轻了它的总体体积,因为某些代码块可能永远不会被加载。
懒加载的本质实际上就是代码分离。把代码分离到不同的 bundle 中,然后按需加载或并行加载这些文件。
在Webpack中常用的代码分离方法有三种:
- 入口起点:使用 entry 配置手动地分离代码。
- 防止重复:使用 Entry dependencies 或者 SplitChunksPlugin 去重和分离 chunk。
- 动态导入:通过模块的内联函数调用来分离代码。
今天我们的核心主要是第三种方式:动态导入。
当涉及到动态代码拆分时,Webpack 提供了两个类似的技术:
- 第一种,也是推荐选择的方式是,使用符合 ECMAScript 提案 的 import()语法 来实现动态导入
- 第二种,则是 Webpack 的遗留功能,使用 Webpack 特定的 require.ensure (不推荐使用) ,本文不做探讨
我们主要看看 import()语法 的方式。
import()
的语法十分简单。该函数只接受一个参数,就是引用模块的地址,并且使用 promise
式的回调获取加载的模块。在代码中所有被 import()
的模块,都将打成一个单独的模块,放在 chunk
存储的目录下。在浏览器运行到这一行代码时,就会自动请求这个资源,实现异步加载。
常见使用场景:路由懒加载。
三、统一配置
为了防止出现我可以你不可以的情况,我们先统一配置:
"webpack": "^5.73.0",
"webpack-cli": "^4.10.0",
webpack.config.js 配置:
module.exports = {
mode: "development",
devtool: false,
entry: {
main: "./src/main.js",
},
output: {
filename: "main.js", //定义打包后的文件名称
path: path.resolve(__dirname, "./dist"), //必须是绝对路径
},
};
四、import()基本使用
我们先来看看使用 import()
异步加载的效果。
在 main.js 中同步导入并使用:
const buttonEle = document.getElementById("button");
buttonEle.onclick = function () {
import("./test").then((module) => {
const print = module.default;
print();
});
};
test.js:
export default () => {
console.log("按钮点击了");
};
先看打包结果:将 main.js 和 test.js 打包成了两个文件(说明有做代码分割)。
将打包后的文件在 index.html 中引入(注意这里只引用了 main.js ,并没有引用 src_test_js.main.js ):
<!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="./main.js"></script>
</html>
将 index.html 在浏览器中打开,查看网络请求:
发现首次并没有加载 src_test_js.main.js 文件(也就是 test.js 模块),在点击按钮后才会加载。符合懒加载的预期,确实有帮助我们做异步加载。
五、原理分析
结合现象看本质。在上面我们主要了解了异步加载的现象,接下来我们主要来分析和实现一下其中的原理。
老规矩,我们先说整体思路:
- 第一步:当点击按钮时,先通过
jsonp
的方式去加载test.js
模块所对应的文件 - 第二步:加载回来后在浏览器中执行此JS脚本,将请求过来的模块定义合并到
main.js
中的modules
中去 - 第三步:合并完后,去加载这个模块
- 第四步:拿到该模块导出的内容
整体代码思路(这里函数命名跟源代码有出入,有优化过):
第一步:当点击按钮时,先通过
jsonp
的方式去加载test.js
模块所对应的文件
const buttonEle = document.getElementById("button");
buttonEle.onclick = function () {
require.e("src_test_js") //src_test_js是test.js打包后的chunkName
};
接下来就去实现require.e
函数:
//接收chunkId,这里其实就是 "src_test_js"
require.e = function (chunkId) {
let promises = []; //定义promises,这里面放的是一个个promise
require.j(chunkId, promises); //给promises赋值
return Promise.all(promises); //只有当promises中的所有promise都执行完成后,才能走到下一步
};
require.j
函数:这一步其实就是给promises
数组赋值,并通过jsonp
去加载文件
//已经安装好的代码块,main.js就是对应的main代码块,0表示已经加载成功,已经就绪
var installedChunks = {
main: 0,
};
//这里传入的是 "src_test_js" , []
require.j = function (chunkId, promises) {
var promise = new Promise((resolve, reject) => {
installedChunks[chunkId] = [resolve, reject]; //此时installedChunks={ main: 0, "src_test_js":[ resolve, reject ]}
});
promises.push(promise); //此时promises=[ promise ]
var url = require.publicPath + chunkId + ".main.js"; //拿到的结果就是test.js打包后输出的文件名称:src_test_js.main.js,publicPath就是我们在output中配置的publicPath,默认是空字符串
let script = document.createElement("script");
script.src = url;
document.head.appendChild(script); //将该脚本添加进来
};
第二步:加载回来后在浏览器中执行此JS脚本,将请求过来的模块定义合并到
main.js
中的modules
中去
在第一步中我们通过jsonp
的方式加载了src_test_js.main.js
文件,加载后需要立即执行该文件的内容,我们先来看看该文件长什么样子:
self["webpackChunkstudy"].push([
["src_test_js"],
{
"./src/test.js": (modules, exports, require) => {
require.defineProperty(exports, {
default: () => WEBPACK_DEFAULT_EXPORT,
});
const WEBPACK_DEFAULT_EXPORT = () => {
console.log("按钮点击了");
};
},
},
]);
这里的self
其实就是window
,webpackChunkstudy
就是一个名字,它是webpackChunk
+ 我们package.json
中的 name
字段拼接来的,我这里是study。
翻译过来就是要执行 window.webpackChunkstudy.push([xxx])
这个函数,那接下来我们就实现一下它:接受一个二维数组作为参数,二维数组中,第一项是moduleId
,第二项是模块定义:
//初始化:默认情况下这里放的是同步代码块,这里的demo因为没有同步代码,所以是一个空的模块对象
var modules = {};
//这里chunkIds=["src_test_js"] moreModules={xxx} test.js文件的模块定义
function webpackJsonpCallback([chunkIds, moreModules]) {
const resolves = [];
for (let i = 0; i < chunkIds.length; i++) {
const chunkId = chunkIds[i];//src_test_js
resolves.push(installedChunks[chunkId][0]); //此时installedChunks={ main: 0, "src_test_js":[ resolve, reject ]} ,将 src_test_js 的resolve放到resolves中去
installedChunks[chunkId] = 0; //标识一下代码已经加载完成了
}
for (const moduleId in moreModules) {
modules[moduleId] = moreModules[moduleId]; //合并modules,此时modules中有了test.js的代码
}
while (resolves.length) {
resolves.shift()(); //执行promise中的resolve,当所有promises都resolve后,接下来执行第三步
}
}
window.webpackChunkstudy.push = webpackJsonpCallback;
此时 modules
已经变为:
var modules = {
"./src/test.js": (modules, exports, require) => {
require.defineProperty(exports, {
default: () => WEBPACK_DEFAULT_EXPORT,
});
const WEBPACK_DEFAULT_EXPORT = () => {
console.log("按钮点击了");
};
},
};
第三步:合并完后,去加载这个模块
走到这里require.e
函数中的 Promise.all 已经走完,接下来走到第一个.then
处:require.bind(require, "./src/test.js")
require.e("src_test_js") //完成第一步和第二步的工作
.then(require.bind(require, "./src/test.js")) //完成第三步
require
函数与之前相同,不做过多的赘述,大家可以看前一篇文章:从构建产物洞悉模块化原理。这里直接拷贝过来:
//已经加载过的模块
var cache = {};
//相当于在浏览器中用于加载模块的polyfill
function require(moduleId) {
var cachedModule = cache[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
var module = (cache[moduleId] = {
exports: {},
});
modules[moduleId](module, module.exports, require);
return module.exports;
}
require.defineProperty = (exports, definition) => {
for (var key in definition) {
Object.defineProperty(exports, key, {
enumerable: true,
get: definition[key],
});
}
};
这里执行完require.bind(require, "./src/test.js")
后,返回的是一个export
对象:
{
default: () => {
console.log("按钮点击了");
} //因为这里是默认导出,所以是default
}
第四步:拿到该模块导出的内容
require.e("src_test_js") //完成第一步和第二步的工作
.then(require.bind(require, "./src/test.js")) //完成第三步:前面代码加载并合并完后,去执行该模块代码
.then((module) => { //完成第四步
const print = module.default;
print();
});
在第三步中导出的是一个export
对象,又因为是默认导出,所以这里取值是module.default
,走到这里就完全走完啦。
六、整体代码
打包后的main.js
(经优化):
//初始化:默认情况下这里放的是同步代码块,这里的demo因为没有同步代码,所以是一个空的模块对象
var modules = {};
//已经加载过的模块
var cache = {};
//相当于在浏览器中用于加载模块的polyfill
function require(moduleId) {
var cachedModule = cache[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
var module = (cache[moduleId] = {
exports: {},
});
modules[moduleId](module, module.exports, require);
return module.exports;
}
require.defineProperty = (exports, definition) => {
for (var key in definition) {
Object.defineProperty(exports, key, {
enumerable: true,
get: definition[key],
});
}
};
//已经安装好的代码块,main.js就是对应的main代码块,0表示已经加载成功,已经就绪
var installedChunks = {
main: 0,
};
require.publicPath = ""; //output中的publicPath属性
require.j = function (chunkId, promises) {
var promise = new Promise((resolve, reject) => {
installedChunks[chunkId] = [resolve, reject];
});
promises.push(promise);
var url = require.publicPath + chunkId + ".main.js";
let script = document.createElement("script");
script.src = url;
document.head.appendChild(script);
};
function webpackJsonpCallback([chunkIds, moreModules]) {
const resolves = [];
for (let i = 0; i < chunkIds.length; i++) {
const chunkId = chunkIds[i];
resolves.push(installedChunks[chunkId][0]);
installedChunks[chunkId] = 0; //标识一下代码已经加载完成了
}
for (const moduleId in moreModules) {
modules[moduleId] = moreModules[moduleId]; //合并modules
}
while (resolves.length) {
resolves.shift()();
}
}
self.webpackChunkstudy = {};
self.webpackChunkstudy.push = webpackJsonpCallback;
require.e = function (chunkId) {
let promises = [];
require.j(chunkId, promises);
return Promise.all(promises);
};
const buttonEle = document.getElementById("button");
buttonEle.onclick = function () {
require
.e("src_test_js")
.then(require.bind(require, "./src/test.js"))
.then((module) => {
const print = module.default;
print();
});
};
打包后的test.js
:
self["webpackChunkstudy"].push([
["src_test_js"],
{
"./src/test.js": (modules, exports, require) => {
require.defineProperty(exports, {
default: () => WEBPACK_DEFAULT_EXPORT,
});
const WEBPACK_DEFAULT_EXPORT = () => {
console.log("按钮点击了");
};
},
},
]);
七、总结
上面我们差不多用50行代码写了一个简易demo实现了懒加载原理,在该demo中当然还有一些场景没有考虑进去:比如当点击按钮时,只需第一次加载时去请求文件,后面加载时应该要去使用缓存。但这并不是重点,希望通过本章大家能够更加深入理解Webpack中的懒加载,早日摆脱API工程师。
八、推荐阅读
转载自:https://juejin.cn/post/7152516872330543141