探尋 webpack 外掛機制

牧云云發表於2018-04-18

探尋 webpack 外掛機制

webpack 可謂是讓人欣喜又讓人憂,功能強大但需要一定的學習成本。在探尋 webpack 外掛機制前,首先需要了解一件有意思的事情,webpack 外掛機制是整個 webpack 工具的骨架,而 webpack 本身也是利用這套外掛機制構建出來的。因此在深入認識 webpack 外掛機制後,再來進行專案的相關優化,想必會大有裨益。

webpack 外掛

先來瞅瞅 webpack 外掛在專案中的運用

const MyPlugin = require('myplugin')
const webpack = require('webpack')

webpack({
  ...,
  plugins: [new MyPlugin()]
  ...,
})
複製程式碼

那麼符合什麼樣的條件能作為 webpack 外掛呢?一般來說,webpack 外掛有以下特點:

  1. 獨立的 JS 模組,暴露相應的函式

  2. 函式原型上的 apply 方法會注入 compiler 物件

  3. compiler 物件上掛載了相應的 webpack 事件鉤子

  4. 事件鉤子的回撥函式裡能拿到編譯後的 compilation 物件,如果是非同步鉤子還能拿到相應的 callback

下面結合程式碼來看看:

function MyPlugin(options) {}
// 2.函式原型上的 apply 方法會注入 compiler 物件
MyPlugin.prototype.apply = function(compiler) {
  // 3.compiler 物件上掛載了相應的 webpack 事件鉤子 4.事件鉤子的回撥函式裡能拿到編譯後的 compilation 物件
  compiler.plugin('emit', (compilation, callback) => {
    ...
  })
}
// 1.獨立的 JS 模組,暴露相應的函式
module.exports = MyPlugin
複製程式碼

這樣子,webpack 外掛的基本輪廓就勾勒出來了,此時疑問點有幾點,

  1. 疑問 1:函式的原型上為什麼要定義 apply 方法?閱讀原始碼後發現原始碼中是通過 plugin.apply() 呼叫外掛的。
const webpack = (options, callback) => {
  ...
  for (const plugin of options.plugins) {
    plugin.apply(compiler);
  }
  ...
}
複製程式碼
  1. 疑問 2:compiler 物件是什麼呢?

  2. 疑問 3:compiler 物件上的事件鉤子是怎樣的?

  3. 疑問 4:事件鉤子的回撥函式裡能拿到的 compilation 物件又是什麼呢?

這些疑問也是本文的線索,讓我們一個個探索。

compiler 物件

compiler 即 webpack 的編輯器物件,在呼叫 webpack 時,會自動初始化 compiler 物件,原始碼如下:

// webpack/lib/webpack.js
const Compiler = require("./Compiler")

const webpack = (options, callback) => {
  ...
  options = new WebpackOptionsDefaulter().process(options) // 初始化 webpack 各配置引數
  let compiler = new Compiler(options.context)             // 初始化 compiler 物件,這裡 options.context 為 process.cwd()
  compiler.options = options                               // 往 compiler 新增初始化引數
  new NodeEnvironmentPlugin().apply(compiler)              // 往 compiler 新增 Node 環境相關方法
  for (const plugin of options.plugins) {
    plugin.apply(compiler);
  }
  ...
}
複製程式碼

終上,compiler 物件中包含了所有 webpack 可配置的內容,開發外掛時,我們可以從 compiler 物件中拿到所有和 webpack 主環境相關的內容。

compilation 物件

compilation 物件代表了一次單一的版本構建和生成資源。當執行 webpack 時,每當檢測到一個檔案變化,一次新的編譯將被建立,從而生成一組新的編譯資源。一個編譯物件表現了當前的模組資源、編譯生成資源、變化的檔案、以及被跟蹤依賴的狀態資訊。

結合原始碼來理解下上面這段話,首先 webpack 在每次執行時會呼叫 compiler.run() (原始碼位置),接著追蹤 onCompiled 函式傳入的 compilation 引數,可以發現 compilation 來自建構函式 Compilation。

// webpack/lib/Compiler.js
const Compilation = require("./Compilation");

newCompilation(params) {
  const compilation = new Compilation(this);
  ...
  return compilation;
}
複製程式碼

