webpack原理

LuSir發表於2019-05-06

webpack原理

檢視所有文件頁面:前端開發文件,獲取更多資訊。 原文連結:webpack原理,原文廣告模態框遮擋,閱讀體驗不好,所以整理成本文,方便查詢。

工作原理概括

基本概念

在瞭解 Webpack 原理前,需要掌握以下幾個核心概念,以方便後面的理解:

  • Entry:入口,Webpack 執行構建的第一步將從 Entry 開始,可抽象成輸入。
  • Module:模組,在 Webpack 裡一切皆模組,一個模組對應著一個檔案。Webpack 會從配置的 Entry 開始遞迴找出所有依賴的模組。
  • Chunk:程式碼塊,一個 Chunk 由多個模組組合而成,用於程式碼合併與分割。
  • Loader:模組轉換器,用於把模組原內容按照需求轉換成新內容。
  • Plugin:擴充套件外掛,在 Webpack 構建流程中的特定時機會廣播出對應的事件,外掛可以監聽這些事件的發生,在特定時機做對應的事情。

流程概括

Webpack 的執行流程是一個序列的過程,從啟動到結束會依次執行以下流程:

  1. 初始化引數:從配置檔案和 Shell 語句中讀取與合併引數,得出最終的引數;
  2. 開始編譯:用上一步得到的引數初始化 Compiler 物件,載入所有配置的外掛,執行物件的 run 方法開始執行編譯;
  3. 確定入口:根據配置中的 entry 找出所有的入口檔案;
  4. 編譯模組:從入口檔案出發,呼叫所有配置的 Loader 對模組進行翻譯,再找出該模組依賴的模組,再遞迴本步驟直到所有入口依賴的檔案都經過了本步驟的處理;
  5. 完成模組編譯:在經過第4步使用 Loader 翻譯完所有模組後,得到了每個模組被翻譯後的最終內容以及它們之間的依賴關係;
  6. 輸出資源:根據入口和模組之間的依賴關係,組裝成一個個包含多個模組的 Chunk,再把每個 Chunk 轉換成一個單獨的檔案加入到輸出列表,這步是可以修改輸出內容的最後機會;
  7. 輸出完成:在確定好輸出內容後,根據配置確定輸出的路徑和檔名,把檔案內容寫入到檔案系統。

在以上過程中,Webpack 會在特定的時間點廣播出特定的事件,外掛在監聽到感興趣的事件後會執行特定的邏輯,並且外掛可以呼叫 Webpack 提供的 API 改變 Webpack 的執行結果。

流程細節

Webpack 的構建流程可以分為以下三大階段:

  1. 初始化:啟動構建,讀取與合併配置引數,載入 Plugin,例項化 Compiler。
  2. 編譯:從 Entry 發出,針對每個 Module 序列呼叫對應的 Loader 去翻譯檔案內容,再找到該 Module 依賴的 Module,遞迴地進行編譯處理。
  3. 輸出:對編譯後的 Module 組合成 Chunk,把 Chunk 轉換成檔案,輸出到檔案系統。

如果只執行一次構建,以上階段將會按照順序各執行一次。但在開啟監聽模式下,流程將變為如下:

img

在每個大階段中又會發生很多事件,Webpack 會把這些事件廣播出來供給 Plugin 使用,下面來一一介紹。

初始化階段

事件名 解釋
初始化引數 從配置檔案和 Shell 語句中讀取與合併引數,得出最終的引數。 這個過程中還會執行配置檔案中的外掛例項化語句 new Plugin()
例項化 Compiler 用上一步得到的引數初始化 Compiler 例項,Compiler 負責檔案監聽和啟動編譯。Compiler 例項中包含了完整的 Webpack 配置,全域性只有一個 Compiler 例項。
載入外掛 依次呼叫外掛的 apply 方法,讓外掛可以監聽後續的所有事件節點。同時給外掛傳入 compiler 例項的引用,以方便外掛通過 compiler 呼叫 Webpack 提供的 API。
environment 開始應用 Node.js 風格的檔案系統到 compiler 物件,以方便後續的檔案尋找和讀取。
entry-option 讀取配置的 Entrys,為每個 Entry 例項化一個對應的 EntryPlugin,為後面該 Entry 的遞迴解析工作做準備。
after-plugins 呼叫完所有內建的和配置的外掛的 apply 方法。
after-resolvers 根據配置初始化完 resolverresolver 負責在檔案系統中尋找指定路徑的檔案。
空格 空格
空格 空格
空格 空格

編譯階段

