1. 程式人生 > 其它 >Webpack外掛核心原理

Webpack外掛核心原理

引言

圍繞 Webpack 打包流程中最核心的機制就是所謂的 Plugin 機制。

所謂外掛即是 webpack 生態中最關鍵的部分, 它為社群使用者提供了一種強有力的方式來直接觸及 webpack 的編譯過程(compilation process)。

今天,我們來聊聊 Webpack 中必不可少的核心 Plugin 機制 ~

Plugin

本質上在 Webpack 編譯階段會為各個編譯物件初始化不同的 Hook ,開發者可以在自己編寫的 Plugin 中監聽到這些 Hook ,在打包的某個特定時間段觸發對應 Hook 注入特定的邏輯從而實現自己的行為。

關於 Plugin 中的 Hook 內部完全是基於 tapable 來實現

Plugin 中的常用物件

首先讓我們先來看看 Webpack 中哪些物件可以註冊 Hook :

  • compiler Hook

  • compilation Hook

  • ContextModuleFactory Hook

  • JavascriptParser Hooks

  • NormalModuleFactory Hooks

別擔心,也許對於這 5 個物件現在你會感覺到非常陌生,之後我會逐步帶你攻克它們。

外掛的基本構成

我們先來看這樣一個最簡單的外掛,它會在 compilation(編譯)完成時執行輸出 done :

class DonePlugin {
  apply(compiler) {
    // 呼叫 Compiler Hook 註冊額外邏輯
    compiler.hooks.done.tap('Plugin Done', () => {
      console.log('compilation done');
    });
  }
}

module.exports = DonePlugin;

此時,在 compilation 完成時打包終端會打印出來一行 compilation done

我們可以看到一個 Webpack Plugin 主要由以下幾個方面組成:

  • 首先一個 Plugin 應該是一個 class,當然也可以是一個函式。

  • 其次 Plugin 的原型物件上應該存在一個 apply 方法,當 webpack 建立 compiler 物件時會呼叫各個外掛例項上的 apply 方法並且傳入 compiler 物件作為引數。

  • 同時需要指定一個繫結在 compiler 物件上的 Hook , 比如 compiler.hooks.done.tap 在傳入的 compiler 物件上監聽 done 事件。

  • 在 Hook 的回撥中處理外掛自身的邏輯,這裡我們簡單的做了 console.log。

  • 根據 Hook 的種類,在完成邏輯後通知 webpack 繼續進行。

外掛的構建物件

上邊我們有提到過 Webpack Plugin 中哪些對應可以進行 Hook 註冊,接下來我會帶你深入這 5 個物件。

理解它們是理解並應用 Webpack Plugin 的重中之重。

compiler 物件

class DonePlugin {
  apply(compiler) {
    // 呼叫 Compiler Hook 註冊額外邏輯
    compiler.hooks.done.tapAsync('Plugin Done', (stats, callback) => {
      console.log(compiler, 'compiler 物件');
    });
  }
}

module.exports = DonePlugin;

在 compiler 物件中儲存著完整的 Webpack 環境配置,它通過 CLI 或 者 Node API傳遞的所有選項創建出一個 compilation 例項。

這個物件會在首次啟動 Webpack 時建立,我們可以通過 compiler 物件上訪問到 Webapck 的主環境配置,比如 loader 、 plugin 等等配置資訊。

compiler 你可以認為它是一個單例,每次啟動 webpack 構建時它都是一個獨一無二,僅僅會建立一次的物件。

關於 compiler 物件存在以下幾個主要屬性:

  • 通過 compiler.options , 我們可以訪問編譯過程中 webpack 的完整配置資訊。

在 compiler.options 物件中儲存著本次啟動 webpack 時候所有的配置檔案,包括但不限於 loaders 、 entry 、 output 、 plugin 等等完整配置資訊。

  • 通過 compiler.inputFileSystem(獲取檔案相關 API 物件)、outputFileSystem(輸出檔案相關 API 物件) 可以幫助我們實現檔案操作,你可以將它簡單的理解為 Node Api 中的 fs 模組的拓展。