不得不提的 tapable 庫

再介紹完 compiler 物件和 compilation 物件後,不得不提的是 tapable 這個庫,這個庫暴露了所有和事件相關的 pub/sub 的方法。而且函式 Compiler 以及函式 Compilation 都繼承自 Tapable。

事件鉤子

事件鉤子其實就是類似 MVVM 框架的生命週期函式,在特定階段能做特殊的邏輯處理。瞭解一些常見的事件鉤子是寫 webpack 外掛的前置條件,下面列舉些常見的事件鉤子以及作用:

鉤子 作用 引數 型別
after-plugins 設定完一組初始化外掛之後 compiler sync
after-resolvers 設定完 resolvers 之後 compiler sync
run 在讀取記錄之前 compiler async
compile 在建立新 compilation 之前 compilationParams sync
compilation compilation 建立完成 compilation sync
emit 在生成資源並輸出到目錄之前 compilation async
after-emit 在生成資源並輸出到目錄之後 compilation async
done 完成編譯 stats sync

完整地請參閱官方文件手冊,同時瀏覽相關原始碼 也能比較清晰地看到各個事件鉤子的定義。

外掛流程淺析

拿 emit 鉤子為例,下面分析下外掛呼叫原始碼:

compiler.plugin('emit', (compilation, callback) => {
  // 在生成資源並輸出到目錄之前完成某些邏輯
})
複製程式碼

此處呼叫的 plugin 函式源自上文提到的 tapable 庫,其最終呼叫棧指向了 hook.tapAsync(),其作用類似於 EventEmitter 的 on,原始碼如下:

// Tapable.js
options => {
  ...
  if(hook !== undefined) {
    const tapOpt = {
      name: options.fn.name || "unnamed compat plugin",
      stage: options.stage || 0
    };
    if(options.async)
      hook.tapAsync(tapOpt, options.fn); // 將外掛中非同步鉤子的回撥函式注入
    else
      hook.tap(tapOpt, options.fn);
    return true;
  }
};
複製程式碼

有注入必有觸發的地方,原始碼中通過 callAsync 方法觸發之前注入的非同步事件,callAsync 類似 EventEmitter 的 emit,相關原始碼如下:

this.hooks.emit.callAsync(compilation, err => {
	if (err) return callback(err);
	outputPath = compilation.getPath(this.outputPath);
	this.outputFileSystem.mkdirp(outputPath, emitFiles);
});
複製程式碼

一些深入細節這裡就不展開了,說下關於閱讀比較大型專案的原始碼的兩點體會,

  • 要抓住一條主線索去讀,忽視細節。否則會浪費很多時間而且會有挫敗感;

  • 結合除錯工具來分析,很多點不用除錯工具的話很容易顧此失彼;

動手實現個 webpack 外掛

結合上述知識點的分析,不難寫出自己的 webpack 外掛,關鍵在於想法。為了統計專案中 webpack 各包的有效使用情況,在 fork webpack-visualizer 的基礎上對程式碼升級了一番,專案地址。效果如下:

探尋 webpack 外掛機制

外掛核心程式碼正是基於上文提到的 emit 鉤子,以及 compiler 和 compilation 物件。程式碼如下:

class AnalyzeWebpackPlugin {
  constructor(opts = { filename: 'analyze.html' }) {
    this.opts = opts
  }

  apply(compiler) {
    const self = this
    compiler.plugin("emit", function (compilation, callback) {
      let stats = compilation.getStats().toJson({ chunkModules: true }) // 獲取各個模組的狀態
      let stringifiedStats = JSON.stringify(stats)
      // 服務端渲染
      let html = `<!doctype html>
          <meta charset="UTF-8">
          <title>AnalyzeWebpackPlugin</title>
          <style>${cssString}</style>
          <div id="App"></div>
          <script>window.stats = ${stringifiedStats};</script>
          <script>${jsString}</script>
      `
      compilation.assets[`${self.opts.filename}`] = { // 生成檔案路徑
        source: () => html,
        size: () => html.length
      }
      callback()
    })
  }
}
複製程式碼

參考資料

看清楚真正的 Webpack 外掛

webpack 官網

相關文章