一個為了讓console.log寫起來更偷懶的webpack-plugin

Winder發表於2019-02-04

作為一個敲碼5分鐘,除錯兩小時的bug大叔,每天和console.log打的交道自然不少,人到中年,越來越懶,於是想把console.log('bug: ', bug)變成log.bug來讓我的懶癌病發得更加徹底。於是硬著頭皮看了下webpack外掛的寫法,在此記錄一下webpack系統的學習筆記。

跟著webpack原始碼摸石過河

去吧!皮卡丘!!!

  1. webpack初始化
// webpack.config.js

const webpack = require('webpack');
const WebpackPlugin = require('webpack-plugin');

const options = {
  entry: 'index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'index.bundle.js'
  },
  module: {
    rules: []
  },
  plugins: [
    new WebpackPlugin()
  ]
  // ...
};

webpack(options);
複製程式碼
// webpack.js

// 引入Compiler類
const Compiler = require("./Compiler");

// webpack入口函式
const webpack = (options, callback) => {

  // 建立一個Compiler例項
  compiler = new Compiler(options.context);
  
  // 例項儲存webpack的配置物件
  compiler.options = options
  
  // 依次呼叫webpack外掛的apply方法,並傳入compiler引用
  for (const plugin of options.plugins) {
    plugin.apply(compiler);
  }
  
  //執行例項的run方法
  compiler.run(callback)
  
  //返回例項
  return compiler
}

module.exports = webpack
複製程式碼
  • 最開始,無論我們在控制檯輸入webpack指令還是使用Node.js的API,都是呼叫了webpack函式(原始碼),並傳入了webpack的配置選項,建立了一個 compiler 例項。
  • compiler 是什麼?—— 明顯發現compiler儲存了完整的webpack的配置引數options。所以官方說:

compiler 物件代表了完整的 webpack 環境配置。這個物件在啟動 webpack 時被一次性建立,並配置好所有可操作的設定,包括 options,loader 和 plugin。可以使用 compiler 來訪問 webpack 的主環境。

  • 所有 webpack-plugin 也在這裡通過提供一個叫 apply 的方法給webpack呼叫,以完成初始化的工作,並且接收到剛建立的 compiler 的引用。
  1. compiler、compilation與tapable
// Compiler.js

const {
  Tapable,
  SyncHook,
  SyncBailHook,
  AsyncParallelHook,
  AsyncSeriesHook
} = require("tapable");

const Compilation = require("./Compilation");

class Compiler extends Tapable {

  hooks = [
    hook1: new SyncHook(),
    hook2: new AsyncSeriesHook(),
    hook3: new AsyncParallelHook()
  ]

  run () {
    // ...
    // 觸發鉤子回撥執行
    this.hooks[hook1].call()
    this.hooks[hook2].callAsync(() => {
      this.hooks[hook4].call()
    })

    // 進入編譯流程
    this.compile()
    // ...
    this.hooks[hook3].promise(() => {})
  }

  compile () {
    // 建立一個Compilation例項
    const compilation = new Compilation(this)
  }
}
複製程式碼
  • 研究下Compiler.js這個檔案,它引入了一個 tapable 類(原始碼)的庫,這個庫提供了一些 Hook 類,內部實現了類似的 事件訂閱/釋出系統

  • 哪裡用到 Hook 類?—— 在 Compiler 類(Compiler.js原始碼) 裡擁有很多有意思的hook,這些hook代表了整個編譯過程的各個關鍵事件節點,它們都是繼承於 Hook 類 ,所以都支援 監聽/訂閱,只要我們的外掛提前做好事件訂閱,那麼編譯流程進行到該事件點時,就會執行我們提供的 事件回撥 ,做我們想做的事情了。

  • 如何進行訂閱呢?——在上文中每個webpack-plugin中都有一個apply方法。其實註冊的程式碼就藏在裡面,通過以下類似的程式碼實現。任何的webpack外掛都可以訂閱hook1,因此hook1維護了一個taps陣列,儲存著所有的callback。

    compiler.hooks.hook1.tap(name, callback) // 註冊/訂閱

    compiler.hooks.hook1.call() // 觸發/釋出

  • 準備工作做好了之後,當 run() 方法開始被呼叫時,編譯就正式開始了。在該方法裡,執行 call/callAsync/promise 這些事件時(他們由webpack內部包括一些官方使用的webpack外掛進行觸發的管理,無需開發者操心),相應的hook就會把自己的taps裡的函式均執行一遍。大概的邏輯如下所示。

    一個為了讓console.log寫起來更偷懶的webpack-plugin

  • 其中,hooks的執行是按照編譯的流程順序來的,hooks之間有彼此依賴的關係。

  • compilation 例項也在這裡建立了,它代表了一次資源版本構建。每當檢測到檔案變化,就會建立一個新的 compilation,從而生成一組新的編譯資源。一個 compilation 物件表現了當前的模組資源、編譯生成資源、變化的檔案、以及被跟蹤依賴的狀態資訊。而且compilation也和compiler類似,擁有很多hooks(Compilation.js原始碼),因此同樣提供了很多事件節點給予我們訂閱使用。

  1. webpack外掛體系的使用套路
