一文喫透 Webpack 核心原理

背景

Webpack 特別難學!!!

時至 5.0 版本之後,Webpack 功能集變得非常龐大,包括:模塊打包、代碼分割、按需加載、HMR、Tree-shaking、文件監聽、sourcemap、Module Federation、devServer、DLL、多進程等等,爲了實現這些功能,webpack 的代碼量已經到了驚人的程度:

在這個數量級下,源碼的閱讀、分析、學習成本非常高,加上 webpack 官網語焉不詳的文檔,導致 webpack 的學習、上手成本極其高。爲此,社區圍繞着 Webpack 衍生出了各種手腳架,比如 vue-cli、create-react-app,解決 “用” 的問題。

但這又導致一個新的問題,大部分人在工程化方面逐漸變成一個配置工程師,停留在 “會用會配” 但是不知道黑盒裏面到底是怎麼轉的階段,遇到具體問題就瞎了:

究其原因還是對 webpack 內部運行機制沒有形成必要的整體認知,無法迅速定位問題 —— 對,連問題的本質都常常看不出,所謂的不能透過現象看本質,那本質是啥?我個人將 webpack 整個龐大的體系抽象爲三方面的知識:

  1. 構建的核心流程

  2. loader 的作用

  3. plugin 架構與常用套路

三者協作構成 webpack 的主體框架:

理解了這三塊內容就算是入了個門,對 Webpack 有了一個最最基礎的認知了,工作中再遇到問題也就能按圖索驥了。補充一句,作爲一份入門教程,本文不會展開太多 webpack 代碼層面的細節 —— 我的精力也不允許,所以讀者也不需要看到一堆文字就產生特別大的心理負擔。

核心流程解析

首先,我們要理解一個點,Webpack 最核心的功能:

At its core, webpack is a static module bundler for modern JavaScript applications.

也就是將各種類型的資源,包括圖片、css、js 等,轉譯、組合、拼接、生成 JS 格式的 bundler 文件。官網首頁的動畫很形象地表達了這一點:

1d66a833-2841-4a8a-a91a-0da800fab306.png

這個過程核心完成了 內容轉換 + 資源合併 兩種功能,實現上包含三個階段:

  1. 初始化階段:

  2. 初始化參數:從配置文件、 配置對象、Shell 參數中讀取,與默認配置結合得出最終的參數

  3. 創建編譯器對象:用上一步得到的參數創建 Compiler 對象

  4. 初始化編譯環境:包括注入內置插件、註冊各種模塊工廠、初始化 RuleSet 集合、加載配置的插件等

  5. 開始編譯:執行 compiler 對象的 run 方法

  6. 確定入口:根據配置中的 entry 找出所有的入口文件,調用 compilition.addEntry 將入口文件轉換爲 dependence 對象

  7. 構建階段:

  8. 編譯模塊 (make):根據 entry 對應的 dependence 創建 module 對象,調用 loader 將模塊轉譯爲標準 JS 內容,調用 JS 解釋器將內容轉換爲 AST 對象,從中找出該模塊依賴的模塊,再 遞歸 本步驟直到所有入口依賴的文件都經過了本步驟的處理

  9. 完成模塊編譯:上一步遞歸處理所有能觸達到的模塊後,得到了每個模塊被翻譯後的內容以及它們之間的 依賴關係圖

  10. 生成階段:

  11. 輸出資源 (seal):根據入口和模塊之間的依賴關係,組裝成一個個包含多個模塊的 Chunk,再把每個 Chunk 轉換成一個單獨的文件加入到輸出列表,這步是可以修改輸出內容的最後機會

  12. 寫入文件系統 (emitAssets):在確定好輸出內容後,根據配置確定輸出的路徑和文件名,把文件內容寫入到文件系統

單次構建過程自上而下按順序執行,下面會展開聊聊細節,在此之前,對上述提及的各類技術名詞不太熟悉的同學,可以先看看簡介:

webpack 編譯過程都是圍繞着這些關鍵對象展開的,更詳細完整的信息,可以參考 Webpack 知識圖譜 。

