webpack 按需加载原理

本文不会带你去阅读 build 后的源码,而是告诉你它是怎么做到的,原理是什么,怎么实现,如何自己动手做一个按需加载模块,如果不想听本文 BB,可直接一步到页脚,获取完整代码 #完整的按需加载代码

准备

如果你想阅读源码,你可以根据下面的结构去创建,然后自己打包阅读源码

COPY
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// webpack.config.js
const path = require('path')
const mode = 'production'

module.exports = {
mode,
entry: './src/index.js',
output: {
path: path.join(__dirname, 'dist'),
filename: 'main.js',
chunkFilename: '[name].js',
libraryTarget: 'umd'
},
optimization: {
minimize: false
}
}
COPY
1
2
// src/test.js
module.exports = 'test'
COPY
1
2
3
4
5
6
7
8
// src/index.js

// 当调用 window 的 init 方法时触发加载 test.js ,加载完成后输出结果
window.init = function () {
import(/* webpackChunkName: "test" */ './test').then((r) => {
console.log(r)
})
}

import() 干了什么

其实 webpack 就是把 import() 动态导入的模块打包成了一个文件,而其中的 webpackChunkName 注释就是定义打包后这个动态模块的 js 文件,上方指定命名为 test,则打包结果为 test.js,如果没有写 webpackChunkName 那么 webpack 会自动定义随机的文件名(当然这不是随机的,它是根据文件中的内容进行 hash 推算的,只要内容没变,他就一直是这个名字,这段感兴趣的可以自己去阅读源码)

如何获取 url

我在没看打包结果的源码时,我是这样想的,既然要加载,那么一定是通过 script 标签
因为引入是多变的,比如通过本地引入,或是 cdn 引入,又或者是其它方式,而 webpack 它是无法判断引入方式的,那么该如何获取 js 文件的 url 地址呢?

拦截请求:
不过 script 发出的请求是浏览器发送的,并不像 xhr 一样可以包装一层进行 hook 拦截
于是我又想到了 Service Workersfetch 事件,不过 webpack 怎么可能干这种事,这就属于入侵式修改了,不合理

思来想去,我实在是想不到有啥办法了,于是我就去看了源码,万万没想到啊,居然是用 document.currentScript 属性,我居然把这给忘了:(

COPY
1
2
// 获取当前 script 标签的 src 属性
document.currentScript.src

webpack 源码里还写了一个获取当前 script 标签的代码

COPY
1
2
3
4
// 获取到所有的 script 标签
var scripts = document.getElementsByTagName('script')
// 如果有,这通过下表获取
if (scripts.length) scriptUrl = scripts[scripts.length - 1].src

原理是什么呢?,大家都知道,html 是从上往下解析的,当解析到 script 标签时,浏览器会阻塞页面渲染,等待下载完当前 js 并执行完成后才会继续往下解析,(除非你给当前 script 标签使用了 asyncdefer 属性,至于这俩属性的作用,可自行网上搜索)这时使用 document.getElementsByTagName('script') 获取页面上的 script 标签则只会获取到已经解析的 script 标签,所以当前的 script 标签它一定是最后一个 script 标签,就可以通过 length - 1 获取啦

既然得到了 url,那么就可以加载 js 了,只需要把当前的 js 文件名改成需要加载的 js 文件名,也就是前面所说的 webpackChunkName

COPY
1
2
3
4
5
6
7
8
9
10
11
12
let scriptUrl
if (document.currentScript) scriptUrl = document.currentScript.src
if (!scriptUrl) {
const scripts = document.getElementsByTagName('script')
if (scripts.length) scriptUrl = scripts[scripts.length - 1].src
}
if (!scriptUrl) throw new Error('Automatic publicPath is not supported in this browser')
// 结果以下几个 replace() 即可得到 url
scriptUrl = scriptUrl
.replace(/#.*$/, '') // 去除锚点
.replace(/\?.*$/, '') // 去除参数
.replace(/\/[^/]+$/, '/') // 去除文件名

如何加载

因为在上一步我们已经得到 url,此时只需要拼接上 webpackChunkName 的文件名就可以加载指定的 js 了

那么webpackChunkName是怎么来的呢?
如果你用的是 webpack,那么它已经帮你做好了,反之则得自己写,其实很简单

COPY
1
2
// 定义一个对象,里面写好动态加载的js文件名即可
const map = { admin: 'discuss.admin.js' }

然后通过 js 动态创建 script 标签加载 js 即可,最后加上一个 onload 事件,等待加载成功后自行代码即可

COPY
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// chunk: js 文件名
// callback: 回调函数,当js加载完成后执行
const loadScript = (chunk, callback) => {
const script = document.createElement('script')
script.src = scriptUrl + map[chunk]
script.onload = () => {
// 执行完成后及时释放,让系统回收内存,毕竟我们只需要加载一次即可
script.onload = null
callback()
// 删掉动态加载的 script 标签 dom 元素(不会对程序照成影响)
script.parentNode && script.parentNode.removeChild(script)
}
document.head.appendChild(script)
}

最后还有一个问题需要解决,那就是避免在多次执行window.init()时触发多次加载,导致多次请求,照成不必要的请求,以及浪费流量带宽

既然已经加载,那么就留个标记,表示这个 url 已经加载过了,下载执行不要不要继续创建 script 标签即可

定义一个数组用来储存已经加载过的动态 js,const chunks = [] 在动态创建 script 标签之前将文件名push()进去数组里即可

完整的按需加载代码

COPY
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// source: https://github.com/discussjs/discuss/blob/dc345810e6696f1282fca65df039b2186b1f34d1/src/client/lib/import.js
let scriptUrl
if (document.currentScript) scriptUrl = document.currentScript.src
if (!scriptUrl) {
const scripts = document.getElementsByTagName('script')
if (scripts.length) scriptUrl = scripts[scripts.length - 1].src
}
if (!scriptUrl) throw new Error('Automatic publicPath is not supported in this browser')
scriptUrl = scriptUrl
.replace(/#.*$/, '') // 去除锚点
.replace(/\?.*$/, '') // 去除参数
.replace(/\/[^/]+$/, '/') // 去除文件名

// 未来避免于其它js产生全局变量冲突,你可以定义为一个其它比较复杂的变量名
window.chunks = []
const map = { admin: 'discuss.admin.js' }
const loadScript = (chunk, callback) => {
// 如果存在,则直接执行回调
if (window.chunks.includes(chunk)) return callback()
// 如果上方判断没有成立,则push到数组里,下次执行就会立即执行回调不必再向下执行代码
window.chunks.push(chunk)

const script = document.createElement('script')
script.src = scriptUrl + map[chunk]
script.onload = () => {
script.onload = null
callback()
script.parentNode && script.parentNode.removeChild(script)
}
document.head.appendChild(script)
}

export default loadScript
Authorship: Lete乐特
Article Link: https://blog.imlete.cn/article/webpack-on-demand-loading-principle.html
Copyright: All posts on this blog are licensed under the CC BY-NC-SA 4.0 license unless otherwise stated. Please cite Lete乐特 's Blog !