徹底理解 Webpack 運行時

背景

=====

實際上,本文及前面幾篇原理性質的文章,可能並不能馬上解決你在業務中可能正在面臨的現實問題,但放到更長的時間維度,這些文章所呈現的知識、思維、思辨過程可能能夠長遠地給到你:

所以,希望感興趣的同學能夠堅持,我後續還會輸出很多關於 Webpack 實現原理的文章!如果你恰好也想提升自己在 Webpack 方面的知識儲備,關注我,我們一起學習!

編譯產物分析

爲了正常、正確運行業務項目,Webpack 需要將開發者編寫的業務代碼以及支撐、調配這些業務代碼的**「運行時」**一併打包到產物 (bundle) 中,以建築作類比的話,業務代碼相當於磚瓦水泥,是看得見摸得着能直接感知的邏輯;運行時相當於掩埋在磚瓦之下的鋼筋地基,通常不會關注但決定了整座建築的功能、質量。

下面先從最簡單的示例開始,逐步展開了解各個特性下的 Webpack 運行時代碼。

基本結構

先從一個最簡單的示例開始,對於下面的代碼結構:

// a.js
export default 'a module';

// index.js
import name from './a'
console.log(name)

使用如下配置:

module.exports = {
  entry: "./src/index",
  mode: "development",
  devtool: false,
  output: {
    filename: "[name].js",
    path: path.join(__dirname, "./dist"),
  },
};

配置的內容比較簡單,就不展開講了,直接看編譯生成的結果:

雖然看起來很非主流,但細心分析還是能拆解出代碼脈絡的,bundle 整體由一個 IIFE 包裹,裏面的內容從上到下依次爲:

這幾個 __webpack_ 開頭奇奇怪怪的函數可以統稱爲 Webpack 運行時代碼,作用如前面所說的是搭起整個業務項目的骨架,就上述簡單示例所羅列出來的幾個函數、對象而言,它們協作構建起一個簡單的模塊化體系從而實現 ES Module 規範所聲明的模塊化特性。

上述示例中最終的函數是 __webpack_require__,它實現了模塊間引用功能,核心代碼:

function __webpack_require__(moduleId) {
    /******/ // 如果模塊被引用過
    /******/ var cachedModule = __webpack_module_cache__[moduleId];
    /******/ if (cachedModule !== undefined) {
      /******/ return cachedModule.exports;
      /******/
    }
    /******/ // Create a new module (and put it into the cache)
    /******/ var module = (__webpack_module_cache__[moduleId] = {
      /******/ // no module.id needed
      /******/ // no module.loaded needed
      /******/ exports: {},
      /******/
    });
    /******/
    /******/ // Execute the module function
    /******/ __webpack_modules__[moduleId](
      module,
      module.exports,
      __webpack_require__
    );
    /******/
    /******/ // Return the exports of the module
    /******/ return module.exports;
    /******/
  }

從代碼可以推測出,它的功能:

其中,業務模塊代碼被存儲在 bundle 最開始的 __webpack_modules__ 變量中,內容如:

var __webpack_modules__ = {
    "./src/a.js"(
        __unused_webpack_module,
        __webpack_exports__,
        __webpack_require__
      ) ={
        // ...
      },
  };

結合 __webpack_require__ 函數與 __webpack_modules__ 變量就可以正確地引用到代碼模塊,例如上例生成代碼最後面的 IIFE:

(() ={
    /*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
    /* harmony import */ var _a__WEBPACK_IMPORTED_MODULE_0__ =
      __webpack_require__(/*! ./a */ "./src/a.js");

    console.log(_a__WEBPACK_IMPORTED_MODULE_0__.name);
  })();

這幾個函數、對象構成了 Webpack 運行時最基本的能力 —— 模塊化,它們的生成規則與原理我們放到文章第二節《實現原理》再講,下面我們繼續看看異步模塊加載、模塊熱更新場景下對應的運行時內容。