初始化階段

學習一個項目的源碼通常都是從入口開始看起,按圖索驥慢慢摸索出套路的,所以先來看看 webpack 的初始化過程:

解釋一下:

  1. process.args + webpack.config.js 合併成用戶配置

  2. 調用 validateSchema 校驗配置

  3. 調用 getNormalizedWebpackOptions + applyWebpackOptionsBaseDefaults 合併出最終配置

  4. 創建 compiler 對象

  5. 遍歷用戶定義的 plugins 集合,執行插件的 apply 方法

  6. 調用 new WebpackOptionsApply().process 方法,加載各種內置插件

主要邏輯集中在 WebpackOptionsApply 類,webpack 內置了數百個插件,這些插件並不需要我們手動配置,WebpackOptionsApply 會在初始化階段根據配置內容動態注入對應的插件,包括:

到這裏,compiler 實例就被創建出來了,相應的環境參數也預設好了,緊接着開始調用 compiler.compile 函數:

// 取自 webpack/lib/compiler.js 
compile(callback) {
    const params = this.newCompilationParams();
    this.hooks.beforeCompile.callAsync(params, err ={
      // ...
      const compilation = this.newCompilation(params);
      this.hooks.make.callAsync(compilation, err ={
        // ...
        this.hooks.finishMake.callAsync(compilation, err ={
          // ...
          process.nextTick(() ={
            compilation.finish(err ={
              compilation.seal(err ={...});
            });
          });
        });
      });
    });
  }

Webpack 架構很靈活,但代價是犧牲了源碼的直觀性,比如說上面說的初始化流程,從創建 compiler 實例到調用 make 鉤子,邏輯鏈路很長:

這個過程需要在 webpack 初始化的時候預埋下各種插件,經歷 4 個文件,7 次跳轉纔開始進入主題,前戲太足了,如果讀者對 webpack 的概念、架構、組件沒有足夠了解時,源碼閱讀過程會很痛苦。

關於這個問題,我在文章最後總結了一些技巧和建議,有興趣的可以滑到附錄閱讀模塊。

構建階段

基本流程

你有沒有思考過這樣的問題:

這些問題,基本上在構建階段都能看出一些端倪。構建階段從 entry 開始遞歸解析資源與資源的依賴,在 compilation 對象內逐步構建出 module 集合以及 module 之間的依賴關係,核心流程:

解釋一下,構建階段從入口文件開始:

  1. 調用 handleModuleCreate ,根據文件類型構建 module 子類

  2. 調用 loader-runner 倉庫的 runLoaders 轉譯 module 內容,通常是從各類資源類型轉譯爲 JavaScript 文本

  3. 調用 acorn 將 JS 文本解析爲 AST

  4. 遍歷 AST,觸發各種鉤子

  5. HarmonyExportDependencyParserPlugin 插件監聽 exportImportSpecifier 鉤子,解讀 JS 文本對應的資源依賴

  6. 調用 module 對象的 addDependency 將依賴對象加入到 module 依賴列表中

  7. AST 遍歷完畢後,調用 module.handleParseResult 處理模塊依賴

  8. 對於 module 新增的依賴,調用 handleModuleCreate ,控制流回到第一步

  9. 所有依賴都解析完畢後,構建階段結束

這個過程中數據流 module => ast => dependences => module ,先轉 AST 再從 AST 找依賴。這就要求 loaders 處理完的最後結果必須是可以被 acorn 處理的標準 JavaScript 語法,比如說對於圖片,需要從圖像二進制轉換成類似於 export default "data:image/png;base64,xxx" 這類 base64 格式或者 export default "http://xxx" 這類 url 格式。

compilation 按這個流程遞歸處理,逐步解析出每個模塊的內容以及 module 依賴關係,後續就可以根據這些內容打包輸出。

示例:層級遞進

假如有如下圖所示的文件依賴樹:

