# Webpack 异步加载

本文转载自掘金, 深受启发, 特此记录
在正式开始之前我们先看看几个常见的相关面试题:
- 在 Webpack 搭建的项目中,如何达到懒加载的效果?
- 在 Webpack 中常用的代码分割方式有哪些?
- Webpack 中懒加载的原理是什么?
相信读完本文,你对上面的一系列问题都能够轻松解答。
# 前置知识
在正式内容开始之前,先来学一些预备小知识点,以免影响后面的学习。
懒加载或者按需加载,是一种很好的优化网页或应用的方式。这种方式实际上是先把你的代码在一些逻辑断点处分离开,然后在一些代码块中完成某些操作后,立即引用或即将引用另外一些新的代码块。这样加快了应用的初始加载速度,减轻了它的总体体积,因为某些代码块可能永远不会被加载。
懒加载的本质实际上就是代码分离。把代码分离到不同的 bundle 中,然后按需加载或并行加载这些文件。
在 Webpack 中常用的代码分离方法有三种:
- 入口起点:使用 entry (opens new window) 配置手动地分离代码。
- 防止重复:使用 Entry dependencies (opens new window) 或者 SplitChunksPlugin (opens new window) 去重和分离 chunk。
- 动态导入:通过模块的内联函数调用来分离代码。
今天我们的核心主要是第三种方式:动态导入。
当涉及到动态代码拆分时,Webpack 提供了两个类似的技术:
- 第一种,也是推荐选择的方式是,使用符合 ECMAScript 提案 (opens new window) 的 import()语法 (opens new window) 来实现动态导入
- 第二种,则是 Webpack 的遗留功能,使用 Webpack 特定的 require.ensure (opens new window) (不推荐使用) ,本文不做探讨
我们主要看看 import()语法 (opens new window) 的方式。
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
函数与之前相同,不做过多的赘述,大家可以看前一篇文章:从构建产物洞悉模块化原理 (opens new window)。这里直接拷贝过来:
//已经加载过的模块
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('按钮点击了')
}
}
}
])