事件名 解釋
run 啟動一次新的編譯。
watch-run run 類似,區別在於它是在監聽模式下啟動的編譯,在這個事件中可以獲取到是哪些檔案發生了變化導致重新啟動一次新的編譯。
compile 該事件是為了告訴外掛一次新的編譯將要啟動,同時會給外掛帶上 compiler 物件。
compilation Webpack 以開發模式執行時,每當檢測到檔案變化,一次新的 Compilation 將被建立。一個 Compilation 物件包含了當前的模組資源、編譯生成資源、變化的檔案等。Compilation 物件也提供了很多事件回撥供外掛做擴充套件。
make 一個新的 Compilation 建立完畢,即將從 Entry 開始讀取檔案,根據檔案型別和配置的 Loader 對檔案進行編譯,編譯完後再找出該檔案依賴的檔案,遞迴的編譯和解析。
after-compile 一次 Compilation 執行完成。
invalid 當遇到檔案不存在、檔案編譯錯誤等異常時會觸發該事件,該事件不會導致 Webpack 退出。
空格 空格
空格 空格

在編譯階段中,最重要的要數 compilation 事件了,因為在 compilation 階段呼叫了 Loader 完成了每個模組的轉換操作,在 compilation 階段又包括很多小的事件,它們分別是:

事件名 解釋
build-module 使用對應的 Loader 去轉換一個模組。
normal-module-loader 在用 Loader 對一個模組轉換完後,使用 acorn 解析轉換後的內容,輸出對應的抽象語法樹(AST),以方便 Webpack 後面對程式碼的分析。
program 從配置的入口模組開始,分析其 AST,當遇到 require 等匯入其它模組語句時,便將其加入到依賴的模組列表,同時對新找出的依賴模組遞迴分析,最終搞清所有模組的依賴關係。
seal 所有模組及其依賴的模組都通過 Loader 轉換完成後,根據依賴關係開始生成 Chunk。

輸出階段

事件名 解釋
should-emit 所有需要輸出的檔案已經生成好,詢問外掛哪些檔案需要輸出,哪些不需要。
emit 確定好要輸出哪些檔案後,執行檔案輸出,可以在這裡獲取和修改輸出內容。
after-emit 檔案輸出完畢。
done 成功完成一次完成的編譯和輸出流程。
failed 如果在編譯和輸出流程中遇到異常導致 Webpack 退出時,就會直接跳轉到本步驟,外掛可以在本事件中獲取到具體的錯誤原因。

在輸出階段已經得到了各個模組經過轉換後的結果和其依賴關係,並且把相關模組組合在一起形成一個個 Chunk。 在輸出階段會根據 Chunk 的型別,使用對應的模版生成最終要要輸出的檔案內容。

輸出檔案分析

雖然在前面的章節中你學會了如何使用 Webpack ,也大致知道其工作原理,可是你想過 Webpack 輸出的 bundle.js 是什麼樣子的嗎? 為什麼原來一個個的模組檔案被合併成了一個單獨的檔案?為什麼 bundle.js 能直接執行在瀏覽器中? 本節將解釋清楚以上問題。

以上看上去複雜的程式碼其實是一個立即執行函式,可以簡寫為如下:

(function(modules) {

  // 模擬 require 語句
  function __webpack_require__() {
  }

  // 執行存放所有模組陣列中的第0個模組
  __webpack_require__(0);

})([/*存放所有模組的陣列*/])
複製程式碼

bundle.js 能直接執行在瀏覽器中的原因在於輸出的檔案中通過 __webpack_require__ 函式定義了一個可以在瀏覽器中執行的載入函式來模擬 Node.js 中的 require 語句。

原來一個個獨立的模組檔案被合併到了一個單獨的 bundle.js 的原因在於瀏覽器不能像 Node.js 那樣快速地去本地載入一個個模組檔案,而必須通過網路請求去載入還未得到的檔案。 如果模組數量很多,載入時間會很長,因此把所有模組都存放在了陣列中,執行一次網路載入。

如果仔細分析 __webpack_require__ 函式的實現,你還有發現 Webpack 做了快取優化: 執行載入過的模組不會再執行第二次,執行結果會快取在記憶體中,當某個模組第二次被訪問時會直接去記憶體中讀取被快取的返回值。

分割程式碼時的輸出

例如把原始碼中的 main.js 修改為如下:

// 非同步載入 show.js
import('./show').then((show) => {
  // 執行 show 函式
  show('Webpack');
});
複製程式碼

