[萬字總結] 一文吃透 Webpack 核心原理

若是以爲文章有用,歡迎點贊關注,但寫做實屬不易,未經做者贊成,禁止任何形式轉載!!!

image.png

背景

Webpack 特別難學!!!javascript

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

  • 498 份JS文件
  • 18862 行註釋
  • 73548 行代碼
  • 54 個 module 類型
  • 69 個 dependency 類型
  • 162 個內置插件
  • 237 個hook

在這個數量級下,源碼的閱讀、分析、學習成本很是高,加上 webpack 官網語焉不詳的文檔,致使 webpack 的學習、上手成本極其高。爲此,社區圍繞着 Webpack 衍生出了各類手腳架,好比 vue-clicreate-react-app,解決「用」的問題。html

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

  • 想給基礎庫作個升級,出現兼容性問題跑不動了,直接放棄
  • 想優化一下編譯性能,可是不清楚內部原理,無從下手

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

  1. 構建的核心流程
  2. loader 的做用
  3. plugin 架構與經常使用套路

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

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

核心流程解析

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

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

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

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

  1. 初始化階段:

    1. 初始化參數:從配置文件、 配置對象、Shell 參數中讀取,與默認配置結合得出最終的參數
    2. 建立編譯器對象:用上一步獲得的參數建立 Compiler 對象
    3. 初始化編譯環境:包括注入內置插件、註冊各類模塊工廠、初始化 RuleSet 集合、加載配置的插件等
    4. 開始編譯:執行 compiler 對象的 run 方法
    5. 肯定入口:根據配置中的 entry 找出全部的入口文件,調用 compilition.addEntry 將入口文件轉換爲 dependence 對象
  2. 構建階段:

    1. 編譯模塊(make):根據 entry 對應的 dependence 建立 module 對象,調用 loader 將模塊轉譯爲標準 JS 內容,調用 JS 解釋器將內容轉換爲 AST 對象,從中找出該模塊依賴的模塊,再 遞歸 本步驟直到全部入口依賴的文件都通過了本步驟的處理
    2. 完成模塊編譯:上一步遞歸處理全部能觸達到的模塊後,獲得了每一個模塊被翻譯後的內容以及它們之間的 依賴關係圖
  3. 生成階段:

    1. 輸出資源(seal):根據入口和模塊之間的依賴關係,組裝成一個個包含多個模塊的 Chunk,再把每一個 Chunk 轉換成一個單獨的文件加入到輸出列表,這步是能夠修改輸出內容的最後機會
    2. 寫入文件系統(emitAssets):在肯定好輸出內容後,根據配置肯定輸出的路徑和文件名,把文件內容寫入到文件系統

單次構建過程自上而下按順序執行,下面會展開聊聊細節,在此以前,對上述說起的各種技術名詞不太熟悉的同窗,能夠先看看簡介:

  • Entry:編譯入口,webpack 編譯的起點
  • Compiler:編譯管理器,webpack 啓動後會建立 compiler 對象,該對象一直存活知道結束退出
  • Compilation:單次編輯過程的管理器,好比 watch = true 時,運行過程當中只有一個 compiler 但每次文件變動觸發從新編譯時,都會建立一個新的 compilation 對象
  • Dependence:依賴對象,webpack 基於該類型記錄模塊間依賴關係
  • Module:webpack 內部全部資源都會以「module」對象形式存在,全部關於資源的操做、轉譯、合併都是以 「module」 爲基本單位進行的
  • Chunk:編譯完成準備輸出時,webpack 會將 module 按特定的規則組織成一個一個的 chunk,這些 chunk 某種程度上跟最終輸出一一對應
  • Loader:資源內容轉換器,其實就是實現從內容 A 轉換 B 的轉換器
  • Plugin:webpack構建過程當中,會在特定的時機廣播對應的事件,插件監聽這些事件,在特定時間點介入編譯過程

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 會在初始化階段根據配置內容動態注入對應的插件,包括:

  • 注入 EntryOptionPlugin 插件,處理 entry 配置
  • 根據 devtool 值判斷後續用那個插件處理 sourcemap,可選值:EvalSourceMapDevToolPluginSourceMapDevToolPluginEvalDevToolModulePlugin
  • 注入 RuntimePlugin ,用於根據代碼內容動態注入 webpack 運行時