異步模塊加載

我們來看個簡單的異步模塊加載示例:

// ./src/a.js
export default "module-a"

// ./src/index.js
import('./a').then(console.log)

Webpack 配置跟上例相似:

module.exports = {
  entry: "./src/index",
  mode: "development",
  devtool: false,
  output: {
    filename: "[name].js",
    path: path.join(__dirname, "./dist"),
  },
};

生成的代碼太長,就不貼了,相比於最開始的基本結構示例所示的模塊化功能,使用異步模塊加載特性時,會額外增加如下運行時:

建議讀者運行示例對比實際生成代碼,感受它們的具體功能。這幾個運行時模塊構建起 Webpack 異步加載能力,其中最核心的是 __webpack_require__.e 函數,它的代碼很簡單:

__webpack_require__.f = {};
/******/    // This file contains only the entry chunk.
/******/    // The chunk loading function for additional chunks
/******/    __webpack_require__.e = (chunkId) ={
/******/      return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) ={
/******/        __webpack_require__.f[key](chunkId, promises);
/******/        return promises;
/******/      }[]));
/******/    };

從代碼看,只是實現了一套基於 __webpack_require__.f 的中間件模式,以及用 Promise.all 實現並行處理,實際加載工作由 __webpack_require__.f.j__webpack_require__.l 實現,分開來看兩個函數:

/******/  __webpack_require__.f.j = (chunkId, promises) ={
/******/        // JSONP chunk loading for javascript
/******/        var installedChunkData = __webpack_require__.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;
/******/        if(installedChunkData !== 0) { // 0 means "already installed".
/******/    
/******/          // a Promise means "currently loading".
/******/          if(installedChunkData) {
/******/            promises.push(installedChunkData[2]);
/******/          } else {
/******/            if(true) { // all chunks have JS
/******/              // ...
/******/              // start chunk loading
/******/              var url = __webpack_require__.p + __webpack_require__.u(chunkId);
/******/              // create error before stack unwound to get useful stacktrace later
/******/              var error = new Error();
/******/              var loadingEnded = ...;
/******/              __webpack_require__.l(url, loadingEnded, "chunk-" + chunkId, chunkId);
/******/            } else installedChunks[chunkId] = 0;
/******/          }
/******/        }
/******/    };

__webpack_require__.f.j 實現了異步 chunk 路徑的拼接、緩存、異常處理三個方面的邏輯,而 __webpack_require__.l 函數:

/******/    var inProgress = {};
/******/    // data-webpack is not used as build has no uniqueName
/******/    // loadScript function to load a script via script tag
/******/    __webpack_require__.l = (url, done, key, chunkId) ={
/******/      if(inProgress[url]) { inProgress[url].push(done); return; }
/******/      var script, needAttach;
/******/      if(key !== undefined) {
/******/        var scripts = document.getElementsByTagName("script");
/******/        // ...
/******/      }
/******/      // ...
/******/      inProgress[url] = [done];
/******/      var onScriptComplete = (prev, event) ={
/******/        // ...
/******/      }
/******/      ;
/******/      var timeout = setTimeout(onScriptComplete.bind(null, undefined, { type: 'timeout', target: script }), 120000);
/******/      script.onerror = onScriptComplete.bind(null, script.onerror);
/******/      script.onload = onScriptComplete.bind(null, script.onload);
/******/      needAttach && document.head.appendChild(script);
/******/    };

__webpack_require__.l 中通過 script 實現異步 chunk 內容的加載與執行。

e + l + f.j 三個運行時函數支撐起 Webpack 異步模塊運行的能力,落到實際用法上只需要調用 e 函數即可完成異步模塊加載、運行,例如上例對應生成的 entry 內容:

/*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
__webpack_require__.e(/*! import() */ "src_a_js").then(__webpack_require__.bind(__webpack_require__, /*! ./a */ "./src/a.js"))

模塊熱更新