重新構建後會輸出兩個檔案,分別是執行入口檔案 bundle.js 和 非同步載入檔案 0.bundle.js

其中 0.bundle.js 內容如下:

// 載入在本檔案(0.bundle.js)中包含的模組
webpackJsonp(
  // 在其它檔案中存放著的模組的 ID
  [0],
  // 本檔案所包含的模組
  [
    // show.js 所對應的模組
    (function (module, exports) {
      function show(content) {
        window.document.getElementById('app').innerText = 'Hello,' + content;
      }

      module.exports = show;
    })
  ]
);
複製程式碼

bundle.js 內容如下:

這裡的 bundle.js 和上面所講的 bundle.js 非常相似,區別在於:

  • 多了一個 __webpack_require__.e 用於載入被分割出去的,需要非同步載入的 Chunk 對應的檔案;
  • 多了一個 webpackJsonp 函式用於從非同步載入的檔案中安裝模組。

在使用了 CommonsChunkPlugin 去提取公共程式碼時輸出的檔案和使用了非同步載入時輸出的檔案是一樣的,都會有 __webpack_require__.ewebpackJsonp。 原因在於提取公共程式碼和非同步載入本質上都是程式碼分割。

編寫 Loader

Loader 就像是一個翻譯員,能把原始檔經過轉化後輸出新的結果,並且一個檔案還可以鏈式的經過多個翻譯員翻譯。

以處理 SCSS 檔案為例:

  • SCSS 原始碼會先交給 sass-loader 把 SCSS 轉換成 CSS;
  • sass-loader 輸出的 CSS 交給 css-loader 處理,找出 CSS 中依賴的資源、壓縮 CSS 等;
  • css-loader 輸出的 CSS 交給 style-loader 處理,轉換成通過指令碼載入的 JavaScript 程式碼;

可以看出以上的處理過程需要有順序的鏈式執行,先 sass-loadercss-loaderstyle-loader。 以上處理的 Webpack 相關配置如下:

Loader 的職責

由上面的例子可以看出:一個 Loader 的職責是單一的,只需要完成一種轉換。 如果一個原始檔需要經歷多步轉換才能正常使用,就通過多個 Loader 去轉換。 在呼叫多個 Loader 去轉換一個檔案時,每個 Loader 會鏈式的順序執行, 第一個 Loader 將會拿到需處理的原內容,上一個 Loader 處理後的結果會傳給下一個接著處理,最後的 Loader 將處理後的最終結果返回給 Webpack。

所以,在你開發一個 Loader 時,請保持其職責的單一性,你只需關心輸入和輸出。

Loader 基礎

由於 Webpack 是執行在 Node.js 之上的,一個 Loader 其實就是一個 Node.js 模組,這個模組需要匯出一個函式。 這個匯出的函式的工作就是獲得處理前的原內容,對原內容執行處理後,返回處理後的內容。

一個最簡單的 Loader 的原始碼如下:

module.exports = function(source) {
  // source 為 compiler 傳遞給 Loader 的一個檔案的原內容
  // 該函式需要返回處理後的內容,這裡簡單起見,直接把原內容返回了,相當於該 Loader 沒有做任何轉換
  return source;
};
複製程式碼

由於 Loader 執行在 Node.js 中,你可以呼叫任何 Node.js 自帶的 API,或者安裝第三方模組進行呼叫:

const sass = require('node-sass');
module.exports = function(source) {
  return sass(source);
};
複製程式碼

Loader 進階

以上只是個最簡單的 Loader,Webpack 還提供一些 API 供 Loader 呼叫,下面來一一介紹。

獲得 Loader 的 options

在最上面處理 SCSS 檔案的 Webpack 配置中,給 css-loader 傳了 options 引數,以控制 css-loader。 如何在自己編寫的 Loader 中獲取到使用者傳入的 options 呢?需要這樣做:

const loaderUtils = require('loader-utils');
module.exports = function(source) {
  // 獲取到使用者給當前 Loader 傳入的 options
  const options = loaderUtils.getOptions(this);
  return source;
};
複製程式碼

返回其它結果

上面的 Loader 都只是返回了原內容轉換後的內容,但有些場景下還需要返回除了內容之外的東西。

例如以用 babel-loader 轉換 ES6 程式碼為例,它還需要輸出轉換後的 ES5 程式碼對應的 Source Map,以方便除錯原始碼。 為了把 Source Map 也一起隨著 ES5 程式碼返回給 Webpack,可以這樣寫:

