
源自最近对业务项目进行 webpack 异步分包加载一点点的学习总结
提纲如下:
- 相关概念
- webpack 分包配置
- webpack 异步加载分包如何实现
相关概念
- module、chunk、bundle 的概念
先来一波名词解释。先上网上一张图解释:
通过图可以很直观的分出这几个名词的概念:
1、module:我们源码目录中的每一个文件,在 webpack 中当作module来处理(webpack 原生不支持的文件类型,则通过 loader 来实现)。module组成了chunk。
2、chunk。webpack打包过程中的产物,在默认一般情况下(没有考虑分包等情况),x 个webpack的entry会输出 x 个bundle。
3、bundle。webpack最终输出的东西,可以直接在浏览器运行的。从图中看可以看到,在抽离 css(当然也可以是图片、字体文件之类的)的情况下,一个chunk是会输出多个bundle的,但是默认情况下一般一个chunk也只是会输出一个bundle
hash、chunkhash、contenthash
这里不进行 demo 演示了,网上相关演示已经很多。
hash。所有的 bundle 使用同一个 hash 值,跟每一次 webpack 打包的过程有关
chunkhash。根据每一个 chunk 的内容进行 hash,同一个 chunk 的所有 bundle 产物的 hash 值是一样的。因此若其中一个 bundle 的修改,同一 chunk 的所有产物 hash 也会被修改。
contenthash。计算与文件内容本身相关。
tips:需要注意的是,在热更新模式下,会导致chunkhash和contenthash计算错误,发生错误(Cannot use [chunkhash] or [contenthash] for chunk in '[name].[chunkhash].js' (use [hash] instead) )。因此热更新下只能使用hash模式或者不使用hash。在生产环境中我们一般使用contenthash或者chunkhash。
说了这么多,那么使用异步加载/分包加载有什么好处呢。简单来说有以下几点
1、更好的利用浏览器缓存。如果我们一个很大的项目,不使用分包的话,每一次打包只会生成一个 js 文件,假设这个 js 打包出来有 2MB。而当日常代码发布的时候,我们可能只是修改了其中的一行代码,但是由于内容变了,打包出来的 js 的哈希值也发生改变。浏览器这个时候就要重新去加载这个 2MB 的 js 文件。而如果使用了分包,分出了几个 chunk,修改了一行代码,影响的只是这个 chunk 的哈希(这里严谨来说在不抽离 mainifest 的情况下,可能有多个哈希也会变化),其它哈希是不变的。这就能利用到 hash 不变化部分代码的缓存
2、更快的加载速度。假设进入一个页面需要加载一个 2MB 的 js,经过分包抽离后,可能进入这个页面变成了加载 4 个 500Kb 的 js。我们知道,浏览器对于同一域名的最大并发请求数是 6 个(所以 webpack 的maxAsyncRequests默认值是 6),这样这个 4 个 500KB 的 js 将同时加载,相当于只是穿行加载一个 500kb 的资源,速度也会有相应的提高。
3、如果实现的是代码异步懒加载。对于部分可能某些地方才用到的代码,在用到的时候才去加载,也能很好起到节省流量的目的。
webpack 分包配置
在这之前,先强调一次概念,splitChunk,针对的是chunk,并不是module。对于同一个 chunk 中,无论一个代码文件被同 chunk 引用了多少次,它都还是算 1 次。只有一个代码文件被多个 chunk 引用,才算是多次。
webpack 的默认分包配置如下
1 | module.exports = { |
还有一个很重要的配置是output.jsonpFunction(默认是webpackJsonp)。这是用于异步加载 chunk 的时候一个全局变量。如果多 webpack 环境下,为了防止该函数命名冲撞产生问题,最好设置成一个比较唯一的值。
一般而言,没有最完美的分包配置,只有最合适当前项目场景需求的配置。很多时候,默认配置已经足够可用了。
通常来说,为了保证 hash 的稳定性,建议:
1、使用webpack.HashedModuleIdsPlugin。这个插件会根据模块的相对路径生成一个四位数的 hash 作为模块 id。默认情况下 webpack 是使用模块数字自增 id 来命名,当插入一个模块占用了一个 id(或者一个删去一个模块)时,后续所有的模块 id 都受到影响,导致模块 id 变化引起打包文件的 hash 变化。使用这个插件就能解决这个问题。
2、chunkid 也是自增的,同样可能遇到模块 id 的问题。可以通过设置optimization.namedChunks为 true(默认 dev 模式下为 true,prod 模式为 false),将chunk的名字使用命名chunk。
1、2 后的效果如下。
3、抽离 css 使用mini-css-extract-plugin。hash 模式使用contenthash。
这里以腾讯云某控制台页面以下为例,使用 webpack 路有异步加载效果后如下。可以看到,第一次访问页面。这里是先请求到一个总的入口 js,然后根据我们访问的路由(路由 1),再去加载这个路由相关的代码。这里可以看到我们异步加载的 js 数为 5,就相当于上面提到的默认配置项maxAsyncRequests,通过waterfall可以看到这里是并发请求的。如果再进去其它路由(路由 2)的话,只会加载一个其它路由的 js(或者还有当前没有加载过的 vendor js)。这里如果只修改了路由 1 的自己单独业务代码,vendor 相关的 hash 和其它路由的 hash 也不是不会变,这些文件就能很好的利用了浏览器缓存了
webpack 异步加载分包如何实现
我们知道,默认情况下,浏览器环境的 js 是不支持import和异步import('xxx').then(...)的。那么 webpack 是如何实现使得浏览器支持的呢,下面对 webpack 构建后的代码进行分析,了解其背后原理。
实验代码结构如下
1 | // webpack.js |
在分析异步加载机制之前,先看下 webpack 打包出来的代码结构长啥样(为了便于阅读,这里使用 dev 模式打包,没有使用任何 babel 转码)。列出与加载相关的部分
1 | // 入口文件 a.js |
可以看到,经过 webpack 打包后的入口文件是一个立即执行函数,立即执行函数的参数就是为入口函数的同步import的代码模块对象。key 值是路径名,value 值是一个执行相应模块代码的eval函数。这个入口函数内有几个重要的变量/函数。
webpackJsonpCallback函数。加载异步模块完成的回调。installedModules变量。 缓存已经加载过的 module。无论是同步还是异步加载的模块都会进入该缓存。key是模块 id,value是一个对象{ i: 模块id, l: 布尔值,表示模块是否已经加载过, exports: 该模块的导出值 }。installedChunks变量。缓存已经加载过的 chunk 的状态。有几个状态位。0表示已加载完成、undefinedchunk 还没加载、null:chunkpreloaded/prefetched加载的模块、Promise: chunk 正在加载jsonpScriptSrc变量。用于返回异步 chunk 的 js 地址。如果设置了webpack.publicPath(一般是 cdn 域名,这个会存到__webpack_require__.p中),也会和该地址拼接成最终地址__webpack_require__函数。同步import的调用__webpack_require__.e函数。异步import的调用
而每个模块构建出来后是一个类型如下形式的函数,函数入参module对应于当前模块的相关状态(是否加载完成、导出值、id 等,下文提到)、__webpack_exports__就是当前模块的导出(就是 export)、__webpack_require__就是入口 chunk 的__webpack_require__函数,用于import其它代码
1 | function(module, __webpack_exports__, __webpack_require__) { |
eval内的代码如下,以a.js为例。
1 | // (1) |
于是,就可知道
- 同步
import最终转化成__webpack_require__函数 - 异步
import最终转化成__webpack_require__.e方法
整个 流程执行就是。
入口文件最开始通过__webpack_require__((__webpack_require__.s = "./src/a.js"))加载入口的 js,(上面可以观察到installedChunked变量的初始值是{a:0},),并通过eval执行 a.js 中的代码。
__webpack_require__可以说是整个 webpack 构建后代码出现最多的东西了,那么__webpack_require__做了啥。
1 | function __webpack_require__(moduleId) { |
这里就很直观了,这个函数接收一个moduleId,对应于立即执行函数传入参数的key值。若一个模块之前已经加载过,直接返回这个模块的导出值;若这个模块还没加载过,就执行这个模块,将它缓存到installedModules相应的moduleId为 key 的位置上,然后返回模块的导出值。所以在 webpack 打包代码中,import一个模块多次,这个模块只会被执行一次。还有一个地方就是,在 webpack 打包模块中,默认import和require是一样的,最终都是转化成__webpack_require__。
回到一个经典的问题,webpack环境中如果发生循环引用会怎样?a.js有一个import x from './b.js'、b.js有一个import x from 'a.js'。经过上面对__webpack_require__的分析就很容易知道了。一个模块执行之前,webpack就已经先将它挂到installedModules中。例如此时执行a.js它引入b.js,b.js中又引入a.js。此时b.js中拿到引入a的内容只是在a.js当前执行的时候已经export出的东西(因为已经挂到了installedModules,所以不会重新执行一遍a.js)。
完成同步加载后,入口 chunk 执行a.js。
接下来回到eval内执行的a.js模块代码片段,异步加载 js 部分。
1 | // a.js模块 |
__webpack_require__.e做的事情就是,根据传入的chunkId,去加载这个chunkId对应的异步 chunk 文件,它返回一个promise。通过jsonp的方式使用script标签去加载。这个函数调用多次,还是只会发起一次请求 js 的请求。若已加载完成,这时候异步的模块文件已经被注入到立即执行函数的入参modules变量中了,这个时候和同步执行import调用__webpack_require__的效果就一样了(这个注入由webpackJsonpCallback函数完成)。此时,在promise的回调中再调用__webpack_require__.bind(null, "./src/asyncCommon1.js")(1) 就能拿到对应的模块,并且执行相关逻辑了(2)。
1 | // __webpack_require__.e 异步import调用函数 |
再看看异步加载 asyncCommon1 chunk(也就是异步加载的 js) 的代码大体结构。它做的操作很简单,就是往jsonpFunction这个全局数组push(需要注意的是这个不是数组的 push,是被重写为入口 chunk 的webpackJsonpCallback函数)一个数组,这个数组由 chunk名和该chunk的 module 对象 一起组成。
1 | // asyncCommon1 chunk |
而执行webpackJsonpCallback的时机,就是我们通过script把异步 chunk 拿回来了(肯定啊,因为请求代码回来,执行异步 chunk 内的push方法嘛!)。结合异步 chunk 的代码和下面的webpackJsonpCallback很容易知道,webpackJsonpCallback主要做了几件事:
1、将异步chunk的状态位置 0,表明该 chunk 已经加载完成。installedChunks[chunkId] = 0;
2、对__webpack_require__.e 中产生的相应的 chunk 加载 promise 进行 resolve
3、将异步chunk的模块 挂载到入口chunk的立即执行函数参数modules中。可供__webpack_require__进行获取。上文分析 a.js 模块已经提到了这个过程
1 | // |
简单总结:
1、经过 webpack 打包,每一个 chunk 内的模块文件,都是组合成形如
1 | { |
2、同一页面多个 webpack 环境,output.jsonpFunction尽量不要撞名字。撞了一般也是不会挂掉的。只是会在立即执行函数的入参modules上挂上别的 webpack 环境异步加载的部分模块代码。(可能会造成一些内存的增加?)
3、每一个 entry chunk 入口都是一个类似的立即执行函数
1 | (function(modules){ |
4、异步加载的背后是用script标签去加载代码
5、异步加载没那么神秘,对于当项目大到一定程度时,能有较好的效果
因水平有限,如有错误欢迎拍砖)
- Post title:由浅至深了解webpack异步加载
- Post author:flytam
- Create time:2019-12-14 22:02:30
- Post link:https://blog.flytam.vip/由浅至深了解webpack异步加载.html
- Copyright Notice:All articles in this blog are licensed under BY-NC-SA unless stating additionally.