Webpack原理-編寫Plugin

浩麟發表於2019-03-04

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 的工作原理,但實際開發中還有很多細節需要注意,下面來詳細介紹。

Compiler 和 Compilation

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

  • Compiler 物件包含了 Webpack 環境所有的的配置資訊,包含 options,loaders,plugins 這些資訊,這個物件在 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.apply 和 compilation.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 事件發生時,代表原始檔的轉換和組裝已經完成,在這裡可以讀取到最終將輸出的資源、程式碼塊、模組及其依賴,並且可以修改輸出資源的內容。 外掛程式碼如下:

class Plugin {
  apply(compiler) {
    compiler.plugin('emit', function (compilation, callback) {
      // compilation.chunks 存放所有程式碼塊,是一個陣列
      compilation.chunks.forEach(function (chunk) {
        // chunk 代表一個程式碼塊
        // 程式碼塊由多個模組組成,通過 chunk.forEachModule 能讀取組成程式碼塊的每個模組
        chunk.forEachModule(function (module) {
          // module 代表一個模組
          // module.fileDependencies 存放當前模組的所有依賴的檔案路徑,是一個陣列
          module.fileDependencies.forEach(function (filepath) {
          });
        });

        // Webpack 會根據 Chunk 去生成輸出的檔案資源,每個 Chunk 都對應一個及其以上的輸出檔案
        // 例如在 Chunk 中包含了 CSS 模組並且使用了 ExtractTextPlugin 時,
        // 該 Chunk 就會生成 .js 和 .css 兩個檔案
        chunk.files.forEach(function (filename) {
          // compilation.assets 存放當前所有即將輸出的資源
          // 呼叫一個輸出資源的 source() 方法能獲取到輸出資源的內容
          let source = compilation.assets[filename].source();
        });
      });

      // 這是一個非同步事件,要記得呼叫 callback 通知 Webpack 本次事件監聽處理結束。
      // 如果忘記了呼叫 callback,Webpack 將一直卡在這裡而不會往後執行。
      callback();
    })
  }
}
複製程式碼

監聽檔案變化

4-5使用自動重新整理 中介紹過 Webpack 會從配置的入口模組出發,依次找出所有的依賴模組,當入口模組或者其依賴的模組發生變化時, 就會觸發一次新的 Compilation。

在開發外掛時經常需要知道是哪個檔案發生變化導致了新的 Compilation,為此可以使用如下程式碼:

// 當依賴的檔案發生變化時會觸發 watch-run 事件
compiler.plugin('watch-run', (watching, callback) => {
	// 獲取發生變化的檔案列表
	const changedFiles = watching.compiler.watchFileSystem.watcher.mtimes;
	// changedFiles 格式為鍵值對,鍵為發生變化的檔案路徑。
	if (changedFiles[filePath] !== undefined) {
	  // filePath 對應的檔案發生了變化
	}
	callback();
});
複製程式碼

預設情況下 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;
複製程式碼

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

本例項提供專案完整程式碼

Webpack原理-編寫Plugin

《深入淺出Webpack》全書線上閱讀連結

閱讀原文

相關文章