前言
否有过这样的经历,当打开一个大型网页的时候,发现它加载得非常慢,甚至卡顿或者崩溃。在现代的前端开发中,随着网页的功能越来越复杂,依赖的模块也越来越多,如果我们将所有的模块都打包到一个文件中,那么就会导致文件过大,加载时间过长,用户体验不佳,甚至影响网页的性能和稳定性。
上篇文章已经讲述了webpack加载同步模块的原理,但是异步加载模块相对更加重要,因为它一般都会涉及到网页的性能,例如:vue的路由懒加载就是利用了异步模块加载逻辑,当用户点击了对应的路由才去加载模块页面。
那么,webpack是如何实现异步加载模块的,它又有哪些原理和技巧呢? 本文将结合最基础的案例详细解答这些问题。
一、webpack异步加载模块
1. 异步加载优势
- 代码分割(Code Splitting):Webpack允许将代码拆分为多个块(chunks),并在需要时动态加载这些块。这意味着可以将应用程序划分为更小的模块,只在需要时加载,而不是一次性加载整个应用程序。这可以减少初始加载时间,提高性能。
- 动态导入语法:Webpack提供了动态导入语法,例如使用import()函数或require.ensure()函数来异步加载模块。这些函数返回一个Promise,可以使用then方法处理加载成功的回调,或使用catch方法处理加载失败的回调。
- 按需加载:通过异步加载模块,可以根据需要加载特定的模块,而不是将所有模块打包到同一个文件中。这样可以减少初始加载时间,并在用户需要时动态加载额外的模块。
- 代码并行加载:Webpack可以同时加载多个模块,利用浏览器的并行加载能力,从而加快加载速度。这对于大型应用程序和复杂的依赖关系特别有用。
2. 项目配置和入口代码
(一) webpack.config.js
javascript
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
module.exports = {
mode: "development",
devtool: "source-map",
entry: "./src/index.js",
output: {
path: path.resolve(__dirname, "./bundle"),
filename: "[name]_bundle.js",
// 异步加载模块命名规则
chunkFilename: "[name]_chunk.js"
},
module: {},
plugins: [
new HtmlWebpackPlugin({
template: "./src/index.html",
filename: "index.html"
}),
new CleanWebpackPlugin()
]
}
(二) 入口代码
javascript
// index.js 入口文件
import(/* webpackChunkName: "header" */'./header.js').then(res => {
console.log(res);
})
// header.js 异步模块
export default "header-com";
export const header = "header";
(三) 打包后执行结果
- 打包后会产生两个包,一个入口文件包一个就是header的异步加载包
- 执行过程中会去请求header_chunk.js包
- 浏览器输出结果
二、分析webpack异步加载流程
1. 同步模块加载回顾
上篇已经介绍了webpack加载同步模块的过程,并且介绍了webpack是如何转化es模块为commonjs规范的;
这里简单回顾下引入同步模块后webpack打包后的结果以及执行过程
javascript
(() => {
// 模块1: 模块依赖关系
let modules = ({
"./header.js": (module) => {
module.exports = "header-com"
}
})
// 模块2: 依赖记录
let modules_cache = {};
// 模块3: require函数引入模块
function require(moduleId) {
let cacheModule = modules_cache[moduleId];
if (cacheModule !== undefined) {
return cacheModule;
}
let module = modules_cache[moduleId] = {
exports: {}
}
modules[moduleId](module, module.exports, require);
return module.exports;
}
// 扩展require方法
// 判断属性值是否在对象上
require.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
// 给exports属性定义getter方法(es模块处理)
require.d = (exports, definition) => {
for (let key in definition) {
if (require.o(definition, key) && !require.o(exports, key)) {
Object.defineProperty(exports, key, {
enumerable: true,
get: definition[key]
})
}
}
}
// 标记为es模块
require.r = (exports) => {
if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
Object.defineProperty(exports, Symbol.toStringTag, {value: 'Module'});
}
Object.defineProperty(exports, '__esModule', {value: true});
};
// 模块4:入口函数执行
(() => {
const header = require("./header.js");
console.log(header);
})();
})();
2. 异步模块流程解析
通过打包后的结果来分析基本的步骤流程
(一) 分析header_chunk.js结构
这里的self指的就是window,可以看这个脚本实际上就是执行了window上webpackChunkwebpack_s的一个push方法并且传入了这个模块名以及对应的模块依赖关系;
这个脚本一加载就会执行这个方法,所以这是一个JSONP;
css
(self["webpackChunkwebpack_s"] = self["webpackChunkwebpack_s"] || []).push([["header"], {
"./src/header.js":
((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.r(__webpack_exports__);
__webpack_require__.d(__webpack_exports__, {
"default": () => (__WEBPACK_DEFAULT_EXPORT__),
header: () => (/* binding */ header)
});
const __WEBPACK_DEFAULT_EXPORT__ = ("header-com");
const header = "header";
})
}]);
(二) 入口打包后的执行逻辑
javascript
__webpack_require__.e(/*! import() | header */ "header")
.then(__webpack_require__.bind(__webpack_require__, "./src/header.js"))
.then(res => {
console.log(res);
})
通过入口执行逻辑可以分析出大致的步骤:
- 执行require.e方法返回一个promise;
- 这个promise的结果就是header_chunk.js里模块的依赖关系(通过JSONP获取),并且把它挂载到全局modules模块依赖关系中;
- 然后继续返回了一个promise,这个promise里的值就是通过require函数执行header.js模块的依赖关系后获取的;
3. 逐步流程解析
必须先理解入口执行逻辑和header_chunk.js异步模块相关逻辑
javascript
// 编译后入口执行逻辑
require.e( "header").then(require.bind(require, "./src/header.js")).then(res => {
console.log(res);
})
// header_chunk.js文件信息
(self["webpackChunkwebpack_s"] = self["webpackChunkwebpack_s"] || []).push([["header"], {
"./src/header.js":
((module, exports, require) => {
require.r(exports);
require.d(exports, {
"default": () => (__WEBPACK_DEFAULT_EXPORT__),
header: () => (header)
});
const __WEBPACK_DEFAULT_EXPORT__ = ("header-com");
const header = "header";
})
}]);
(一) require.e方法
可以看出它会返回一个Promise, 但是执行过程中会进入require.f.j内部方法中。这里源码就是这么定义的,只需要了解因为属性无法通过前端工具(Terset)进行压缩,所以webpack通过一个字母的形式减少代码体积,但是字母又不够用所以通过类似命名空间的形式进行扩展;
javascript
// webpack打包后入口文件
(() => {
// ...
// 定义e函数
require.e = (chunkId) => {
// 记录所有的promise
let promises = [];
require.f.j(chunkId, promises);
return Promise.all(promises);
}
// 入口文件执行
require.e( "header").then(require.bind(require, "./src/header.js")).then(res => {
console.log(res);
})
})();
(二) require.f.j方法
这里需要重点理解这个方法;
- installedChunks用来记录已经安装模块和待安装模块的resolve和reject函数
- 当require.e方法传入了chunkId也就是header字符串和一个装载promise的数组后,j函数执行的时候会处理三个步骤
javascript
// webpack打包后入口文件
(() => {
// ...
// 定义j函数相关逻辑
require.f = {};
// 理解installedChunks作用
// 记录已经安装的代码块,值0表示已经安装
// index:0是因为index为入口文件
let installedChunks = {
index: 0,
// j函数之后的结果,会把chunkId和创建的promise的resolve, reject进行存放
// header: [resolve, reject]
}
require.f.j = (chunkId, promisesArr) => {
// 1. 步骤1 创建promise并且把它和chunkId关联记录到installedChunks中
let promise = new Promise((resolve, reject) => {
installedChunks[chunkId] = [resolve, reject];
})
// 2. 步骤2 将这个promise传入到require.e函数定义的数组中;
promisesArr.push(promise);
// 3. 步骤3 获取当前header_chunk.js文件地址发起请求
let url = require.p + require.u(chunkId);
require.l(url);
}
// 入口文件执行
require.e( "header").then(require.bind(require, "./src/header.js")).then(res => {
console.log(res);
})
})();
(三) header_chunk.js文件地址获取逻辑
这一步就是开始发起JSONP请求这个异步加载包的脚本,需要定义require.p 获取公共资源路径 、 require.u 获取异步模块名称、 require.l jsonp请求 三个方法
javascript
// webpack打包后入口文件
(() => {
// ...
// 地址方法
// public公共资源访问路径webpack会根配置项自动填充,因为没有配置默认为空;
require.p = "";
// 返回文件名称: 因为配置项中是 chunkFilename: "[name]_chunk.js"所以这个函数也是webpack根据配置生成
require.u = (chunkId) => chunkId + "_chunk.js";
// 请求方法: 本地地址为 http://127.0.0.1:8080/header_chunk.js
// 一旦加载到了dom文件中浏览器会立即加载并执行脚本
require.l = (url) => {
let script = document.createElement("script");
script.src = url;
document.head.appendChild(script);
}
// 入口文件执行
require.e( "header").then(require.bind(require, "./src/header.js")).then(res => {
console.log(res);
})
})();
(四) 定义JSONP回调函数
上面获取到了异步脚本会立刻执行,所以还需要先给脚本定义一个回调函数;
chunkLoadingGlobal会挂载到window对象中并且它是一个数组重写了push方法为jsonp回调
javascript
// webpack打包后入口文件
(() => {
// ...
// jsonp回调
let webpackJsonpCallback = ([chunkIds, moreModules]) => {
// chunksIds对应的异步模块的实参["header"]
// moreModules对应这异步模块的依赖函数 {"./src/header.js": fn(module)}
// 1. 根据所有的模块ID获取所有对应的resolve函数
let allAsyncModuleResolve = chunkIds.map(chunkId => installedChunks[chunkId][0]);
// 2. 标识异步模块加载完毕
chunkIds.forEach(chunkId => installedChunks[chunkId] = 0);
// 3. 把所有异步模块加载的依赖关系挂载到全局的modules依赖上
for (let moduleId in moreModules) {
modules[moduleId] = moreModules[moduleId];
}
// 4. 执行所有的resolve方法, 也就是执行了require.e方法中所有的promises数组
// 这样它内部的promise.all才会执行才会走到下一个then中去
allAsyncModuleResolve.forEach(r => r());
}
// 定义webpack全局变量和重新push方法
// webpackChunkwebpack_s这个名称是webpack根据项目名称自动生成的
var chunkLoadingGlobal = self['webpackChunkwebpack_s'] = [];
chunkLoadingGlobal.push = webpackJsonpCallback;
// 入口文件执行
require.e( "header").then(require.bind(require, "./src/header.js")).then(res => {
console.log(res);
})
})();
(五) 打印结果
jsonp的回调函数webpackJsonpCallback干了两件重要的事,才能触发下一个then执行
- 将异步模块的结果挂载到了全局的modules模块依赖对象上;
- 执行所有installedChunks记录的异步模块中resolve方法,是的require.e的Primose.all执行;
.then(require.bind(require, "./src/header.js"))可以把括号里面的看为是一个resolve函数,其实就是执行了require("./src/header.js"),因为之前的步骤header.js模块函数信息已经挂载到全局中了,所以此时可以获取到header.js模块结果,并且返回到下一个then中;