Webpack 系列第五篇: 完全理解 Webpack 運行時

全文 5000 字,深度剖析 Webpack 運行時的內容、結構與生成原理,歡迎點贊關注。寫做不易,未經做者贊成,禁止任何形式轉載!!!

背景

在上一篇文章 有點難的 webpack 知識點:Chunk 分包規則詳解 中,咱們詳細講解了 Webpack 默認的分包規則,以及一部分 seal 階段的執行邏輯,如今咱們將按 Webpack 的執行流程,繼續往下深度分析實現原理,具體內容包括:javascript

  • Webpack 的構建產物包含那些內容?產物如何支持諸如模塊化、異步加載、HMR 特性?
  • 何謂運行時?Webpack 構建過程當中如何收集運行時依賴?如何將運行時與業務代碼合併輸出到 bundle

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

  • 分析、理解複雜開源代碼的能力
  • 理解 Webpack 架構及實現細節,下次遇到問題的時候能根據表象迅速定位到根源
  • 理解 Webpack 爲 hooks、loader 提供的上下文,可以更通暢地理解其它開源組件,甚至可以自如地實現本身的組件

因此,但願感興趣的同窗可以堅持,我後續還會輸出不少關於 Webpack 實現原理的文章!若是你剛好也想提高本身在 Webpack 方面的知識儲備,關注我,咱們一塊兒學習!java

編譯產物分析

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

大多數 Webpack 特性都須要特定鋼筋地基才能跑起來,好比說:web

  • 異步按需加載
  • HMR
  • WASM
  • Module Federation

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

基本結構

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

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

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

使用以下配置:promise

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

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

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

  • __webpack_modules__ 對象,包含了除入口外的全部模塊,示例中即 a.js 模塊
  • __webpack_module_cache__ 對象,用於存儲被引用過的模塊
  • __webpack_require__ 函數,實現模塊引用(require) 邏輯
  • __webpack_require__.d ,工具函數,實現將模塊導出的內容附加的模塊對象上
  • __webpack_require__.o ,工具函數,判斷對象屬性用
  • __webpack_require__.r ,工具函數,在 ESM 模式下聲明 ESM 模塊標識
  • 最後的 IIFE,對應 entry 模塊即上述示例的 index.js ,用於啓動整個應用

這幾個 __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;
    /******/
  }

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

  • 根據 moduleId 參數找到對應的模塊代碼,執行並返回結果
  • 若是 moduleId 對應的模塊被引用過,則直接返回存儲在 __webpack_module_cache__ 緩存對象中的導出內容,避免重複執行

其中,業務模塊代碼被存儲在 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_require__.e :邏輯上包裹了一層中間件模式與 promise.all ,用於異步加載多個模塊
  • __webpack_require__.f :供 __webpack_require__.e 使用的中間件對象,例如使用 Module Federation 特性時就須要在這裏註冊中間件以修改 e 函數的執行邏輯
  • __webpack_require__.u :用於拼接異步模塊名稱的函數
  • __webpack_require__.l :基於 JSONP 實現的異步模塊加載函數
  • __webpack_require__.p :當前文件的完整 URL,可用於計算異步模塊的實際 URL

建議讀者運行示例對比實際生成代碼,感覺它們的具體功能。這幾個運行時模塊構建起 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 所須要用到的 webpack-dev-serverwebpack/hot/xxxquerystring 等框架,這一部分佔了大部分代碼
  • __webpack_require__.l :與異步模塊加載同樣,基於 JSONP 實現的異步模塊加載函數
  • __webpack_require__.e :與異步模塊加載同樣
  • __webpack_require__.f :與異步模塊加載同樣
  • __webpack_require__.hmrF: 用於拼接熱更新模塊 url 的函數
  • webpack/runtime/hot :這不是單個對象或函數,而是包含了一堆實現模塊替換的方法

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

  • 模塊化能力
  • 異步模塊加載能力 —— 實現變動模塊的異步加載
  • 熱替換能力 —— 用拉取到的新模塊替換掉舊的模塊,並觸發熱更新事件

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

