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

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

提纲以下:html

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

相关概念

  • module、chunk、bundle 的概念

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

经过图能够很直观的分出这几个名词的概念:

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

  • hashchunkhashcontenthash

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

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

chunkhash。根据每个 chunk 的内容进行 hash,同一个 chunk 的全部 bundle 产物的 hash 值是同样的。所以若其中一个 bundle 的修改,同一 chunk 的全部产物 hash 也会被修改。数组

contenthash。计算与文件内容自己相关。promise

tips:须要注意的是,在热更新模式下,会致使chunkhashcontenthash计算错误,发生错误(Cannot use [chunkhash] or [contenthash] for chunk in '[name].[chunkhash].js' (use [hash] instead) )。所以热更新下只能使用hash模式或者不使用hash。在生产环境中咱们通常使用contenthash或者chunkhash浏览器

说了这么多,那么使用异步加载/分包加载有什么好处呢。简单来讲有如下几点缓存

一、更好的利用浏览器缓存。若是咱们一个很大的项目,不使用分包的话,每一次打包只会生成一个 js 文件,假设这个 js 打包出来有 2MB。而当平常代码发布的时候,咱们可能只是修改了其中的一行代码,可是因为内容变了,打包出来的 js 的哈希值也发生改变。浏览器这个时候就要从新去加载这个 2MB 的 js 文件。而若是使用了分包,分出了几个 chunk,修改了一行代码,影响的只是这个 chunk 的哈希(这里严谨来讲在不抽离 mainifest 的状况下,可能有多个哈希也会变化),其它哈希是不变的。这就能利用到 hash 不变化部分代码的缓存

二、更快的加载速度。假设进入一个页面须要加载一个 2MB 的 js,通过分包抽离后,可能进入这个页面变成了加载 4 个 500Kb 的 js。咱们知道,浏览器对于同一域名的最大并发请求数是 6 个(因此 webpack 的maxAsyncRequests默认值是 6),这样这个 4 个 500KB 的 js 将同时加载,至关于只是穿行加载一个 500kb 的资源,速度也会有相应的提升。

三、若是实现的是代码异步懒加载。对于部分可能某些地方才用到的代码,在用到的时候才去加载,也能很好起到节省流量的目的。

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 的稳定性,建议:

一、使用webpack.HashedModuleIdsPlugin。这个插件会根据模块的相对路径生成一个四位数的 hash 做为模块 id。默认状况下 webpack 是使用模块数字自增 id 来命名,当插入一个模块占用了一个 id(或者一个删去一个模块)时,后续全部的模块 id 都受到影响,致使模块 id 变化引发打包文件的 hash 变化。使用这个插件就能解决这个问题。

二、chunkid 也是自增的,一样可能遇到模块 id 的问题。能够经过设置optimization.namedChunks为 true(默认 dev 模式下为 true,prod 模式为 false),将chunk的名字使用命名chunk

一、2 后的效果以下。

三、抽离 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主要作了几件事:

一、将异步chunk的状态位置 0,代表该 chunk 已经加载完成。installedChunks[chunkId] = 0;

二、对__webpack_require__.e 中产生的相应的 chunk 加载 promise 进行 resolve

三、将异步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()();
  }
}
复制代码

简单总结:

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

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

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

三、每个 entry chunk 入口都是一个相似的当即执行函数

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

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

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

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

相关文章
相关标签/搜索