其中 index.jsentry 文件,依賴於 a/b 文件;a 依賴於 c/d 文件。初始化編譯環境之後,EntryPlugin 根據 entry 配置找到 index.js 文件,調用 compilation.addEntry 函數觸發構建流程,構建完畢後內部會生成這樣的數據結構:

此時得到 module[index.js] 的內容以及對應的依賴對象 dependence[a.js]dependence[b.js] 。OK,這就得到下一步的線索:a.js、b.js,根據上面流程圖的邏輯繼續調用 module[index.js]handleParseResult 函數,繼續處理 a.js、b.js 文件,遞歸上述流程,進一步得到 a、b 模塊:

從 a.js 模塊中又解析到 c.js/d.js 依賴,於是再再繼續調用 module[a.js]handleParseResult ,再再遞歸上述流程:

到這裏解析完所有模塊後,發現沒有更多新的依賴,就可以繼續推進,進入下一步。

總結

回顧章節開始時提到的問題:

生成階段

基本流程

構建階段圍繞 module 展開,生成階段則圍繞 chunks 展開。經過構建階段之後,webpack 得到足夠的模塊內容與模塊關係信息,接下來開始生成最終資源了。代碼層面,就是開始執行 compilation.seal 函數:

// 取自 webpack/lib/compiler.js 
compile(callback) {
    const params = this.newCompilationParams();
    this.hooks.beforeCompile.callAsync(params, err ={
      // ...
      const compilation = this.newCompilation(params);
      this.hooks.make.callAsync(compilation, err ={
        // ...
        this.hooks.finishMake.callAsync(compilation, err ={
          // ...
          process.nextTick(() ={
            compilation.finish(err ={
              **compilation.seal**(err ={...});
            });
          });
        });
      });
    });
  }

seal 原意密封、上鎖,我個人理解在 webpack 語境下接近於 “將模塊裝進蜜罐”seal 函數主要完成從 modulechunks 的轉化,核心流程:

簡單梳理一下:

  1. 構建本次編譯的 ChunkGraph 對象;

  2. 遍歷 compilation.modules 集合,將 moduleentry/動態引入 的規則分配給不同的 Chunk 對象;

  3. compilation.modules 集合遍歷完畢後,得到完整的 chunks 集合對象,調用 createXxxAssets 方法

  4. createXxxAssets 遍歷 module/chunk ,調用 compilation.emitAssets 方法將資 assets 信息記錄到 compilation.assets 對象中

  5. 觸發 seal 回調,控制流回到 compiler 對象

這一步的關鍵邏輯是將 module 按規則組織成 chunks ,webpack 內置的 chunk 封裝規則比較簡單:

chunk 是輸出的基本單位,默認情況下這些 chunks 與最終輸出的資源一一對應,那按上面的規則大致上可以推導出一個 entry 會對應打包出一個資源,而通過動態引入語句引入的模塊,也對應會打包出相應的資源,我們來看個示例。

示例:多入口打包

假如有這樣的配置:

const path = require("path");

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

實例配置中有兩個入口,對應的文件結構:

index-a 依賴於 c,且動態引入了 e;index-b 依賴於 c/d 。根據上面說的規則:

生成的 chunks 結構爲:

也就是根據依賴關係,chunk[a] 包含了 index-a/c 兩個模塊;chunk[b] 包含了 c/index-b/d 三個模塊;chunk[e-hash] 爲動態引入 e 對應的 chunk。

不知道大家注意到沒有,chunk[a]chunk[b] 同時包含了 c,這個問題放到具體業務場景可能就是,一個多頁面應用,所有頁面都依賴於相同的基礎庫,那麼這些所有頁面對應的 entry 都會包含有基礎庫代碼,這豈不浪費?爲了解決這個問題,webpack 提供了一些插件如 CommonsChunkPluginSplitChunksPlugin,在基本規則之外進一步優化 chunks 結構。

SplitChunksPlugin 的作用

SplitChunksPlugin 是 webpack 架構高擴展的一個絕好的示例,我們上面說了 webpack 主流程裏面是按 entry / 動態引入 兩種情況組織 chunks 的,這必然會引發一些不必要的重複打包,webpack 通過插件的形式解決這個問題。