如果我們希望自定義外掛的一些輸入輸出行為能夠跟 webpack 儘量同步,那麼最好使用 compiler 提供的這兩個變數。

需要額外注意的是當 compiler 物件執行在 watch 模式通常是 devServer 下,outputFileSystem 會被重寫成記憶體輸出物件,換句話來說也就是在 watch 模式下 webpack 構建並非生成真正的檔案而是儲存在了記憶體中。

如果你的外掛對於檔案操作存在對應的邏輯,那麼接下里請使用 compiler.inputFileSystem/outputFileSystem 更換掉程式碼中的 fs 吧。

  • 同時 compiler.hooks 中也儲存了擴充套件了來自 tapable 的不同種類 Hook ,監聽這些 Hook 從而可以在 compiler 生命週期中植入不同的邏輯。

關於 compiler 物件的屬性你可以在 webpack/lib/Compiler.js中進行檢視所有屬性。

compilation 物件

class DonePlugin {
  apply(compiler) {
    compiler.hooks.afterEmit.tapAsync(
      'Plugin Done',
      (compilation, callback) => {
        console.log(compilation, 'compilation 物件');
      }
    );
  }
}

module.exports = DonePlugin;

所謂 compilation 物件代表一次資源的構建,compilation 例項能夠訪問所有的模組和它們的依賴。

一個 compilation 物件會對構建依賴圖中所有模組,進行編譯。 在編譯階段,模組會被載入(load)、封存(seal)、優化(optimize)、 分塊(chunk)、雜湊(hash)和重新建立(restore)。

在 compilation 物件中我們可以獲取/操作本次編譯當前模組資源、編譯生成資源、變化的檔案以及被跟蹤的狀態資訊,同樣 compilation 也基於 tapable 拓展了不同時機的 Hook 回撥。

簡單來說比如在 devServer 下每次修改程式碼都會進行重新編譯,此時你可以理解為每次構建都會建立一個新的 compilation 物件。

關於 compilation 物件存在以下幾個主要屬性:

  • modules

它的值是一個 Set 型別,關於 modules 。簡單來說你可以認為一個檔案就是一個模組,無論你使用 ESM 還是 Commonjs 編寫你的檔案。每一個檔案都可以被理解成為一個獨立的 module。

  • chunks

所謂 chunk 即是多個 modules 組成而來的一個程式碼塊,當 Webapck 進行打包時會首先根據專案入口檔案分析對應的依賴關係,將入口依賴的多個 modules 組合成為一個大的物件,這個物件即可被稱為 chunk 。

所謂 chunks 當然是多個 chunk 組成的一個 Set 物件。

  • assets

assets 物件上記錄了本次打包生成所有檔案的結果。

  • hooks

同樣在 compilation 物件中基於 tapable 提供給一系列的 Hook ,用於在 compilation 編譯模組階段進行邏輯新增以及修改。

在 Webpack 5 之後提供了一系列 compilation API 替代直接操作 moduels/chunks/assets 等屬性,從而提供給開發者來操作對應 API 影響打包結果。參考 webpack面試題詳細解答

比如一些常見的輸出檔案工作,現在使用 compilation.emitAsset API 來替代直接操作 compilation.assets 物件。

ContextModuleFactory Hook

class DonePlugin {
  apply(compiler) {
    compiler.hooks.contextModuleFactory.tap(
      'Plugin',
      (contextModuleFactory) => {
        // 在 require.context 解析請求的目錄之前呼叫該 Hook
        // 引數為需要解析的 Context 目錄物件
        contextModuleFactory.hooks.beforeResolve.tapAsync(
          'Plugin',
          (data, callback) => {
            console.log(data, 'data');
            callback();
          }
        );
      }
    );
  }
}

module.exports = DonePlugin;