class MyPlugin {
  apply(compiler) {
    // 設定回撥來訪問 compilation 物件:
    compiler.hooks.compilation.tap('myPlugin', (compilation) => {
      // 現在,設定回撥來訪問 compilation 中的任務點:
      compilation.hooks.optimize.tap('myPlugin', () => {
        console.log('Hello compilation!');
      });
    });
  }
}

module.exports = MyPlugin;
複製程式碼
  • 訂閱 compilercomplation 的事件節點都在webpack-plugin中的 apply 方法裡書寫,具體的演示如上。當然你想拿到編譯過程中的什麼資源,首先得要找出能提供該資源引用的對應的compiler事件節點進行訂閱(上帝先創造了亞當,再有夏娃)。每個compiler時間節點能提供什麼引數,在hook的例項化時已經做了說明(如下),更多可檢視原始碼
this.hooks = {
  // 拿到compilation和params
  compilation: new SyncHook(["compilation", "params"]),
  // 拿到stats
  done: new AsyncSeriesHook(["stats"]),
  // 拿到compiler
  beforeRun: new AsyncSeriesHook(["compiler"])
}
複製程式碼

梳理webpack流程

經過以上的初步探索,寫webpack外掛需要了解的幾個知識點應該有了大概的掌握:

  1. 外掛提供 apply 方法供 webpack 呼叫進行初始化
  2. 使用 tap 註冊方式鉤入 compilercompilation 的編譯流程
  3. 使用 webpack 提供的 api 進行資源的個性化處理。

寫外掛的套路已經知道了,現在還剩如何找出合適的鉤子,修改資源這件事。在webpack系統裡,鉤子即流程,是編譯構建工作的生命週期 。當然,想要了解所有 tapable 例項物件的鉤子的具體作用,需要探索webpack所有的內部外掛如何使用這些鉤子,做了什麼工作來進行總結,想想就複雜,所以只能抽取重要流程做思路概括,借用淘寶的一張經典圖示。![webpack_flow.jpg](file:///Users/Ando/Documents/webpack-plugin/webpack_flow.jpg) 整個編譯流程大概分成三個階段,現在重新整理一下:

  1. 準備階段 ,webpack的初始化
  • webpack依次呼叫開發者自定義的外掛的 apply 方法,讓外掛們做好事件註冊。
  • WebpackOptionsApply 接收組合了 命令列webpack.config.js 的配置引數,負責webpack內部基礎流程使用的外掛和compiler上下文環境的初始化工作。(*Plugin均為webpack內部使用的外掛)
      // webpack.js
      // 開發者自定義的外掛初始化
      if (options.plugins && Array.isArray(options.plugins)) {
        for (const plugin of options.plugins) {
          if (typeof plugin === "function") {
            plugin.call(compiler, compiler)
          } else {
            plugin.apply(compiler)
          }
        }
      }
      // ...
      // webpack內部使用的外掛初始化
      compiler.options = new WebpackOptionsApply().process(options, compiler)
    
      // WebpackOptionsApply.js
      class WebpackOptionsApply extends OptionsApply {
        process (options, compiler) {
          // ...
          new WebAssemblyModulesPlugin({
              mangleImports: options.optimization.mangleWasmImports
          }).apply(compiler);
    
          new EntryOptionPlugin().apply(compiler);
          compiler.hooks.entryOption.call(options.context, options.entry);
    
          new CompatibilityPlugin().apply(compiler);
          new HarmonyModulesPlugin(options.module).apply(compiler);
          new AMDPlugin(options.module, options.amd || {}).apply(compiler);
          // ...
        }
      }
    複製程式碼
  • 執行 run / watch(一次打包/監聽打包模式)觸發 編譯 階段
  1. 編譯階段,生成modulechunk資源。

    runcompile 編譯 → 建立 compilation 物件。compilation 的建立是一次編譯的起步,即將對所有模組載入(load)封存(seal)優化(optimiz)分塊(chunk)雜湊(hash)重新建立(restore)

      module.exports = class Compiler extends Tapable {
    
        run () {
          // 宣告編譯結束回撥
          function onCompiled () {}
          // 觸發run鉤子
          this.hooks.run.callAsync(this, err => {
            this.compile(onCompiled)
          })
        }
    
        compile(callback) {
          // ...
          // 編譯開始前,觸發beforeCompile鉤子
          this.hooks.beforeCompile.callAsync(params, err => {
            // 編譯開始,觸發compile鉤子
            this.hooks.compile.call(params);
            // 建立compilation例項
            const compilation = this.newCompilation(params);
            // 觸發make鉤子
            this.hooks.make.callAsync(compilation, err => {
              // 模組解析完畢,執行compilation的finish方法
              compilation.finish();
              // 資源封存,執行seal方法
              compilation.seal(err => {
                // 編譯結束,執行afterCompile鉤子
                this.hooks.afterCompile.callAsync(compilation, err => {
    
                  // ...
                });
              });
            });
          });
        }
      }
    複製程式碼
  • 載入模組make鉤子觸發 → DllEntryPlugin 內部外掛呼叫compilation.addEntrycompilation維護了一些資源生成工廠方法 compilation.dependencyFactories ,負責把入口檔案及其(迴圈)依賴轉換成 modulemodule 的解析過程會應用匹配的 loader )。每個 module 的解析過程提供 buildModule / succeedModule 等鉤子, 所有 module 解析完成後觸發 finishModules 鉤子。
  • 封存seal 方法包含了 優化/分塊/雜湊 , 編譯停止接收新模組,開始生成chunks。此階段依賴了一些webpack內部外掛對module進行優化,為本次構建生成的chunk加入hash等。createChunkAssets()會根據chunk型別使用不同的模板進行渲染。此階段執行完畢後就代表一次編譯完成,觸發 afterCompile鉤子
    • 優化BannerPlugin
        compilation.hooks.optimizeChunkAssets.tapAsync('MyPlugin', (chunks, callback) => {
          chunks.forEach(chunk => {
        	chunk.files.forEach(file => {
        	  compilation.assets[file] = new ConcatSource(
        	    '\/**Sweet Banner**\/',
        	    '\n',
        	    compilation.assets[file]
        	  );
        	});
          });
      
          callback();
        });
      複製程式碼
    • 分塊:用來分割chunk的 SplitChunksPlugin 外掛監聽了optimizeChunksAdvanced鉤子
    • 雜湊createHash
  1. 檔案生成階段
  • 編譯完成後,觸發 emit ,遍歷 compilation.assets 生成所有檔案。