回顧 compilation.seal 函數的代碼,大致上可以梳理成這麼 4 個步驟:

  1. 遍歷 compilation.modules ,記錄下模塊與 chunk 關係

  2. 觸發各種模塊優化鉤子,這一步優化的主要是模塊依賴關係

  3. 遍歷 module 構建 chunk 集合

  4. 觸發各種優化鉤子

image (3).png

上面 1-3 都是預處理 + chunks 默認規則的實現,不在我們討論範圍,這裏重點關注第 4 個步驟觸發的 optimizeChunks 鉤子,這個時候已經跑完主流程的邏輯,得到 chunks 集合,SplitChunksPlugin 正是使用這個鉤子,分析 chunks 集合的內容,按配置規則增加一些通用的 chunk :

module.exports = class SplitChunksPlugin {
  constructor(options = {}) {
    // ...
  }

  _getCacheGroup(cacheGroupSource) {
    // ...
  }

  apply(compiler) {
    // ...
    compiler.hooks.thisCompilation.tap("SplitChunksPlugin"(compilation) ={
      // ...
      compilation.hooks.optimizeChunks.tap(
        {
          name: "SplitChunksPlugin",
          stage: STAGE_ADVANCED,
        },
        (chunks) ={
          // ...
        }
      );
    });
  }
};

理解了嗎?webpack 插件架構的高擴展性,使得整個編譯的主流程是可以固化下來的,分支邏輯和細節需求 “外包” 出去由第三方實現,這套規則架設起了龐大的 webpack 生態,關於插件架構的更多細節,下面 plugin 部分有詳細介紹,這裏先跳過。

寫入文件系統

經過構建階段後,compilation 會獲知資源模塊的內容與依賴關係,也就知道 “輸入” 是什麼;而經過 seal 階段處理後, compilation 則獲知資源輸出的圖譜,也就是知道怎麼 “輸出”:哪些模塊跟那些模塊“綁定” 在一起輸出到哪裏。seal 後大致的數據結構:

compilation = {
  // ...
  modules: [
    /* ... */
  ],
  chunks: [
    {
      id: "entry name",
      files: ["output file name"],
      hash: "xxx",
      runtime: "xxx",
      entryPoint: {xxx}
      // ...
    },
    // ...
  ],
};

seal 結束之後,緊接着調用 compiler.emitAssets 函數,函數內部調用 compiler.outputFileSystem.writeFile 方法將 assets 集合寫入文件系統,實現邏輯比較曲折,但是與主流程沒有太多關係,所以這裏就不展開講了。

資源形態流轉

OK,上面已經把邏輯層面的構造主流程梳理完了,這裏結合資源形態流轉的角度重新考察整個過程,加深理解:

Plugin 解析

網上不少資料將 webpack 的插件架構歸類爲 “事件 / 訂閱” 模式,我認爲這種歸納有失偏頗。訂閱模式是一種松耦合架構,發佈器只是在特定時機發布事件消息,訂閱者並不或者很少與事件直接發生交互,舉例來說,我們平常在使用 HTML 事件的時候很多時候只是在這個時機觸發業務邏輯,很少調用上下文操作。而 webpack 的鉤子體系是一種強耦合架構,它在特定時機觸發鉤子時會附帶上足夠的上下文信息,插件定義的鉤子回調中,能也只能與這些上下文背後的數據結構、接口交互產生 side effect,進而影響到編譯狀態和後續流程。

學習插件架構,需要理解三個關鍵問題:

What: 什麼是插件

從形態上看,插件通常是一個帶有 apply 函數的類:

class SomePlugin {
    apply(compiler) {
    }
}

apply 函數運行時會得到參數 compiler ,以此爲起點可以調用 hook 對象註冊各種鉤子回調,例如:compiler.hooks.make.tapAsync ,這裏面 make 是鉤子名稱,tapAsync 定義了鉤子的調用方式,webpack 的插件架構基於這種模式構建而成,插件開發者可以使用這種模式在鉤子回調中,插入特定代碼。webpack 各種內置對象都帶有 hooks 屬性,比如 compilation 對象:

