由浅至深了解webpack异步加载背后的原理

  • 2020 年 1 月 14 日
  • 筆記

源自最近对业务项目进行 webpack 异步分包加载一点点的学习总结

提纲如下:

  • 相关概念
  • webpack 分包配置
  • webpack 异步加载分包如何实现

相关概念

  • module、chunk、bundle 的概念

先来一波名词解释。先上网上一张图解释:

通过图可以很直观的分出这几个名词的概念:

1、module:我们源码目录中的每一个文件,在 webpack 中当作module来处理(webpack 原生不支持的文件类型,则通过 loader 来实现)。module组成了chunk。 2、chunkwebpack打包过程中的产物,在默认一般情况下(没有考虑分包等情况),x 个webpackentry会输出 x 个bundle。 3、bundlewebpack最终输出的东西,可以直接在浏览器运行的。从图中看可以看到,在抽离 css(当然也可以是图片、字体文件之类的)的情况下,一个chunk是会输出多个bundle的,但是默认情况下一般一个chunk也只是会输出一个bundle

  • hashchunkhashcontenthash

这里不进行 demo 演示了,网上相关演示已经很多。

hash。所有的 bundle 使用同一个 hash 值,跟每一次 webpack 打包的过程有关

chunkhash。根据每一个 chunk 的内容进行 hash,同一个 chunk 的所有 bundle 产物的 hash 值是一样的。因此若其中一个 bundle 的修改,同一 chunk 的所有产物 hash 也会被修改。

contenthash。计算与文件内容本身相关。