compiler.hooks 物件上同樣存在一個 contextModuleFactory ,它同樣是基於 tapable 進行衍生了一些列的 hook 。

contextModuleFactory 提供了一些列的 hook ,正如其名稱那樣它主要用來使用 Webpack 獨有 API require.context 解析檔案目錄時候進行處理。

關於 ContextModuleFactory 系列的 Hook 不是特別常用

NormalModuleFactory Hook

class DonePlugin {
  apply(compiler) {
    compiler.hooks.normalModuleFactory.tap(
      'MyPlugin',
      (NormalModuleFactory) => {
        NormalModuleFactory.hooks.beforeResolve.tap(
          'MyPlugin',
          (resolveData) => {
            console.log(resolveData, 'resolveData');
            // 僅僅解析目錄為./src/index.js 忽略其他引入的模組
            return resolveData.request === './src/index.js';
          }
        );
      }
    );
  }
}

module.exports = DonePlugin;

Webpack compiler 物件中通過 NormalModuleFactory 模組生成各類模組。

換句話來說,從入口檔案開始,NormalModuleFactory 會分解每個模組請求,解析檔案內容以查詢進一步的請求,然後通過分解所有請求以及解析新的檔案來爬取全部檔案。在最後階段,每個依賴項都會成為一個模組例項。

我們可以通過 NormalModuleFactory Hook 來注入 Plugin 邏輯從而控制 Webpack 中對於預設模組引用時的處理,比如 ESM、CJS 等模組引入前後時注入對應邏輯。

關於 NormalModuleFactory Hook 可以用於在 Plugin 中處理 Webpack 解析模組時注入特定的邏輯從而影影響打包時的模組引入內容

JavascriptParser Hook

const t = require('@babel/types');
const g = require('@babel/generator').default;
const ConstDependency = require('webpack/lib/dependencies/ConstDependency');

class DonePlugin {
  apply(compiler) {
    // 解析模組時進入
    compiler.hooks.normalModuleFactory.tap('pluginA', (factory) => {
      // 當使用javascript/auto處理模組時會呼叫該hook
      const hook = factory.hooks.parser.for('javascript/auto');

      // 註冊
      hook.tap('pluginA', (parser) => {
        parser.hooks.statementIf.tap('pluginA', (statementNode) => {
          const { code } = g(t.booleanLiteral(false));
          const dep = new ConstDependency(code, statementNode.test.range);
          dep.loc = statementNode.loc;
          parser.state.current.addDependency(dep);
          return statementNode;
        });
      });
    });
  }
}

module.exports = DonePlugin;

上邊我們提到了 compiler.normalModuleFactory 鉤子用於 Webpack 對於解析模組時候觸發,而 JavascriptParser Hook 正是基於模組解析生成 AST 節點時注入的 Hook 。

webpack使用 Parser 對每個模組進行解析,我們可以在 Plugin 中註冊 JavascriptParser Hook 在 Webpack 對於模組解析生成 AST 節點時新增額外的邏輯。

上述的 DonePlugin 會將模組中所有的 statementIf 節點的判斷表示式修改稱為 false 。

結尾

Webpack Plugin 的核心機制就是基於 tapable 產生的釋出訂閱者模式,在不同的週期觸發不同的 Hook 從而影響最終的打包結果。

其實乍一看很多文章中很多概念,而且關於 Webpack 文件的確很多地方也沒有進行完善的補充,但是回過頭來仔細梳理一下。

你感覺到陌生的僅僅是文章中羅列出來的 API 而已,文章的目的並不是希望通過短短几千字你可以詳細掌握 Webpack Plugin 的各種開發方式,而是在於讓你對於 Plugin 機制和開發用法有一個簡短的瞭解和概念。

之後我會在專欄中補充一些 Plugin 的實戰開發,真正帶大家領略開源外掛專案中是如何在這些看似零碎的知識中化零為整,成為真正投身於業務之中的企業應用。