1. 程式人生 > >探尋 webpack 插件機制

探尋 webpack 插件機制

require article gif analyze 圖片 spa cal 開發 assets

技術分享圖片

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 的基礎上對代碼升級了一番,項目地址。效果如下:

技術分享圖片

插件核心代碼正是基於上文提到的 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 官網

探尋 webpack 插件機制