到這裏,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 ,觸發 lib/webpack.js 文件中 createCompiler 方法
  • createCompiler 方法內部調用 WebpackOptionsApply 插件
  • WebpackOptionsApply 定義在 lib/WebpackOptionsApply.js 文件,內部根據 entry 配置決定注入 entry 相關的插件,包括:DllEntryPluginDynamicEntryPluginEntryPluginPrefetchPluginProgressPluginContainerPlugin
  • Entry 相關插件,如 lib/EntryPlugin.jsEntryPlugin 監聽 compiler.make 鉤子
  • lib/compiler.jscompile 函數內調用 this.hooks.make.callAsync
  • 觸發 EntryPluginmake 回調,在回調中執行 compilation.addEntry 函數
  • compilation.addEntry 函數內部通過一坨與主流程無關的 hook 以後,再調用 handleModuleCreate 函數,正式開始構建內容

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

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

構建階段

基本流程

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

  • Webpack 編譯過程會將源碼解析爲 AST 嗎?webpack 與 babel 分別實現了什麼?
  • Webpack 編譯過程當中,如何識別資源對其餘資源的依賴?
  • 相對於 grunt、gulp 等流式構建工具,爲何 webpack 會被認爲是新一代的構建工具?

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

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

  1. 調用 handleModuleCreate ,根據文件類型構建 module 子類
  2. 調用 loader-runner 倉庫的 runLoaders 轉譯 module 內容,一般是從各種資源類型轉譯爲 JavaScript 文本
  3. 調用 acorn 將 JS 文本解析爲AST
  4. 遍歷 AST,觸發各類鉤子

    1. HarmonyExportDependencyParserPlugin 插件監聽 exportImportSpecifier 鉤子,解讀 JS 文本對應的資源依賴
    2. 調用 module 對象的 addDependency 將依賴對象加入到 module 依賴列表中
  5. AST 遍歷完畢後,調用 module.handleParseResult 處理模塊依賴
  6. 對於 module 新增的依賴,調用 handleModuleCreate ,控制流回到第一步
  7. 全部依賴都解析完畢後,構建階段結束

這個過程當中數據流 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 ,再再遞歸上述流程:

到這裏解析完全部模塊後,發現沒有更多新的依賴,就能夠繼續推動,進入下一步。

總結

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

  • Webpack 編譯過程會將源碼解析爲 AST 嗎?webpack 與 babel 分別實現了什麼?

    • 構建階段會讀取源碼,解析爲 AST 集合。
    • Webpack 讀出 AST 以後僅遍歷 AST 集合;babel 則對源碼作等價轉換
  • Webpack 編譯過程當中,如何識別資源對其餘資源的依賴?

    • Webpack 遍歷 AST 集合過程當中,識別 require/ import 之類的導入語句,肯定模塊對其餘資源的依賴關係
  • 相對於 grant、gulp 等流式構建工具,爲何 webpack 會被認爲是新一代的構建工具?

    • Grant、Gulp 僅執行開發者預約義的任務流;而 webpack 則深刻處理資源的內容,功能上更強大

生成階段

基本流程

構建階段圍繞 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 封裝規則比較簡單:

  • entry 及 entry 觸達到的模塊,組合成一個 chunk
  • 使用動態引入語句引入的模塊,各自組合成一個 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 。根據上面說的規則:

  • entry 及entry觸達到的模塊,組合成一個 chunk
  • 使用動態引入語句引入的模塊,各自組合成一個 chunk

生成的 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. 觸發各類優化鉤子

上面 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,上面已經把邏輯層面的構造主流程梳理完了,這裏結合資源形態流轉的角度從新考察整個過程,加深理解:

  • compiler.make 階段:

    • entry 文件以 dependence 對象形式加入 compilation 的依賴列表,dependence 對象記錄有 entry 的類型、路徑等信息
    • 根據 dependence 調用對應的工廠函數建立 module 對象,以後讀入 module 對應的文件內容,調用 loader-runner 對內容作轉化,轉化結果如有其它依賴則繼續讀入依賴資源,重複此過程直到全部依賴均被轉化爲 module
  • compilation.seal 階段:

    • 遍歷 module 集合,根據 entry 配置及引入資源的方式,將 module 分配到不一樣的 chunk
    • 遍歷 chunk 集合,調用 compilation.emitAsset 方法標記 chunk 的輸出規則,即轉化爲 assets 集合
  • compiler.emitAssets 階段:

    • assets 寫入文件系統

