阅读 1106

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

源自最近对业务项目进行 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__

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

// 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、异步加载没那么神秘,对于当项目大到一定程度时,能有较好的效果

(水平有限,如有错误欢迎拍砖)