由淺至深瞭解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 演示了,網上相關演示已經不少。git

hash。全部的 bundle 使用同一個 hash 值,跟每一次 webpack 打包的過程有關github

chunkhash。根據每個 chunk 的內容進行 hash,同一個 chunk 的全部 bundle 產物的 hash 值是同樣的。所以若其中一個 bundle 的修改,同一 chunk 的全部產物 hash 也會被修改。web

contenthash。計算與文件內容自己相關。json

tips:須要注意的是,在熱更新模式下,會致使chunkhashcontenthash計算錯誤,發生錯誤(Cannot use [chunkhash] or [contenthash] for chunk in '[name].[chunkhash].js' (use [hash] instead) )。所以熱更新下只能使用hash模式或者不使用hash。在生產環境中咱們通常使用contenthash或者chunkhash數組

說了這麼多,那麼使用異步加載/分包加載有什麼好處呢。簡單來講有如下幾點promise

一、更好的利用瀏覽器緩存。若是咱們一個很大的項目,不使用分包的話,每一次打包只會生成一個 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標籤去加載代碼

五、異步加載沒那麼神祕,對於當項目大到必定程度時,能有較好的效果

(本文地址。因水平有限,若有錯誤歡迎拍磚)

相關文章
相關標籤/搜索