???由淺至深瞭解webpack非同步載入背後的原理

flytam發表於2019-12-14

源自最近對業務專案進行 webpack 非同步分包載入一點點的學習總結

提綱如下:

  • 相關概念
  • webpack 分包配置
  • webpack 非同步載入分包如何實現

相關概念

  • module、chunk、bundle 的概念

先來一波名詞解釋。先上網上一張圖解釋:

???由淺至深瞭解webpack非同步載入背後的原理
通過圖可以很直觀的分出這幾個名詞的概念:

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 後的效果如下。

???由淺至深瞭解webpack非同步載入背後的原理
3、抽離 css 使用mini-css-extract-plugin。hash 模式使用contenthash

這裡以騰訊雲某控制檯頁面以下為例,使用 webpack 路有非同步載入效果後如下。可以看到,第一次訪問頁面。這裡是先請求到一個總的入口 js,然後根據我們訪問的路由(路由 1),再去載入這個路由相關的程式碼。這裡可以看到我們非同步載入的 js 數為 5,就相當於上面提到的預設配置項maxAsyncRequests,通過waterfall可以看到這裡是併發請求的。如果再進去其它路由(路由 2)的話,只會載入一個其它路由的 js(或者還有當前沒有載入過的 vendor js)。這裡如果只修改了路由 1 的自己單獨業務程式碼,vendor 相關的 hash 和其它路由的 hash 也不是不會變,這些檔案就能很好的利用了瀏覽器快取了

???由淺至深瞭解webpack非同步載入背後的原理

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、非同步載入沒那麼神祕,對於當專案大到一定程度時,能有較好的效果

(水平有限,如有錯誤歡迎拍磚)

相關文章