module.exports = function(source) {
  // 通過 this.callback 告訴 Webpack 返回的結果
  this.callback(null, source, sourceMaps);
  // 當你使用 this.callback 返回內容時,該 Loader 必須返回 undefined,
  // 以讓 Webpack 知道該 Loader 返回的結果在 this.callback 中,而不是 returnreturn;
};
複製程式碼

其中的 this.callback 是 Webpack 給 Loader 注入的 API,以方便 Loader 和 Webpack 之間通訊。 this.callback 的詳細使用方法如下:

this.callback(
    // 當無法轉換原內容時,給 Webpack 返回一個 Error
    err: Error | null,
    // 原內容轉換後的內容
    content: string | Buffer,
    // 用於把轉換後的內容得出原內容的 Source Map,方便除錯
    sourceMap?: SourceMap,
    // 如果本次轉換為原內容生成了 AST 語法樹,可以把這個 AST 返回,
    // 以方便之後需要 AST 的 Loader 複用該 AST,以避免重複生成 AST,提升效能
    abstractSyntaxTree?: AST
);
複製程式碼

Source Map 的生成很耗時,通常在開發環境下才會生成 Source Map,其它環境下不用生成,以加速構建。 為此 Webpack 為 Loader 提供了 this.sourceMap API 去告訴 Loader 當前構建環境下使用者是否需要 Source Map。 如果你編寫的 Loader 會生成 Source Map,請考慮到這點。

同步與非同步

Loader 有同步和非同步之分,上面介紹的 Loader 都是同步的 Loader,因為它們的轉換流程都是同步的,轉換完成後再返回結果。 但在有些場景下轉換的步驟只能是非同步完成的,例如你需要通過網路請求才能得出結果,如果採用同步的方式網路請求就會阻塞整個構建,導致構建非常緩慢。

在轉換步驟是非同步時,你可以這樣:

module.exports = function(source) {
    // 告訴 Webpack 本次轉換是非同步的,Loader 會在 callback 中回撥結果
    var callback = this.async();
    someAsyncOperation(source, function(err, result, sourceMaps, ast) {
        // 通過 callback 返回非同步執行後的結果
        callback(err, result, sourceMaps, ast);
    });
};
複製程式碼

處理二進位制資料

在預設的情況下,Webpack 傳給 Loader 的原內容都是 UTF-8 格式編碼的字串。 但有些場景下 Loader 不是處理文字檔案,而是處理二進位制檔案,例如 file-loader,就需要 Webpack 給 Loader 傳入二進位制格式的資料。 為此,你需要這樣編寫 Loader:

module.exports = function(source) {
    // 在 exports.raw === true 時,Webpack 傳給 Loader 的 source 是 Buffer 型別的
    source instanceof Buffer === true;
    // Loader 返回的型別也可以是 Buffer 型別的
    // 在 exports.raw !== true 時,Loader 也可以返回 Buffer 型別的結果
    return source;
};
// 通過 exports.raw 屬性告訴 Webpack 該 Loader 是否需要二進位制資料 
module.exports.raw = true;
複製程式碼

以上程式碼中最關鍵的程式碼是最後一行 module.exports.raw = true;,沒有該行 Loader 只能拿到字串。

快取加速

在有些情況下,有些轉換操作需要大量計算非常耗時,如果每次構建都重新執行重複的轉換操作,構建將會變得非常緩慢。 為此,Webpack 會預設快取所有 Loader 的處理結果,也就是說在需要被處理的檔案或者其依賴的檔案沒有發生變化時, 是不會重新呼叫對應的 Loader 去執行轉換操作的。

如果你想讓 Webpack 不快取該 Loader 的處理結果,可以這樣:

module.exports = function(source) {
  // 關閉該 Loader 的快取功能
  this.cacheable(false);
  return source;
};
複製程式碼

其它 Loader API

