由淺至深了解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、異步加載沒那麼神秘,對於當項目大到一定程度時,能有較好的效果

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

Exit mobile version