模塊熱更新 —— HMR 是一個能顯著提高開發效率的能力,它能夠在模塊代碼出現變化的時候,單獨編譯該模塊並將最新的編譯結果傳送到瀏覽器,瀏覽器再用新的模塊代碼替換掉舊的代碼,從而實現模塊級別的代碼熱替換能力。落到最終體驗上,開發者啓動 Webpack 後,編寫、修改代碼的過程中不需要手動刷新瀏覽器頁面,所有變更能夠實時同步呈現到頁面中。

實現上,HMR 的實現鏈路很長也比較有意思,我們後續會單開一篇文章討論,本文主要關注 HMR 特性所帶入運行時代碼。啓動 HMR 能力需要用到一些特殊的配置項:

module.exports = {
  entry: "./src/index",
  mode: "development",
  devtool: false,
  output: {
    filename: "[name].js",
    path: path.join(__dirname, "./dist"),
  },
  // 簡單起見,這裏使用 HtmlWebpackPlugin 插件自動生成作爲 host 的 html 文件
  plugins: [
    new HtmlWebpackPlugin({
      title: "Hot Module Replacement",
    }),
  ],
  // 配置 devServer 屬性,啓動 HMR
  devServer: {
    contentBase: "./dist",
    hot: true,
    writeToDisk: true,
  },

按照上述配置,使用命令 webpack serve --hot-only 啓動 Webpack,就可以在 dist 文件夾找到產物:

相比於前面兩個示例,HMR 所產生運行時代碼達到 1.5w+ 行,簡直可以用炸裂來形容。主要的運行時內容有:

可以看到, HMR 運行時是上面異步模塊加載運行時的超集,而異步模塊加載的運行時又是第一個基本示例運行時的超集,層層疊加。在 HMR 中包含了:

內容過多,我們放到下次專門開一篇文章聊聊 HMR。

實現原理

仔細閱讀上述三個示例,相信讀者應該已經模模糊糊捕捉到一些重要規則:

落到 Webpack 源碼實現上,運行時的生成邏輯可以劃分爲兩個步驟:

  1. 「依賴收集」:遍歷業務代碼模塊收集模塊的特性依賴,從而確定整個項目對 Webpack runtime 的依賴列表

  2. 「生成」:合併 runtime 的依賴列表,打包到最終輸出的 bundle

兩個步驟都發生在打包階段,即 Webpack(v5) 源碼的 compilation.seal 函數中:

上圖是我總結的 Webpack 知識圖譜的一部分,可關注公衆號【Tecvan】 回覆【1】獲取線上地址

注意上圖,進入 runtime 處理環節時 Webpack 已經解析得出 ModuleDependencyGraphChunkGraph 關係,也就意味着此時已經可以計算出:

對 bundle、module、chunk 關係這幾個概念還不太清晰的同學,建議擴展閱讀:

基於這些信息,接下來首先需要收集運行時依賴。

依賴收集

Webpack runtime 的依賴概念上很像 Vue 的依賴,都是用來表達模塊對其它模塊存在依附關係,只是實現方法上 Vue 基於動態、在運行過程中收集,而 Webpack 則基於靜態代碼分析的方式收集依賴。實現邏輯大致爲:

運行時依賴的計算邏輯集中在 compilation.processRuntimeRequirements 函數,代碼上包含三次循環:

下面我們展開聊聊細節。

第一次循環:收集模塊依賴

在打包 (seal) 階段,完成 ChunkGraph 的構建之後,Webpack 會緊接着調用 codeGeneration 函數遍歷 module 數組,調用它們的 module.codeGeneration 函數執行模塊轉譯,模塊轉譯結果如:

其中,sources 屬性爲模塊經過轉譯後的結果;而 runtimeRequirements 則是基於 AST 計算出來的,爲運行該模塊時所需要用到的運行時,計算過程與本文主題無關,挖個坑下一回我們再繼續講。

所有模塊轉譯完畢後,開始調用 compilation.processRuntimeRequirements 進入第一重循環,將上述轉譯結果的 runtimeRequirements 記錄到 ChunkGraph 對象中。

第二次循環:整合 chunk 依賴

第一次循環針對 module 收集依賴,第二次循環則遍歷 chunk 數組,收集將其對應所有 module 的 runtime 依賴,例如:

示例圖中,module a 包含兩個運行時依賴;module b 包含一個運行時依賴,則經過第二次循環整合後,對應的 chunk 會包含兩個模塊對應的三個運行時依賴。

第三次循環:依賴標識轉 RuntimeModule 對象

源碼中,第三次循環的代碼最少但邏輯最複雜,大致上執行三個操作:

至此,runtime 依賴完成了從 module 內容解析,到收集,到創建依賴對應的 Module 子類,再將 Module 加入到 ModuleDepedencyGraph /ChunkGraph 體系的全流程,業務代碼及運行時代碼對應的模塊依賴關係圖完全 ready,可以準備進入下一階段 —— 生成最終產物。

但在繼續講解產物邏輯之前,我們有必要先解決兩個問題:

總結:Chunk 與 Runtime Chunk

在上一篇文章 有點難的 webpack 知識點:Chunk 分包規則詳解 我嘗試完整地講解 Webpack 默認分包規則,回顧一下在三種特定的情況下,Webpack 會創建新的 chunk

默認情況下 initial chunk 通常包含運行該 entry 所需要的所有 runtime 代碼,但 webpack 5 之後出現的第三條規則打破了這一限制,允許開發者將 runtime 從 initial chunk 中剝離出來獨立爲一個多 entry 間可共享的 runtime chunk

類似的,異步模塊對應 runtime 代碼大部分都被包含在對應的引用者身上,比如說:

// a.js
export default 'a-module'

// index.js
// 異步引入 a 模塊
import('./a').then(console.log)

在這個示例中,index 異步引入 a 模塊,那麼按默認分配規則會產生兩個 chunk:入口文件 index 對應的 initial chunk、異步模塊 a 對應的 async chunk。此時從 ChunkGraph 的角度看 chunk[index]chunk[a] 的父級,運行時代碼會被打入 chunk[index],站在瀏覽器的角度,運行 chunk[a] 之前必須先運行 chunk[index] ,兩者形成明顯的父子關係。

總結:RuntimeModule 體系

在最開始閱讀 Webpack 源碼的時候,我就覺得很奇怪,Module 是 Webpack 資源管理的基本單位,但 Module 底下總共衍生出了 54 個子類,且大部分爲 Module => RuntimeModule => xxxRuntimeModule 的繼承關係:

有點難的 webpack 知識點:Dependency Graph 深度解析 一文中我們聊到模塊依賴關係圖的生成過程及作用,但文章的內容主要圍繞業務代碼展開,用到的大多是 NormalModule 。到 seal 函數收集運行時的過程中,RuntimePlugin 還會爲運行時依賴一一創建對應的 RuntimeModule 子類,例如:

所以可以推導出所有 RuntimeModule 結尾的類型與特定的運行時功能一一對應,收集依賴的結果就是在業務代碼之外創建出一堆支撐性質的 RuntimeModule 子類,這些子類對象隨後被加入 ModuleDependencyGraph ,併入整個模塊依賴體系中。

資源合併生成

經過上面的運行時依賴收集過程後,bundle 所需要的所有內容都就緒了,接着就可以準備寫出到文件中,即下圖核心流程中的生成 (emit) 階段:

我的另一篇 [萬字總結] 一文喫透 Webpack 核心原理 對這一塊有比較細緻的講解,這裏從運行時的視角再簡單聊一下代碼流程:

挖坑

Webpack 真的很複雜,每次信心滿滿寫出一個主題的內容之後都會發現更多新的坑點,比如本文可以衍生出來的關注點:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/nkBvbwpzeb0fzG02HXta8A