tips:需要注意的是,在热更新模式下,会导致chunkhashcontenthash计算错误,发生错误(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 的默认分包配置如下

module.exports = {    optimization: {      splitChunks: {        // **`splitChunks.chunks: 'async'`**。表示哪些类型的chunk会参与split。默认是异步加载的chunk。值还可以是`initial`(表示入口同步chunk)、`all`(相当于`initial`+`async`)。        chunks: "async",        // minSize 表示符合代码分割产生的新生成chunk的最小大小。默认是大于30kb的才会生成新的chunk        minSize: 30000,        // maxSize 表示webpack会尝试将大于maxSize的chunk拆分成更小的chunk,拆解后的值需要大于minSize        maxSize: 0,        // 一个模块被最少多少个chunk共享时参与split        minChunks: 1,        // 最大异步请求数。该值可以理解为一个异步chunk,被抽离出同时加载的chunk数不超过该值。若为1,该异步chunk将不会抽离出任意代码块        maxAsyncRequests: 5,        // 入口chunk最大请求数。在多entry chunk的情况下会用到,表示多entry chunk公共代码抽出的最大同时加载的chunk数        maxInitialRequests: 3,        // 初始chunk最大请求数。        // 多个chunk拆分出小chunk时,这个chunk的名字由多个chunk与连接符组合成        automaticNameDelimiter: "~",        // 表示chunk的名字自动生成(由cacheGroups的key、entry名字)        name: true,        // cacheGroups 表示分包分组规则,每一个分组会继承于default        // priority表示优先级,一个chunk可能被多个分组规则命中时,会使用优先级较高的        // test提供时 表示哪些模块会被抽离        cacheGroups: {          vendors: {            test: /[\/]node_modules[\/]/,            priority: -10          },          default: {            minChunks: 2,            priority: -20,            // 复用已经生成的chunk            reuseExistingChunk: true          }        }      }    }  };

还有一个很重要的配置是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 构建后的代码进行分析,了解其背后原理。

实验代码结构如下

展开查看

// webpack.js  const webpack = require("webpack");  const path = require("path");  const CleanWebpackPlugin = require("clean-webpack-plugin").CleanWebpackPlugin;  const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = { entry: { a: “./src/a.js”, b: “./src/b.js” }, output: { filename: “[name].[chunkhash].js”, chunkFilename: “[name].[chunkhash].js”, path: **dirname + “/dist”, jsonpFunction: “_**jsonp” }, optimization: { splitChunks: { minSize: 0 } // namedChunks: true }, plugins: [ new CleanWebpackPlugin(), new HtmlWebpackPlugin() //new webpack.HashedModuleIdsPlugin() ], devServer: { contentBase: path.join(__dirname, “dist”), compress: true, port: 8000 } };

// src/a.js import { common1 } from “./common1”; import { common2 } from “./common2”; common1(); common2(); import(/_ webpackChunkName: “asyncCommon2” _/ “./asyncCommon2.js”).then( ({ asyncCommon2 }) => { asyncCommon2(); console.log(“done”); } );

// src/b.js import { common1 } from “./common1”; common1(); import(/_ webpackChunkName: “asyncCommon2” _/ “./asyncCommon2.js”).then( ({ asyncCommon2 }) => { asyncCommon2(); console.log(“done”); } );

// src/asyncCommon1.js export function asyncCommon1(){ console.log(‘asyncCommon1’) } // src/asyncCommon2.js export function asyncCommon2(){ console.log(‘asyncCommon2’) }

// ./src/common1.js export function common1() { console.log(“common11”); } import(/_ webpackChunkName: “asyncCommon1” _/ “./asyncCommon1”).then( ({ asyncCommon1 }) => { asyncCommon1(); } );

// src/common2.js export function common2(){ console.log(‘common2’) }

在分析异步加载机制之前,先看下 webpack 打包出来的代码结构长啥样(为了便于阅读,这里使用 dev 模式打包,没有使用任何 babel 转码)。列出与加载相关的部分

// 入口文件 a.js  (function() {    //.....    function webpackJsonpCallback(data){      //....    }      // 缓存已经加载过的module。无论是同步还是异步加载的模块都会进入该缓存    var installedModules = {};    // 记录chunk的状态位    // 值:0 表示已加载完成。    // undefined : chunk 还没加载    // null :chunk preloaded/prefetched    // Promise : chunk正在加载    var installedChunks = {      a: 0    };      // 用于根据chunkId,拿异步加载的js地址  function jsonpScriptSrc(chunkId){  //...  }    // 同步import  function __webpack_require__(moduleId){    //...  }    // 用于加载异步import的方法  __webpack_require__.e = function requireEnsure(chunkId)  {    //...  }    // 加载并执行入口js    return __webpack_require__((__webpack_require__.s = "./src/a.js"));    })({    "./src/a.js": function(module, __webpack_exports__, __webpack_require__) {      eval( ...); // ./src/a.js的文件内容    },    "./src/common1.js": ....,     "./src/common2.js": ...  });

可以看到,经过 webpack 打包后的入口文件是一个立即执行函数,立即执行函数的参数就是为入口函数的同步import的代码模块对象。key 值是路径名,value 值是一个执行相应模块代码的eval函数。这个入口函数内有几个重要的变量/函数。

  • webpackJsonpCallback函数。加载异步模块完成的回调。
  • installedModules变量。 缓存已经加载过的 module。无论是同步还是异步加载的模块都会进入该缓存。key是模块 id,value是一个对象{ i: 模块id, l: 布尔值,表示模块是否已经加载过, exports: 该模块的导出值 }
  • installedChunks变量。缓存已经加载过的 chunk 的状态。有几个状态位。0表示已加载完成、 undefined chunk 还没加载、 null :chunk preloaded/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其它代码

function(module, __webpack_exports__, __webpack_require__) {  "use strict";  eval(模块代码...);// (1)   }

eval内的代码如下,以a.js为例。

// (1)  // 格式化为js后  __webpack_require__.r(__webpack_exports__);  var _common1__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(    "./src/common1.js"  );  var _common2__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(    "./src/common2.js"  );  // _common1__WEBPACK_IMPORTED_MODULE_0__是导出对象  // 执行导出的common1方法  // 源码js:  // import { common1 } from "./common1";  // common1();  Object(_common1__WEBPACK_IMPORTED_MODULE_0__["common1"])();    Object(_common2__WEBPACK_IMPORTED_MODULE_1__["common2"])();  __webpack_require__    .e("asyncCommon2")    .then(__webpack_require__.bind(null, "./src/asyncCommon2.js"))    .then(({ asyncCommon2 }) => {      asyncCommon2();      console.log("done");    });

于是,就可知道

  • 同步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__做了啥。

function __webpack_require__(moduleId) {    // 如果一个模块已经import加载过了,再次import的话就直接返回    if (installedModules[moduleId]) {      return installedModules[moduleId].exports;    }    // 之前没有加载的话将它挂到installedModules进行缓存    var module = (installedModules[moduleId] = {      i: moduleId,      l: false,      exports: {}    });      // 执行相应的加载的模块    modules[moduleId].call(      module.exports,      module,      module.exports,      __webpack_require__    );      // 设置模块的状态为已加载    module.l = true;      // 返回模块的导出值    return module.exports;  }

这里就很直观了,这个函数接收一个moduleId,对应于立即执行函数传入参数的key值。若一个模块之前已经加载过,直接返回这个模块的导出值;若这个模块还没加载过,就执行这个模块,将它缓存到installedModules相应的moduleId为 key 的位置上,然后返回模块的导出值。所以在 webpack 打包代码中,import一个模块多次,这个模块只会被执行一次。还有一个地方就是,在 webpack 打包模块中,默认importrequire是一样的,最终都是转化成__webpack_require__

完成同步加载后,入口 chunk 执行a.js

回到一个经典的问题,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)。

接下来回到eval内执行的a.js模块代码片段,异步加载 js 部分。

// a.js模块  __webpack_require__    .e("asyncCommon2")    .then(__webpack_require__.bind(null, "./src/asyncCommon1.js")) // (1) 异步的模块文件已经被注入到立即执行函数的入参`modules`变量中了,这个时候和同步执行`import`调用`__webpack_require__`的效果就一样了    .then(({ asyncCommon2 }) => {      //(2) 就能拿到对应的模块,并且执行相关逻辑了(2)。      asyncCommon2();      console.log("done");    });

__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)。

// __webpack_require__.e 异步import调用函数  // 再回顾下上文提到的 chunk 的状态位  // 记录chunk的状态位  // 值:0 表示已加载完成。  // undefined : chunk 还没加载  // null :chunk preloaded/prefetched  // Promise : chunk正在加载  var installedChunks = {    a: 0  };    __webpack_require__.e = function requireEnsure(chunkId) {    //...只保留核心代码    var promises = [];    var installedChunkData = installedChunks[chunkId];    if (installedChunkData !== 0) {      // chunk还没加载完成      if (installedChunkData) {        // chunk正在加载        // 继续等待,因此只会加载一遍        promises.push(installedChunkData[2]);      } else {        // chunk 还没加载        // 使用script标签去加载对应的js        var promise = new Promise(function(resolve, reject) {          installedChunkData = installedChunks[chunkId] = [resolve, reject];        });        promises.push((installedChunkData[2] = promise)); // start chunk loading          //        var script = document.createElement("script");        var onScriptComplete;          script.src = jsonpScriptSrc(chunkId);        document.head.appendChild(script);    //.....    }    // promise的resolve调用是在jsonpFunctionCallback中调用    return Promise.all(promises);  };

再看看异步加载 asyncCommon1 chunk(也就是异步加载的 js) 的代码大体结构。它做的操作很简单,就是往jsonpFunction这个全局数组push(需要注意的是这个不是数组的 push,是被重写为入口 chunk 的webpackJsonpCallback函数)一个数组,这个数组由 chunk名和该chunk的 module 对象 一起组成。

// asyncCommon1 chunk  (window["jsonpFunction"] = window["jsonpFunction"] || []).push([["asyncCommon1"],{    "./src/asyncCommon1.js":   (function(module, __webpack_exports__, __webpack_require__) {  eval(module代码....);  })  }]);

而执行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 模块已经提到了这个过程

//  function webpackJsonpCallback(data) {    var chunkIds = data[0];    var moreModules = data[1];    var moduleId,      chunkId,      i = 0,      resolves = [];    for (; i < chunkIds.length; i++) {      chunkId = chunkIds[i];      if (        Object.prototype.hasOwnProperty.call(installedChunks, chunkId) &&        installedChunks[chunkId]      ) {        resolves.push(installedChunks[chunkId][0]);      }      // 将当前chunk设置为已加载      installedChunks[chunkId] = 0;    }    for (moduleId in moreModules) {      if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {        // 将异步`chunk`的模块 挂载到入口`chunk`的立即执行函数参数`modules`中        modules[moduleId] = moreModules[moduleId];      }    }      // 执行旧的jsonPFunction    // 可以理解为原生的数组Array,但是这里很精髓,可以防止撞包的情况部分模块没加载!    if (parentJsonpFunction) parentJsonpFunction(data);      while (resolves.length) {      // 对__webpack_require__.e 中产生的相应的chunk 加载promise进行resolve      resolves.shift()();    }  }

简单总结:

1、经过 webpack 打包,每一个 chunk 内的模块文件,都是组合成形如

{    [moduleName:string] : function(module, __webpack_exports__, __webpack_require__){      eval('模块文件源码')    }  }

2、同一页面多个 webpack 环境,output.jsonpFunction尽量不要撞名字。撞了一般也是不会挂掉的。只是会在立即执行函数的入参modules上挂上别的 webpack 环境异步加载的部分模块代码。(可能会造成一些内存的增加?)

3、每一个 entry chunk 入口都是一个类似的立即执行函数

(function(modules){  //....  })({     [moduleName:string] : function(module, __webpack_exports__, __webpack_require__){      eval('模块文件源码')    }  })

4、异步加载的背后是用script标签去加载代码

5、异步加载没那么神秘,对于当项目大到一定程度时,能有较好的效果

(本文地址因水平有限,如有错误欢迎拍砖)