class SomePlugin {
    apply(compiler) {
        compiler.hooks.thisCompilation.tap('SomePlugin'(compilation) ={
            compilation.hooks.optimizeChunkAssets.tapAsync('SomePlugin'()=>{});
        })
    }
}

鉤子的核心邏輯定義在 Tapable 倉庫,內部定義瞭如下類型的鉤子:

const {
        SyncHook,
        SyncBailHook,
        SyncWaterfallHook,
        SyncLoopHook,
        AsyncParallelHook,
        AsyncParallelBailHook,
        AsyncSeriesHook,
        AsyncSeriesBailHook,
        AsyncSeriesWaterfallHook
 } = require("tapable");

不同類型的鉤子根據其並行度、熔斷方式、同步異步,調用方式會略有不同,插件開發者需要根據這些的特性,編寫不同的交互邏輯,這部分內容也特別多,回頭展開聊聊。

When: 什麼時候會觸發鉤子

瞭解 webpack 插件的基本形態之後,接下來需要弄清楚一個問題:webpack 會在什麼時間節點觸發什麼鉤子?這一塊我認爲是知識量最大的一部分,畢竟源碼裏面有 237 個鉤子,但官網只介紹了不到 100 個,且官網對每個鉤子的說明都太簡短,就我個人而言看完並沒有太大收穫,所以有必要展開聊一下這個話題。先看幾個例子:

這是我總結的鉤子的三個學習要素:觸發時機、傳遞參數、示例代碼。

觸發時機

觸發時機與 webpack 工作過程緊密相關,大體上從啓動到結束,compiler 對象逐次觸發如下鉤子:

image.png

compilation 對象逐次觸發:

image (1).png

所以,理解清楚前面說的 webpack 工作的主流程,基本上就可以捋清楚 “什麼時候會觸發什麼鉤子”。

參數

傳遞參數與具體的鉤子強相關,官網對這方面沒有做出進一步解釋,我的做法是直接在源碼裏面搜索調用語句,例如對於 compilation.hooks.optimizeTree ,可以在 webpack 源碼中搜索 hooks.optimizeTree.call 關鍵字,就可以找到調用代碼:

// lib/compilation.js#2297
this.hooks.optimizeTree.callAsync(this.chunks, this.modules, err ={
});

結合代碼所在的上下文,可以判斷出此時傳遞的是經過優化的 chunksmodules 集合。

找到示例

Webpack 的鉤子複雜程度不一,我認爲最好的學習方法還是帶着目的去查詢其他插件中如何使用這些鉤子。例如,在 compilation.seal 函數內部有 optimizeModulesafterOptimizeModules 這一對看起來很對偶的鉤子,optimizeModules 從字面上可以理解爲用於優化已經編譯出的 modules ,那 afterOptimizeModules 呢?

從 webpack 源碼中唯一搜索到的用途是 ProgressPlugin ,大體上邏輯如下:

compilation.hooks.afterOptimizeModules.intercept({
  name: "ProgressPlugin",
  call() {
    handler(percentage, "sealing", title);
  },
  done() {
    progressReporters.set(compiler, undefined);
    handler(percentage, "sealing", title);
  },
  result() {
    handler(percentage, "sealing", title);
  },
  error() {
    handler(percentage, "sealing", title);
  },
  tap(tap) {
    // p is percentage from 0 to 1
    // args is any number of messages in a hierarchical matter
    progressReporters.set(compilation.compiler, (p, ...args) ={
      handler(percentage, "sealing", title, tap.name, ...args);
    });
    handler(percentage, "sealing", title, tap.name);
  }
});

基本上可以猜測出,afterOptimizeModules 的設計初衷就是用於通知優化行爲的結束。