寫一個增強console.log除錯體驗的webpack外掛 simple-log-webpack-plugin

一張效果圖先上為敬。(對照圖片)只需寫 log.a ,通過自己的webpack外掛自動補全欄位標識 a欄位: ,加入 檔案路徑 ,輕鬆支援 列印顏色 效果,相同檔案的日誌資訊 可摺疊 ,給你一個簡潔方便的除錯環境。

一個為了讓console.log寫起來更偷懶的webpack-plugin

以下為原始碼,歡迎測試反饋。github npm

const ColorHash = require('color-hash')
const colorHash = new ColorHash()

const Dependency = require('webpack/lib/Dependency');

class LogDependency extends Dependency {

  constructor(module) {
    super();
    this.module = module;
  }
}

LogDependency.Template = class {
  apply(dep, source) {

    const before = `;console.group('${source._source._name}');`
    const after = `;console.groupEnd();`
    const _size = source.size()

    source.insert(0, before)

    source.replace(_size, _size, after)

  }
};


module.exports = class LogPlugin {

  constructor (opts) {
    this.options = {
      expression: /\blog\.(\w+)\b/ig,
      ...opts
    }
    this.plugin = { name: 'LogPlugin' }
    
  }

  doLog (module) {

    if (!module._source) return
    let _val = module._source.source(),
        _name = module.resource;

    const filedColor = colorHash.hex(module._buildHash)

    // 判斷是否需要加入
    if (this.options.expression.test(_val)) {
      module.addDependency(new LogDependency(module));
    }

    _val = _val.replace(
      this.options.expression,
      `console.log('%c$1欄位:%c%o, %c%s', 'color: ${filedColor}', 'color: red', $1, 'color: pink', '${_name}')`
    )

    return _val

  }

  apply (compiler) {

    compiler.hooks.compilation.tap(this.plugin, (compilation) => {

      // 註冊自定義依賴模板
      compilation.dependencyTemplates.set(
        LogDependency,
        new LogDependency.Template()
      );

      // modlue解析完畢鉤子
      compilation.hooks.succeedModule.tap(this.plugin, module => {
        // 修改模組的程式碼
        module._source._value = this.doLog(module)
        
      })
    })

  }
}
複製程式碼

相關文章