Plugin 解析

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

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

  • WHAT: 什麼是插件
  • WHEN: 什麼時間點會有什麼鉤子被觸發
  • HOW: 在鉤子回調中,如何影響編譯狀態

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個,且官網對每一個鉤子的說明都太簡短,就我我的而言看完並無太大收穫,因此有必要展開聊一下這個話題。先看幾個例子:

  • compiler.hooks.compilation

    • 時機:啓動編譯建立出 compilation 對象後觸發
    • 參數:當前編譯的 compilation 對象
    • 示例:不少插件基於此事件獲取 compilation 實例
  • compiler.hooks.make

    • 時機:正式開始編譯時觸發
    • 參數:一樣是當前編譯的 compilation 對象
    • 示例:webpack 內置的 EntryPlugin 基於此鉤子實現 entry 模塊的初始化
  • compilation.hooks.optimizeChunks

    • 時機: seal 函數中,chunk 集合構建完畢後觸發
    • 參數:chunks 集合與 chunkGroups 集合
    • 示例: SplitChunksPlugin 插件基於此鉤子實現 chunk 拆分優化
  • compiler.hooks.done

    • 時機:編譯完成後觸發
    • 參數: stats 對象,包含編譯過程當中的各種統計信息
    • 示例: webpack-bundle-analyzer 插件基於此鉤子實現打包分析

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

觸發時機

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

compilation 對象逐次觸發:

因此,理解清楚前面說的 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 輸出,因此在插件中只能經過調用類型實體的各類方法來或者更改實體的配置信息,變動編譯行爲。例如:

  • compilation.addModule :添加模塊,能夠在原有的 module 構建規則以外,添加自定義模塊
  • compilation.emitAsset:直譯是「提交資產」,功能能夠理解將內容寫入到特定路徑

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

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 對象狀態的接口:

  • compilation.dependencyFactories.set
  • compilation.addEntry

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

  • compilation.addModule:添加模塊,能夠在原有的 module 構建規則以外,添加自定義模塊
  • compilation.emitAsset:直譯是「提交資產」,功能能夠理解將內容寫入到特定路徑
  • compilation.addEntry:添加入口,功能上與直接定義 entry 配置相同
  • module.addError:添加編譯錯誤信息
  • ...

Loader 介紹

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

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

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

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

附錄

源碼閱讀技巧

  • 拈輕怕重:挑軟柿子捏,好比初始化過程雖然繞,可是相對來講是概念最少、邏輯最清晰的,那從這裏入手摸清整個工做過程,能夠習得 webpack 的一些通用套路,例如鉤子的設計與做用、編碼規則、命名習慣、內置插件的加載邏輯等,至關於先入了個門
  • 學會調試:多用 ndb 單點調試功能追蹤程序的運行,雖然 node 的調試有不少種方法,可是我我的更推薦 ndb ,靈活、簡單,配合 debugger 語句是大殺器
  • 理解架構:某種程度上能夠將 webpack 架構簡化爲 compiler + compilation + plugins ,webpack 運行過程當中只會有一個 compiler ;而每次編譯 —— 包括調用 compiler.run 函數或者 watch = true 時文件發生變動,都會建立一個 compilation 對象。理解這三個核心對象的設計、職責、協做,差很少就能理解 webpack 的核心邏輯了
  • 抓大放小: plugin 的關鍵是「鉤子」,我建議戰略上重視,戰術上忽視!鉤子畢竟是 webpack 的關鍵概念,是整個插件機制的根基,學習 webpack 根本不可能繞過鉤子,可是相應的邏輯跳轉實在太繞太不直觀了,看代碼的時候一直揪着這個點的話,複雜性會劇增,個人經驗是:

    • 認真看一下 tapable 倉庫的文檔,或者粗略看一下 tapable 的源碼,理解同步鉤子、異步鉤子、promise 鉤子、串行鉤子、並行鉤子等概念,對 tapable 提供的事件模型有一個較爲精細的認知,這叫戰略上重視
    • 遇到不懂的鉤子別慌,個人經驗我連這個類都不清楚幹啥的,要去理解這些鉤子實在太難了,不如先略過鉤子自己的含義,去看那些插件用到了它,而後到插件哪裏去加 debugger 語句單點調試,等你縷清後續邏輯的時候,大機率你也知道鉤子的含義了,這叫戰術上忽視
  • 保持好奇心:學習過程保持旺盛的好奇心和韌性,善於 \& 勇於提出問題,而後基於源碼和社區資料去總結出本身的答案,問題可能會不少,好比:

    • loader 爲何要設計 pre、pitch、post、inline?
    • compilation.seal 函數內部設計了不少優化型的鉤子,爲何須要區分的這麼細?webpack 設計者對不一樣鉤子有什麼預期?
    • 爲何須要那麼多 module 子類?這些子類分別在何時被使用?

ModuleModule 子類

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

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

沒法複製加載中的內容

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

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

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

image.png

相關文章
相關標籤/搜索