apply 雖然是一個函數,但是從設計上就只有輸入,webpack 不 care 輸出,所以在插件中只能通過調用類型實體的各種方法來或者更改實體的配置信息,變更編譯行爲。例如:

到這裏,插件的工作機理和寫法已經有一個很粗淺的介紹了,回頭單拎出來細講吧。

How: 如何影響編譯狀態

解決上述兩個問題之後,我們就能理解 “如何將特定邏輯插入 webpack 編譯過程”,接下來纔是重點 —— 如何影響編譯狀態?強調一下,webpack 的插件體系與平常所見的 訂閱 / 發佈 模式差別很大,是一種非常強耦合的設計,hooks 回調由 webpack 決定何時,以何種方式執行;而在 hooks 回調內部可以通過修改狀態、調用上下文 api 等方式對 webpack 產生 side effect

比如,EntryPlugin 插件:

class EntryPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap(
      "EntryPlugin",
      (compilation, { normalModuleFactory }) ={
        compilation.dependencyFactories.set(
          EntryDependency,
          normalModuleFactory
        );
      }
    );

    compiler.hooks.make.tapAsync("EntryPlugin"(compilation, callback) ={
      const { entry, options, context } = this;

      const dep = EntryPlugin.createDependency(entry, options);
      compilation.addEntry(context, dep, options, (err) ={
        callback(err);
      });
    });
  }
}

上述代碼片段調用了兩個影響 compilation 對象狀態的接口:

操作的具體含義可以先忽略,這裏要理解的重點是,webpack 會將上下文信息以參數或 this (compiler 對象) 形式傳遞給鉤子回調,在回調中可以調用上下文對象的方法或者直接修改上下文對象屬性的方式,對原定的流程產生 side effect。所以想純熟地編寫插件,除了要理解調用時機,還需要了解我們可以用哪一些 api,例如:

Loader 介紹

Loader 的作用和實現比較簡單,容易理解,所以簡單介紹一下就行了。回顧 loader 在編譯流程中的生效的位置:

流程圖中, runLoaders 會調用用戶所配置的 loader 集合讀取、轉譯資源,此前的內容可以千奇百怪,但轉譯之後理論上應該輸出標準 JavaScript 文本或者 AST 對象,webpack 才能繼續處理模塊依賴。

理解了這個基本邏輯之後,loader 的職責就比較清晰了,不外乎是將內容 A 轉化爲內容 B,但是在具體用法層面還挺多講究的,有 pitch、pre、post、inline 等概念用於應對各種場景。

爲了幫助理解,這裏補充一個示例:Webpack 案例 -- vue-loader 原理分析。

附錄

源碼閱讀技巧

ModuleModule 子類

從上文可以看出,webpack 構建階段的核心流程基本上都圍繞着 module 展開,相信接觸過、用過 Webpack 的讀者對 module 應該已經有一個感性認知,但是實現上 module 的邏輯是非常複雜繁重的。

以 webpack@5.26.3 爲例,直接或間接繼承自 Module (webpack/lib/Module.js 文件) 的子類有 54 個:

module 體系. png

要一個一個捋清楚這些類的作用實在太累了,我們需要抓住本質:module 的作用是什麼?

module 是 webpack 資源處理的基本單位,可以認爲 webpack 對資源的路徑解析、讀入、轉譯、分析、打包輸出,所有操作都是圍繞着 module 展開的。有很多文章會說 module = 文件, 其實這種說法並不準確,比如子類 AsyncModuleRuntimeModule 就只是一段內置的代碼,是一種資源而不能簡單等價於實際文件。

Webpack 擴展性很強,包括模塊的處理邏輯上,比如說入口文件是一個普通的 js,此時首先創建 NormalModule 對象,在解析 AST 時發現這個文件裏還包含了異步加載語句,例如 requere.ensure ,那麼相應地會創建 AsyncModuleRuntimeModule 模塊,注入異步加載的模板代碼。上面類圖的 54 個 module 子類都是爲適配各種場景設計的。

轉自: Tecvan

https://juejin.cn/post/6949040393165996040#heading-23

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