import()模块懒加载的实现
紧接上一篇文章(强烈建议先去阅读),从零开始的Webpack原理剖析(一)我们了解了webpack中如何处理CommonJs模块与ES module之间相互加载,那么,这篇文章,我们重点来讲解一下webpack是如何处理懒加载的,首先,我们要通过一个简单的示例,来了解一下懒加载到底是什么样子的。我们还是要做下准备工作:
懒加载的示例
step 1:
npm init -y生成package.json文件,并在scripts中配置"build": "webpack"和"dev":"webpack serve" 命令
step 2:
npm i webpack webpack-cli html-webpack-plugin webpack-dev-server -D 来安装基本的依赖(本文章中用的都是webpack5的版本)
step 3:
在package.json同级创建webpack.config.js,其中内容为:
const path = require("path")
const HtmlWebpackPlugin = require("html-webpack-plugin")
module.exports = {
mode: "development", // 模式写成development,方便查看打包结果
devtool: false,
entry: "./index.js",
output: {
path: path.resolve(__dirname, "dist"),
filename: "main.js"
},
plugins: [
new HtmlWebpackPlugin({ template: "./index.html", filename: "index.html"})
],
devServer: {
port: 8080
}
}
step 4:
在package.json同级创建index.js,testName.js,index.html文件,内容如下:
// index.js
btn.onclick = async function () {
const result = await import(/* webpackChunkName: "testName" */ './testName')
console.log(result.default)
}
// testName.js
export default 'test_name_xiaoMing'
// 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>webpack</title>
</head>
<body>
<button id="btn">我是按钮嘿嘿嘿</button>
</body>
</html>
使用npm run dev命令启动服务,并在浏览器中打开localhost:8080和控制台。此时页面上只有一个按钮,我们打开network面板查看,发现只有一个main.js文件(index.js打包后的名字,和webpack.config.js中filename配置的名字相对应)。
当我们点击了按钮之后,发现此时多了一个testName.main.js文件,并且控制台成功打印出来了testName.js
中默认导出的名字。
通过这个例子,我们可以简单的了解了懒加载是什么,没错顾名思义,懒加载就是当需要的时候,才会加载这部分的代码,懒加载这部分代码,在页面一开始加载的时候,并没有被加载到页面中,因此在一定程度上可以提升页面打开的速度,那么基本的演示看过了,webpack是怎么实现这个懒加载的功能呢?我们对代码稍作修改,还是从最简单的打包结果入手,来好好的研究下其原理:
// index.js,删除原有代码,用以下代码覆盖
// 注:webpackChunkName为魔法注释,后边的字符串为懒加载模块的模块名,具体查看webpack文档~
import(/* webpackChunkName: "testName" */ './testName').then(result => {
console.log(result)
})
// testName.js,保持不变
// index.html,保持不变
懒加载的实现原理探究
接下来我们使用npm run build命令,进行打包,并观察dist文件中的结果,发现生成了2个js文件:main.js和testName.main.js,经过使用和上一篇文章相同方法的代码简化,注释删除,变量同名替换等操作,我们得到了如下所示的2个文件的代码,我们一点点进行分析
// testName.main.js文件中的代码
/* 'webpackChunkwebpack_exercise'这个名字是webpack自动生成的,规范为webpackChunk+项目文件夹的
名字,可以理解为一个常量;又见到了熟悉的代码,因为testName.js是用ES module导出的,所以要先调用
require.r方法做标识,之后再用require.d方法进行赋值,是不是很熟悉?没错其实拆出来的这个
testName.main.js方法,就可以理解为我们上一篇文章里最一开始定义模块modules的操作*/
(self["webpackChunkwebpack_exercise"] = self["webpackChunkwebpack_exercise"] || []).push([["testName"], {
"./testName.js": (module, exports, require) => {
require.r(exports);
require.d(exports, {
"default": () => _DEFAULT_EXPORT__
});
const _DEFAULT_EXPORT__ = 'test_name_xiaoMing';
}
}])
main.js完整处理过的代码如下,我们接下来拆开,进行详细的分析
// main.js文件中的代码
var modules = {};
var cache = {};
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.r = exports => {
if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
Object.defineProperty(exports, Symbol.toStringTag, {
value: 'Module'
});
}
Object.defineProperty(exports, '__esModule', {
value: true
});
};
require.d = (exports, definition) => {
for (var key in definition) {
if (require.o(definition, key) && !require.o(exports, key)) {
Object.defineProperty(exports, key, {
enumerable: true,
get: definition[key]
});
}
}
};
require.o = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop);
require.e = chunkId => {
return Promise.all(Object.keys(require.f).reduce((promises, key) => {
require.f[key](chunkId, promises);
return promises;
}, []));
};
require.u = chunkId => {
return "" + chunkId + ".main.js";
};
require.p = "";
require.m = modules;
require.f = {};
var inProgress = {};
var dataWebpackPrefix = "webpack-exercise:";
require.l = (url, done, key, chunkId) => {
if (inProgress[url]) {
inProgress[url].push(done);
return;
}
var script, needAttach;
if (key !== undefined) {
var scripts = document.getElementsByTagName("script");
for (var i = 0; i < scripts.length; i++) {
var s = scripts[i];
if (s.getAttribute("src") == url || s.getAttribute("data-webpack") == dataWebpackPrefix + key) {
script = s;
break;
}
}
}
if (!script) {
needAttach = true;
script = document.createElement('script');
script.charset = 'utf-8';
script.timeout = 120;
if (require.nc) {
script.setAttribute("nonce", require.nc);
}
script.setAttribute("data-webpack", dataWebpackPrefix + key);
script.src = url;
}
inProgress[url] = [done];
var onScriptComplete = (prev, event) => {
script.onerror = script.onload = null;
clearTimeout(timeout);
var doneFns = inProgress[url];
delete inProgress[url];
script.parentNode && script.parentNode.removeChild(script);
doneFns && doneFns.forEach(fn => fn(event));
if (prev) return prev(event);
};
var timeout = setTimeout(onScriptComplete.bind(null, undefined, {
type: 'timeout',
target: script
}), 120000);
script.onerror = onScriptComplete.bind(null, script.onerror);
script.onload = onScriptComplete.bind(null, script.onload);
needAttach && document.head.appendChild(script);
};
var installedChunks = {
"main": 0
};
require.f.j = (chunkId, promises) => {
var installedChunkData = require.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;
if (installedChunkData !== 0) {
if (installedChunkData) {
promises.push(installedChunkData[2]);
} else {
if (true) {
var promise = new Promise((resolve, reject) => installedChunkData = installedChunks[chunkId] = [resolve, reject]);
promises.push(installedChunkData[2] = promise);
var url = require.p + require.u(chunkId);
var error = new Error();
var loadingEnded = event => {
if (require.o(installedChunks, chunkId)) {
installedChunkData = installedChunks[chunkId];
if (installedChunkData !== 0) installedChunks[chunkId] = undefined;
if (installedChunkData) {
var errorType = event && (event.type === 'load' ? 'missing' : event.type);
var realSrc = event && event.target && event.target.src;
error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
error.name = 'ChunkLoadError';
error.type = errorType;
error.request = realSrc;
installedChunkData[1](error);
}
}
};
require.l(url, loadingEnded, "chunk-" + chunkId, chunkId);
} else installedChunks[chunkId] = 0;
}
}
};
var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
var [chunkIds, moreModules, runtime] = data;
var moduleId,
chunkId,
i = 0;
if (chunkIds.some(id => installedChunks[id] !== 0)) {
for (moduleId in moreModules) {
if (require.o(moreModules, moduleId)) {
require.m[moduleId] = moreModules[moduleId];
}
}
if (runtime) var result = runtime(require);
}
if (parentChunkLoadingFunction) parentChunkLoadingFunction(data);
for (; i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if (require.o(installedChunks, chunkId) && installedChunks[chunkId]) {
installedChunks[chunkId][0]();
}
installedChunks[chunkId] = 0;
}
};
var chunkLoadingGlobal = self["webpackChunkwebpack_exercise"] = self["webpackChunkwebpack_exercise"] || [];
chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));
var exports = {};
require.e("testName").then(require.bind(require, "./testName.js")).then(function (result) {
console.log(result);
});
我们先按从上到下的顺序,回顾下比较熟悉的代码
// 没错,和上篇文章的代码几乎一模一样
// 创建一个空的模块对象(因为是异步加载,同步加载的时候,modules对象中会存放key和value)
var modules = {};
// 创建缓存对象
var cache = {};
// 重新定义一个require方法
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;
}
// 只要打包前的模块是ES module,那么就调用require.r方法,为其做标识
require.r = exports => {
if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
Object.defineProperty(exports, Symbol.toStringTag, {
value: 'Module'
});
}
Object.defineProperty(exports, '__esModule', {
value: true
});
};
// 给导出对象进行赋值
require.d = (exports, definition) => {
for (var key in definition) {
if (require.o(definition, key) && !require.o(exports, key)) {
Object.defineProperty(exports, key, {
enumerable: true,
get: definition[key]
});
}
}
};
/* 其实上篇文章打包出来也有这个方法,只不过无关紧要,所以当时删除了,作用很简单,就是调用了下
hasOwnProperty方法,在for...in...遍历的时候,不遍历其原型链上的key,提升下效率 */
require.o = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop);
接下来我们再看下有哪些我们之前没有见过的新方法:
// 定义m,赋值为之前声明的modules
require.m = modules;
// 定义u方法,作用很简单,就是模块名的拼接
require.u = chunkId => {
return "" + chunkId + ".main.js";
};
// 定义p,为分隔符,对应着webpack.config.js中的publicPath属性,一般默认就是空
require.p = "";
// 定义f,初始化值是一个空对象
require.f = {};
/* 定义一个e方法,在代码正式开始运行的时候(位置在最后几行),首先就要调用此方法作用就是异步加载
testName代码块文件,Promise成功后,会将testName.main.js里边的代码,合并到 require.m这个对象上,
之后的步骤就和以前同步加载是一样的了 */
require.e = chunkId => {
return Promise.all(Object.keys(require.f).reduce((promises, key) => {
require.f[key](chunkId, promises);
return promises;
}, []));
};
/* 上边其他方法还好,但是require.e代码第一眼看上去,会有些难以理解,我们不妨简化一下逻辑,做一下等价代
换,即为:*/
require.e = chunkId => {
let promises = []
// 其实就是相当于执行require.f.j方法,并把chunkId和promises传进去(后边重点讲解require.f.j方法)
require.f.j(chunkId, promises)
// 将require.f.j处理好的promises数组传入,返回Promise.all的结果
return Promise.all(promises)
}
经过对require.e方法的解释,相信大家已经了解其具体作用了,那么接下来我们再继续看几个比较复杂的方法,其实就是如何实现异步加载的核心逻辑了,同样,先说结论:没错,就是通过JSONP异步加载chunkId对应的代码块,也就是testName.main.js文件。
// 声明已经安装好的代码,key是代码块的名字,value为0表示已经加载完成
var installedChunks = {
"main": 0
};
/* 我们来看require.f.j方法,作用已经提到过了,就是通过JSONP来加载代码块,那么源码中这一大堆逻辑,我们
只需要看其核心逻辑即可,很多的代码是兼容了异常和特殊的场景,对我们研究其核心逻辑影响不大,那么我们可以做
如下核心代码代码的等价代换,减少阅读的难度 */
require.f.j = (chunkId, promises) => {
var installedChunkData = require.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;
if (installedChunkData !== 0) {
if (installedChunkData) {
promises.push(installedChunkData[2]);
} else {
if (true) {
var promise = new Promise((resolve, reject) => installedChunkData = installedChunks[chunkId] = [resolve, reject]);
promises.push(installedChunkData[2] = promise);
var url = require.p + require.u(chunkId);
var error = new Error();
var loadingEnded = event => {
if (require.o(installedChunks, chunkId)) {
installedChunkData = installedChunks[chunkId];
if (installedChunkData !== 0) installedChunks[chunkId] = undefined;
if (installedChunkData) {
var errorType = event && (event.type === 'load' ? 'missing' : event.type);
var realSrc = event && event.target && event.target.src;
error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
error.name = 'ChunkLoadError';
error.type = errorType;
error.request = realSrc;
installedChunkData[1](error);
}
}
};
require.l(url, loadingEnded, "chunk-" + chunkId, chunkId);
} else installedChunks[chunkId] = 0;
}
}
};
// 核心逻辑如下
require.f.j = (chunkId, promises) => {
// 声明一个当前代码块的数据
let installedChunkData;
// 创建一个Promise
const promise = new Promise((resolve, reject) => {
/* 给已安装好的代码块进行赋值,此时installedChunks里边的值为
{
"main": 0,
"testName": [resolve, reject]
}*/
installedChunkData = installedChunks[chunkId] = [resolve, reject]
})
/* 把新生成的promise也push到当前代码块数据中,此时的 installedChunkData为[resolve, reject,
promise], installedChunks里边的值也会同时收到影响,为{
"main": 0,
"testName": [resolve, reject, promise]
}*/
promises.push(installedChunkData[2] = promise);
// 如果不知道上边的代码为啥这么做,那就先继续往下看,等看完再进行理解
// 接下来,就需要进行url拼接,调用之前提到过的p和u方法,得到url
var url = require.p + require.u(chunkId);
// 最后调用require.l方法,根据url来实现JSONP
require.l(url)
}
接下来,我们再来分析一下require.l方法:
// 是不是看起来又头大了,没关系,我们再次简化逻辑,给l方法瘦瘦身
require.l = (url, done, key, chunkId) => {
if (inProgress[url]) {
inProgress[url].push(done);
return;
}
var script, needAttach;
if (key !== undefined) {
var scripts = document.getElementsByTagName("script");
for (var i = 0; i < scripts.length; i++) {
var s = scripts[i];
if (s.getAttribute("src") == url || s.getAttribute("data-webpack") == dataWebpackPrefix + key) {
script = s;
break;
}
}
}
if (!script) {
needAttach = true;
script = document.createElement('script');
script.charset = 'utf-8';
script.timeout = 120;
if (require.nc) {
script.setAttribute("nonce", require.nc);
}
script.setAttribute("data-webpack", dataWebpackPrefix + key);
script.src = url;
}
inProgress[url] = [done];
var onScriptComplete = (prev, event) => {
script.onerror = script.onload = null;
clearTimeout(timeout);
var doneFns = inProgress[url];
delete inProgress[url];
script.parentNode && script.parentNode.removeChild(script);
doneFns && doneFns.forEach(fn => fn(event));
if (prev) return prev(event);
};
var timeout = setTimeout(onScriptComplete.bind(null, undefined, {
type: 'timeout',
target: script
}), 120000);
script.onerror = onScriptComplete.bind(null, script.onerror);
script.onload = onScriptComplete.bind(null, script.onload);
needAttach && document.head.appendChild(script);
};
/* require.l的核心逻辑,是不是非常简单,就是创建了script标签然后添加到head标签,这时候,其实就已经
能拿到testName.main.js文件了,拿到文件之后,就剩下最后一步,就是将testName.main.js中的信息合并
到require.m这个模块对象中,让我们继续往下看 */
require.l = url => {
let script = document.createELement('script')
script.src = url
document.head.appendChild(script)
}
那么又会问了,为啥这些方法都是一个字母,根本不好记,太奇怪了吧?其实之所以这些方法名字都是一个字母,是因为想尽量减少打包的体积,因为在代码压缩的时候,对象中的key是不会被压缩的,所以就要尽可能的减少长度,能做到语义化的就用相应的字母,比如__webpack_require__.d方法,里边用了defineProperty赋值,所以就取d作为简写,实在做不到语义化么,那就随缘取名吧~
我们继续进行分析最后一步:拿到testName.main.js文件之后,我们还需要执行回调函数,将testName.main.js中的代码合并到之前说的require.m模块对象中去
// 源码中的self等价于window,那么相当于在window全局对象中挂载了一个属性,同样
var chunkLoadingGlobal = self["webpackChunkwebpack_exercise"] = self["webpackChunkwebpack_exercise"] || [];
chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));
// 我们可以优化下代码,提取上边的核心逻辑,简写代码增加可读性
var chunkLoadingGlobal = window["webpackChunkwebpack_exercise"] || [];
// 重写其push方法为JSONP的回调函数
chunkLoadingGlobal.push = webpackJsonpCallback
// 接下来我们来分析回调函数,同样,可以优化其逻辑
var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
var [chunkIds, moreModules, runtime] = data;
var moduleId,
chunkId,
i = 0;
if (chunkIds.some(id => installedChunks[id] !== 0)) {
for (moduleId in moreModules) {
if (require.o(moreModules, moduleId)) {
require.m[moduleId] = moreModules[moduleId];
}
}
if (runtime) var result = runtime(require);
}
if (parentChunkLoadingFunction) parentChunkLoadingFunction(data);
for (; i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if (require.o(installedChunks, chunkId) && installedChunks[chunkId]) {
installedChunks[chunkId][0]();
}
installedChunks[chunkId] = 0;
}
};
// 优化代码为下,chunkIds为testName.main.js文件中的模块id数组,moreModules,为模块的信息
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;//表示此代码块已经下载完毕
}
//合并模块定义到modules去
for (const moduleId in moreModules) {
modules[moduleId] = moreModules[moduleId];
}
//依次取出resolve方法并执行
while (resolves.length) {
resolves.shift()();
}
}
到此为止,require.e方法的整体逻辑就走完了,结果就是require.m这个模块对象中,有了testName模块的信息,之后再执行require方法,步骤就和上一篇文章里讲的,一模一样啦。
分析完了一遍源码,看到了结尾,仿佛又全忘了,那最后的总结,一定不能少~
总结
require中方法的总结
- require.m:全局存储的模块对象;
- require.p:拼接的路径,受webpack.config.js中publicPath影响,一般默认为空;
- require.u:异步模块的名称,受懒加载中魔法注释,和webpack.config.js中filename影响;
- require方法:重写了node中的require方法,使得代码能够在浏览器中运行;
- require.r方法:区分是否为ES module,添加ES module的标签;
- require.o方法:工具函数,调用hasOwnProperty;
- require.d方法:用来给module.exports进行赋值;
- require.e方法:用来加载异步模块入口,内部使用require.f.j方法加载模块;
- require.f.j方法:作用主要是将异步的promise设置到installedChunks中,为方便在最后的;webpackJsonpCallback回调函数中使用resolve()来让当前promise变为成功态,还会调用require.l方法来加载js文件;
- require.l方法:动态创建script标签并且加载js文件;
- webpackJsonpCallback:异步js文件内的执行方法,将installedChubks中对应模块的promise使用;resolve()使其状态变为fullfill成功态,并且将异步js文件中的模块存储到require.m这个全局模块中;
异步模块整体加载流程
// 代码开始执行的加载入口
require.e("testName").then(require.bind(require, "./testName.js")).then(function (result) {
console.log(result);
});
异步加载文件的时候,从入口文件触发,依次调用require.e -> require.f.j -> require.l方法,动态创建script标签,并且下载testName异步模块文件,下载完毕后,require.e会返回Promise.all;下载完的testName文件会自动执行文件中的self['webpackChunkwebpack_exercise'].push方法,因为这个push方法已经被重写成了webpackJsonpCallback回调函数,所以函数被调用,Promise.all中promise的状态变为了fullfill成功态,require.e方法执行完毕,接下来在.then方法中,执行require方法,来引入对应的文件,整个流程便执行完毕。
最后
刚开始看懒加载源码的时候,也是硬着头皮去看的,发现没必要每行代码都读懂,只需要把核心的逻辑看懂就好了,剩下的有空再去慢慢研究,把整个流程跑通了,弄懂了,才是最重要的,欢迎大家一起来讨论~