實現原理

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

  • 除了業務代碼外,bundle 中還必須包含運行時代碼才能正常運行
  • 運行時的具體內容由業務代碼,確切地說由業務代碼所使用到的特性決定,例如使用到異步加載時須要打包 __webpack_require__.e 函數,那麼這裏面必然有一個運行時依賴收集的過程
  • 開發者編寫的業務代碼會被包裹進恰當的運行時函數中,實現總體協調

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

  1. 依賴收集:遍歷業務代碼模塊收集模塊的特性依賴,從而肯定整個項目對 Webpack runtime 的依賴列表
  2. 生成:合併 runtime 的依賴列表,打包到最終輸出的 bundle

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

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

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

  • 須要輸出那些 chunk
  • 每一個 chunk 包含那些 module,以及每一個 module 的內容
  • chunkchunk 之間的父子依賴關係

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

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

依賴收集

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

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

  • 第一次循環遍歷全部 module,收集全部 module 的 runtime 依賴
  • 第二次循環遍歷全部 chunk,將 chunk 下全部 module 的 runtime 統一收錄到 chunk
  • 第三次循環遍歷全部 runtime chunk,收集其對應的子 chunk 下全部 runtime 依賴,以後遍歷全部依賴併發布 runtimeRequirementInTree 鉤子,(主要是) RuntimePlugin 插件訂閱該鉤子並根據依賴類型建立對應的 RuntimeModule 子類實例

下面咱們展開聊聊細節。

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

在打包(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 chunk,收集其全部子 chunk 的 runtime 依賴
  • 爲該 runtime chunk 下的全部依賴發佈 runtimeRequirementInTree 鉤子
  • RuntimePlugin 監聽鉤子,並根據 runtime 依賴的標識信息建立對應的 RuntimeModule 子類對象,並將對象加入到 ModuleDepedencyGraphChunkGraph 體系中管理

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

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

  • 何謂 runtime chunk?與普通 chunk 是什麼關係
  • 何謂 RuntimeModule?與普通 Module 有什麼區別

總結:Chunk 與 Runtime Chunk

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

  • 每一個 entry 項都會對應生成一個 chunk 對象,稱之爲 initial chunk
  • 每一個異步模塊都會對應生成一個 chunk 對象,稱之爲 async chunk
  • Webpack 5 以後,若是 entry 配置中包含 runtime 值,則在 entry 以外再增長一個專門容納 runtime 的 chunk 對象,此時能夠稱之爲 runtime 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 子類,例如:

  • 模塊化實現中依賴 __webpack_require__.r ,則對應建立 MakeNamespaceObjectRuntimeModule 對象
  • ESM 依賴 __webpack_require__.o ,則對應建立 HasOwnPropertyRuntimeModule 對象
  • 異步模塊加載依賴 __webpack_require__.e,則對應建立 EnsureChunkRuntimeModule 對象
  • 等等

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

資源合併生成

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

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

  • 調用 compilation.createChunkAssets ,遍歷 chunks 將 chunk 對應的全部 module,包括業務模塊、運行時模塊所有合併成一個資源(Source 子類)對象
  • 調用 compilation.emitAsset 將資源對象掛載到 compilation.assets 屬性中
  • 調用 compiler.emitAssets 將 assets 所有寫到 FileSystem
  • 發佈 compiler.hooks.done 鉤子
  • 運行結束

挖坑

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

  • 除了 NormalModule 與 RuntimeModule 體系外,其餘的 Module 子類分別起什麼做用?
  • 單個 Module 的內容轉譯過程是怎麼樣的?在這個過程當中具體是怎麼計算出 runtime 依賴的?
  • 除了記錄 module、chunk 的 runtimeRequirements 以外,ChunkGraph 還起什麼做用?

慢慢挖坑,慢慢填坑吧。若是以爲文章有用,請務必點贊關注轉發來一波。

往期文章

相關文章
相關標籤/搜索