除了以上提到的在 Loader 中能呼叫的 Webpack API 外,還存在以下常用 API:

  • this.context:當前處理檔案的所在目錄,假如當前 Loader 處理的檔案是 /src/main.js,則 this.context 就等於 /src
  • this.resource:當前處理檔案的完整請求路徑,包括 querystring,例如 /src/main.js?name=1
  • this.resourcePath:當前處理檔案的路徑,例如 /src/main.js
  • this.resourceQuery:當前處理檔案的 querystring
  • this.target:等於 Webpack 配置中的 Target。
  • this.loadModule:但 Loader 在處理一個檔案時,如果依賴其它檔案的處理結果才能得出當前檔案的結果時, 就可以通過 this.loadModule(request: string, callback: function(err, source, sourceMap, module)) 去獲得 request 對應檔案的處理結果。
  • this.resolve:像 require 語句一樣獲得指定檔案的完整路徑,使用方法為 resolve(context: string, request: string, callback: function(err, result: string))
  • this.addDependency:給當前處理檔案新增其依賴的檔案,以便再其依賴的檔案發生變化時,會重新呼叫 Loader 處理該檔案。使用方法為 addDependency(file: string)
  • this.addContextDependency:和 addDependency 類似,但 addContextDependency 是把整個目錄加入到當前正在處理檔案的依賴中。使用方法為 addContextDependency(directory: string)
  • this.clearDependencies:清除當前正在處理檔案的所有依賴,使用方法為 clearDependencies()
  • this.emitFile:輸出一個檔案,使用方法為 emitFile(name: string, content: Buffer|string, sourceMap: {...})

載入本地 Loader

在開發 Loader 的過程中,為了測試編寫的 Loader 是否能正常工作,需要把它配置到 Webpack 中後,才可能會呼叫該 Loader。 在前面的章節中,使用的 Loader 都是通過 Npm 安裝的,要使用 Loader 時會直接使用 Loader 的名稱,程式碼如下:

module.exports = {
  module: {
    rules: [
      {
        test: /\.css/,
        use: ['style-loader'],
      },
    ]
  },
};
複製程式碼

如果還採取以上的方法去使用本地開發的 Loader 將會很麻煩,因為你需要確保編寫的 Loader 的原始碼是在 node_modules目錄下。 為此你需要先把編寫的 Loader 釋出到 Npm 倉庫後再安裝到本地專案使用。

解決以上問題的便捷方法有兩種,分別如下:

Npm link

Npm link 專門用於開發和除錯本地 Npm 模組,能做到在不釋出模組的情況下,把本地的一個正在開發的模組的原始碼連結到專案的 node_modules 目錄下,讓專案可以直接使用本地的 Npm 模組。 由於是通過軟連結的方式實現的,編輯了本地的 Npm 模組程式碼,在專案中也能使用到編輯後的程式碼。

完成 Npm link 的步驟如下:

  • 確保正在開發的本地 Npm 模組(也就是正在開發的 Loader)的 package.json 已經正確配置好;
  • 在本地 Npm 模組根目錄下執行 npm link,把本地模組註冊到全域性;
  • 在專案根目錄下執行 npm link loader-name,把第2步註冊到全域性的本地 Npm 模組連結到專案的 node_moduels下,其中的 loader-name 是指在第1步中的 package.json 檔案中配置的模組名稱。

連結好 Loader 到專案後你就可以像使用一個真正的 Npm 模組一樣使用本地的 Loader 了。

ResolveLoader

ResolveLoader 用於配置 Webpack 如何尋找 Loader。 預設情況下只會去 node_modules 目錄下尋找,為了讓 Webpack 載入放在本地專案中的 Loader 需要修改 resolveLoader.modules

假如本地的 Loader 在專案目錄中的 ./loaders/loader-name 中,則需要如下配置:

module.exports = {
  resolveLoader:{
    // 去哪些目錄下尋找 Loader,有先後順序之分
    modules: ['node_modules','./loaders/'],
  }
}
複製程式碼

加上以上配置後, Webpack 會先去 node_modules 專案下尋找 Loader,如果找不到,會再去 ./loaders/目錄下尋找。

實戰

上面講了許多理論,接下來從實際出發,來編寫一個解決實際問題的 Loader。

該 Loader 名叫 comment-require-loader,作用是把 JavaScript 程式碼中的註釋語法:

// @require '../style/index.css'
複製程式碼

轉換成:

require('../style/index.css');
複製程式碼

該 Loader 的使用場景是去正確載入針對 Fis3 編寫的 JavaScript,這些 JavaScript 中存在通過註釋的方式載入依賴的 CSS 檔案。

該 Loader 的使用方法如下:

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['comment-require-loader'],
        // 針對採用了 fis3 CSS 匯入語法的 JavaScript 檔案通過 comment-require-loader 去轉換 
        include: [path.resolve(__dirname, 'node_modules/imui')]
      }
    ]
  }
};
複製程式碼

該 Loader 的實現非常簡單,完整程式碼如下:

function replace(source) {
    // 使用正則把 // @require '../style/index.css' 轉換成 require('../style/index.css');  
    return source.replace(/(\/\/ *@require) +(('|").+('|")).*/, 'require($2);');
}

module.exports = function (content) {
    return replace(content);
};
複製程式碼

編寫 Plugin

Webpack 通過 Plugin 機制讓其更加靈活,以適應各種應用場景。 在 Webpack 執行的生命週期中會廣播出許多事件,Plugin 可以監聽這些事件,在合適的時機通過 Webpack 提供的 API 改變輸出結果。

一個最基礎的 Plugin 的程式碼是這樣的:

class BasicPlugin{
  // 在建構函式中獲取使用者給該外掛傳入的配置
  constructor(options){
  }

  // Webpack 會呼叫 BasicPlugin 例項的 apply 方法給外掛例項傳入 compiler 物件
  apply(compiler){
    compiler.plugin('compilation',function(compilation) {
    })
  }
}

// 匯出 Plugin
module.exports = BasicPlugin;
複製程式碼

在使用這個 Plugin 時,相關配置程式碼如下:

const BasicPlugin = require('./BasicPlugin.js');
module.export = {
  plugins:[
    new BasicPlugin(options),
  ]
}
複製程式碼

Webpack 啟動後,在讀取配置的過程中會先執行 new BasicPlugin(options) 初始化一個 BasicPlugin 獲得其例項。 在初始化 compiler 物件後,再呼叫 basicPlugin.apply(compiler) 給外掛例項傳入 compiler 物件。 外掛例項在獲取到 compiler 物件後,就可以通過 compiler.plugin(事件名稱, 回撥函式) 監聽到 Webpack 廣播出來的事件。 並且可以通過 compiler 物件去操作 Webpack。

通過以上最簡單的 Plugin 相信你大概明白了 Plugin 的工作原理,但實際開發中還有很多細節需要注意,下面來詳細介紹。

CompilerCompilation

在開發 Plugin 時最常用的兩個物件就是 Compiler 和 Compilation,它們是 Plugin 和 Webpack 之間的橋樑。 Compiler 和 Compilation 的含義如下:

  • Compiler 物件包含了 Webpack 環境所有的的配置資訊,包含 optionsloadersplugins 這些資訊,這個物件在 Webpack 啟動時候被例項化,它是全域性唯一的,可以簡單地把它理解為 Webpack 例項;
  • Compilation 物件包含了當前的模組資源、編譯生成資源、變化的檔案等。當 Webpack 以開發模式執行時,每當檢測到一個檔案變化,一次新的 Compilation 將被建立。Compilation 物件也提供了很多事件回撥供外掛做擴充套件。通過 Compilation 也能讀取到 Compiler 物件。

Compiler 和 Compilation 的區別在於:Compiler 代表了整個 Webpack 從啟動到關閉的生命週期,而 Compilation 只是代表了一次新的編譯。

事件流

Webpack 就像一條生產線,要經過一系列處理流程後才能將原始檔轉換成輸出結果。 這條生產線上的每個處理流程的職責都是單一的,多個流程之間有存在依賴關係,只有完成當前處理後才能交給下一個流程去處理。 外掛就像是一個插入到生產線中的一個功能,在特定的時機對生產線上的資源做處理。

Webpack 通過 Tapable 來組織這條複雜的生產線。 Webpack 在執行過程中會廣播事件,外掛只需要監聽它所關心的事件,就能加入到這條生產線中,去改變生產線的運作。 Webpack 的事件流機制保證了外掛的有序性,使得整個系統擴充套件性很好。

Webpack 的事件流機制應用了觀察者模式,和 Node.js 中的 EventEmitter 非常相似。Compiler 和 Compilation 都繼承自 Tapable,可以直接在 Compiler 和 Compilation 物件上廣播和監聽事件,方法如下:

/**
* 廣播出事件
* event-name 為事件名稱,注意不要和現有的事件重名
* params 為附帶的引數
*/
compiler.apply('event-name',params);

/**
* 監聽名稱為 event-name 的事件,當 event-name 事件發生時,函式就會被執行。
* 同時函式中的 params 引數為廣播事件時附帶的引數。
*/
compiler.plugin('event-name',function(params) {

});
複製程式碼

同理,compilation.applycompilation.plugin 使用方法和上面一致。

在開發外掛時,你可能會不知道該如何下手,因為你不知道該監聽哪個事件才能完成任務。

在開發外掛時,還需要注意以下兩點:

  • 只要能拿到 Compiler 或 Compilation 物件,就能廣播出新的事件,所以在新開發的外掛中也能廣播出事件,給其它外掛監聽使用。
  • 傳給每個外掛的 Compiler 和 Compilation 物件都是同一個引用。也就是說在一個外掛中修改了 Compiler 或 Compilation 物件上的屬性,會影響到後面的外掛。
  • 有些事件是非同步的,這些非同步的事件會附帶兩個引數,第二個引數為回撥函式,在外掛處理完任務時需要呼叫回撥函式通知 Webpack,才會進入下一處理流程。例如:
 compiler.plugin('emit',function(compilation, callback) {
    // 支援處理邏輯

    // 處理完畢後執行 callback 以通知 Webpack 
    // 如果不執行 callback,執行流程將會一直卡在這不往下執行 
    callback();
  });
複製程式碼

常用 API

外掛可以用來修改輸出檔案、增加輸出檔案、甚至可以提升 Webpack 效能、等等,總之外掛通過呼叫 Webpack 提供的 API 能完成很多事情。 由於 Webpack 提供的 API 非常多,有很多 API 很少用的上,又加上篇幅有限,下面來介紹一些常用的 API。

讀取輸出資源、程式碼塊、模組及其依賴

有些外掛可能需要讀取 Webpack 的處理結果,例如輸出資源、程式碼塊、模組及其依賴,以便做下一步處理。

emit 事件發生時,代表原始檔的轉換和組裝已經完成,在這裡可以讀取到最終將輸出的資源、程式碼塊、模組及其依賴,並且可以修改輸出資源的內容。 外掛程式碼如下:

See the Pen emit點選預覽 by whjin (@whjin點選預覽) on CodePen.

預設情況下 Webpack 只會監視入口和其依賴的模組是否發生變化,在有些情況下專案可能需要引入新的檔案,例如引入一個 HTML 檔案。 由於 JavaScript 檔案不會去匯入 HTML 檔案,Webpack 就不會監聽 HTML 檔案的變化,編輯 HTML 檔案時就不會重新觸發新的 Compilation。 為了監聽 HTML 檔案的變化,我們需要把 HTML 檔案加入到依賴列表中,為此可以使用如下程式碼:

compiler.plugin('after-compile', (compilation, callback) => {
  // 把 HTML 檔案新增到檔案依賴列表,好讓 Webpack 去監聽 HTML 模組檔案,在 HTML 模版檔案發生變化時重新啟動一次編譯
    compilation.fileDependencies.push(filePath);
    callback();
});
複製程式碼

修改輸出資源

有些場景下外掛需要修改、增加、刪除輸出的資源,要做到這點需要監聽 emit 事件,因為發生 emit 事件時所有模組的轉換和程式碼塊對應的檔案已經生成好, 需要輸出的資源即將輸出,因此 emit 事件是修改 Webpack 輸出資源的最後時機。

所有需要輸出的資源會存放在 compilation.assets 中,compilation.assets 是一個鍵值對,鍵為需要輸出的檔名稱,值為檔案對應的內容。

設定 compilation.assets 的程式碼如下:

compiler.plugin('emit', (compilation, callback) => {
  // 設定名稱為 fileName 的輸出資源
  compilation.assets[fileName] = {
    // 返回檔案內容
    source: () => {
      // fileContent 既可以是代表文字檔案的字串,也可以是代表二進位制檔案的 Buffer
      return fileContent;
      },
    // 返回檔案大小
      size: () => {
      return Buffer.byteLength(fileContent, 'utf8');
    }
  };
  callback();
});
複製程式碼

讀取 compilation.assets 的程式碼如下:

compiler.plugin('emit', (compilation, callback) => {
  // 讀取名稱為 fileName 的輸出資源
  const asset = compilation.assets[fileName];
  // 獲取輸出資源的內容
  asset.source();
  // 獲取輸出資源的檔案大小
  asset.size();
  callback();
});
複製程式碼

判斷 Webpack 使用了哪些外掛

在開發一個外掛時可能需要根據當前配置是否使用了其它某個外掛而做下一步決定,因此需要讀取 Webpack 當前的外掛配置情況。 以判斷當前是否使用了 ExtractTextPlugin 為例,可以使用如下程式碼:

// 判斷當前配置使用使用了 ExtractTextPlugin,
// compiler 引數即為 Webpack 在 apply(compiler) 中傳入的引數
function hasExtractTextPlugin(compiler) {
  // 當前配置所有使用的外掛列表
  const plugins = compiler.options.plugins;
  // 去 plugins 中尋找有沒有 ExtractTextPlugin 的例項
  return plugins.find(plugin=>plugin.__proto__.constructor === ExtractTextPlugin) != null;
}
複製程式碼

實戰

下面我們舉一個實際的例子,帶你一步步去實現一個外掛。

該外掛的名稱取名叫 EndWebpackPlugin,作用是在 Webpack 即將退出時再附加一些額外的操作,例如在 Webpack 成功編譯和輸出了檔案後執行釋出操作把輸出的檔案上傳到伺服器。 同時該外掛還能區分 Webpack 構建是否執行成功。使用該外掛時方法如下:

module.exports = {
  plugins:[
    // 在初始化 EndWebpackPlugin 時傳入了兩個引數,分別是在成功時的回撥函式和失敗時的回撥函式;
    new EndWebpackPlugin(() => {
      // Webpack 構建成功,並且檔案輸出了後會執行到這裡,在這裡可以做釋出檔案操作
    }, (err) => {
      // Webpack 構建失敗,err 是導致錯誤的原因
      console.error(err);        
    })
  ]
}
複製程式碼

要實現該外掛,需要藉助兩個事件:

  • done:在成功構建並且輸出了檔案後,Webpack 即將退出時發生;
  • failed:在構建出現異常導致構建失敗,Webpack 即將退出時發生;

實現該外掛非常簡單,完整程式碼如下:

class EndWebpackPlugin {

  constructor(doneCallback, failCallback) {
    // 存下在建構函式中傳入的回撥函式
    this.doneCallback = doneCallback;
    this.failCallback = failCallback;
  }

  apply(compiler) {
    compiler.plugin('done', (stats) => {
        // 在 done 事件中回撥 doneCallback
        this.doneCallback(stats);
    });
    compiler.plugin('failed', (err) => {
        // 在 failed 事件中回撥 failCallback
        this.failCallback(err);
    });
  }
}
// 匯出外掛 
module.exports = EndWebpackPlugin;
複製程式碼

從開發這個外掛可以看出,找到合適的事件點去完成功能在開發外掛時顯得尤為重要。 在 工作原理概括 中詳細介紹過 Webpack 在執行過程中廣播出常用事件,你可以從中找到你需要的事件。

除錯 Webpack

在編寫 Webpack 的 Plugin 和 Loader 時,可能執行結果會和你預期的不一樣,就和你平時寫程式碼遇到了奇怪的 Bug 一樣。 對於無法一眼看出問題的 Bug,通常需要除錯程式原始碼才能找出問題所在。

雖然可以通過 console.log 的方式完成除錯,但這種方法非常不方便也不優雅,本節將教你如何斷點除錯 工作原理概括 中的外掛程式碼。 由於 Webpack 執行在 Node.js 之上,除錯 Webpack 就相對於除錯 Node.js 程式。

在 Webstorm 中除錯

Webstorm 整合了 Node.js 的除錯工具,因此使用 Webstorm 除錯 Webpack 非常簡單。

1. 設定斷點

在你認為可能出現問題的地方設下斷點,點選編輯區程式碼左側出現紅點表示設定了斷點。

2. 配置執行入口

告訴 Webstorm 如何啟動 Webpack,由於 Webpack 實際上就是一個 Node.js 應用,因此需要新建一個 Node.js 型別的執行入口。

以上配置中有三點需要注意:

  • Name 設定成了 debug webpack,就像設定了一個別名,方便記憶和區分;
  • Working directory 設定為需要除錯的外掛所在的專案的根目錄;
  • JavaScript file 即 Node.js 的執行入口檔案,設定為 Webpack 的執行入口檔案 node_modules/webpack/bin/webpack.js

3. 啟動除錯

經過以上兩步,準備工作已經完成,下面啟動除錯,啟動時選中前面設定的 debug webpack

4. 執行到斷點

啟動後程式就會停在斷點所在的位置,在這裡你可以方便的檢視變數當前的狀態,找出問題。

原理總結

Webpack 是一個龐大的 Node.js 應用,如果你閱讀過它的原始碼,你會發現實現一個完整的 Webpack 需要編寫非常多的程式碼。 但你無需瞭解所有的細節,只需瞭解其整體架構和部分細節即可。

對 Webpack 的使用者來說,它是一個簡單強大的工具; 對 Webpack 的開發者來說,它是一個擴充套件性的高系統。

Webpack 之所以能成功,在於它把複雜的實現隱藏了起來,給使用者暴露出的只是一個簡單的工具,讓使用者能快速達成目的。 同時整體架構設計合理,擴充套件性高,開發擴充套件難度不高,通過社群補足了大量缺失的功能,讓 Webpack 幾乎能勝任任何場景。

相關文章