webpack-tapable-0.2.8 原始碼分析

jizhi發表於2019-03-02

webpack 是基於事件流的打包構建工具,也就是內建了很多 hooks。作為使用方,可以在這些鉤子當中,去插入自己的處理邏輯,而這一切的實現都得益於 tapable 這個工具。它有多個版本,webpack 前期的版本是依賴於 tapable 0.2.8 這個版本,後來重構了,發了 2.0.0 beta 版本,因為原始碼都是通過字串拼接,通過 new Function 的模式使用,所以看起來比較晦澀。

那麼既然如此,我們先從早期的 0.2.8 這個版本瞭解下它的前身,畢竟核心思想不會發生太大的變化。

tapable 的實現類似於 node 的 EventEmitter 的釋出訂閱模式。用一個物件的鍵儲存對應的事件名稱,鍵值來儲存事件的處理函式,類似於下面:

function Tapable () {
  this._plugins = {
    `emit`: [handler1, handler2, ......]
  }
}
複製程式碼

同時,原型上定義了不同的方法來呼叫 handlers。

我們先來看下用法,

  1. pluginapplyPlugins

    void plugin(names: string|string[], handler: Function)
    void applyPlugins(name: string, args: any...)
    複製程式碼

    最基礎的就是註冊外掛以及外掛觸發的回撥函式。

    const Tapable = require(`tapable`)
    const t = new Tapable()
    
    // 註冊外掛
    t.plugin(`emit`, (...args) => {
      console.log(args)
      console.log(`This is a emit handler`)
    })
    
    // 呼叫外掛
    t.applyPlugins(`emit`, `引數1`)
    
    // 列印如下
    [ `引數1` ]
    This is a emit handler
    複製程式碼

    原始碼如下

    Tapable.prototype.applyPlugins = function applyPlugins(name) {
      if(!this._plugins[name]) return;
      var args = Array.prototype.slice.call(arguments, 1);
      var plugins = this._plugins[name];
      for(var i = 0; i < plugins.length; i++)
        plugins[i].apply(this, args);
    };
    
    Tapable.prototype.plugin = function plugin(name, fn) {
      if(Array.isArray(name)) {
        name.forEach(function(name) {
          this.plugin(name, fn);
        }, this);
        return;
      }
      if(!this._plugins[name]) this._plugins[name] = [fn];
      else this._plugins[name].push(fn);
    };
    複製程式碼

    很簡單,內部維護 _plugins 屬性來快取 plugin 名稱以及 handler。

  2. apply

    void apply(plugins: Plugin...)
    複製程式碼

    接收 plugin 作為引數,每個 plugin 必須提供 apply 方法,也就是 webpack 在編寫 plugin 的規是外掛例項必須提供 apply 方法。

    const Tapable = require(`tapable`)
    const t = new Tapable()
    
    // 宣告一個 webpack 外掛的類,物件必須宣告 apply 方法
    class WebpackPlugin {
      constructor () {}
      apply () {
        console.log(`This is webpackPlugin`)
      }
    }
    
    const plugin = new WebpackPlugin()
    
    // tapable.apply
    t.apply(plugin) // print `This is webpackPlugin`
    複製程式碼

    原始碼如下

    Tapable.prototype.apply = function apply() {
      for(var i = 0; i < arguments.length; i++) {
        arguments[i].apply(this);
      }
    };
    複製程式碼

    也很簡單,依次執行每個外掛的 apply 方法。

  3. applyPluginsWaterfall

    any applyPluginsWaterfall(name: string, init: any, args: any...)
    複製程式碼

    依次呼叫外掛對應的 handler,傳入的引數是上一個 handler 的返回值,以及呼叫 applyPluginsWaterfall 傳入 args 引數組成的陣列,說起來很繞,看看下面的例子:

    t.plugin(`waterfall`, (...args) => {
      // print [`init`, `args1`]
      console.log(args)
      return `result1`
    })
    
    t.plugin(`waterfall`, (...args) => {
      // print [`result1`, `args1`]
      console.log(args)
      return `result2`
    })
    
    const ret = t.applyPluginsWaterfall(`waterfall`, `init`, `args1`) // ret => `result2`
    複製程式碼

    原始碼如下

    Tapable.prototype.applyPluginsWaterfall = function applyPluginsWaterfall(name, init) {
      if(!this._plugins[name]) return init;
      var args = Array.prototype.slice.call(arguments, 1);
      var plugins = this._plugins[name];
      var current = init;
      for(var i = 0; i < plugins.length; i++) {
        args[0] = current;
        current = plugins[i].apply(this, args);
      }
      return current;
    };
    複製程式碼

    上一個 handler 返回的值,會作為下一個 handler的第一個引數。

  4. applyPluginsBailResult

    any applyPluginsBailResult(name: string, args: any...)
    複製程式碼

    依次呼叫外掛對應的 handler,傳入的引數是 args,如果正執行的 handler 的 返回值不是 undefined,其餘的 handler 都不會執行了。 bail 是保險的意思,即只要任意一個 handler 有 !== undefined 的返回值,那麼函式的執行就終止了。

    t.plugin(`bailResult`, (...args) => {
      // [ `引數1`, `引數2` ]
      console.log(args)
      return `result1`
    })
    
    t.plugin(`bailResult`, (...args) => {
      // 因為上一個函式返回了 `result1`,所以不會執行到這個handler
      console.log(args)
      return `result2`
    })
    
    t.applyPluginsBailResult(`bailResult`, `引數1`, `引數2`)
    複製程式碼

    原始碼如下

    Tapable.prototype.applyPluginsBailResult = function applyPluginsBailResult(name, init) {
      if(!this._plugins[name]) return;
      var args = Array.prototype.slice.call(arguments, 1);
      var plugins = this._plugins[name];
      for(var i = 0; i < plugins.length; i++) {
        var result = plugins[i].apply(this, args);
        if(typeof result !== "undefined") {
          return result;
        }
      }
    };
    複製程式碼

    只要 handler 返回的值 !== undefined,就會停止呼叫接下來的 handler。

  5. applyPluginsAsyncSeries & applyPluginsAsync(支援非同步)

    void applyPluginsAsync(
      name: string,
      args: any...,
      callback: (err?: Error) -> void
    )
    複製程式碼

    applyPluginsAsyncSeries 與 applyPluginsAsync 的函式引用都是相同的,並且函式內部支援非同步。callback 在所有 handler 都執行完了才會呼叫,但是在註冊 handler 的時候,函式內部一定要執行 next() 的邏輯,這樣才能執行到下一個 handler。

    t.plugin(`asyncSeries`, (...args) => {
      // handler 的最後一個引數一定是 next 函式
      const next = args.pop()
      // 執行 next,函式才會執行到下面的 handler
      setTimeout (() => {
        next()
      }, 3000)
    })
    
    t.plugin(`asyncSeries`, (...args) => {
      // handler 的最後一個引數一定是 next
      const callback = args.pop()
      // 執行 next,函式才會執行到 applyPluginsAsyncSeries 傳入的 callback
      Promise.resolve(1).then(next)
    })
    
    t.applyPluginsAsyncSeries(`asyncSeries`, `引數1`, (...args) => {
      console.log(`這是 applyPluginsAsyncSeries 的 callback`)
    })
    複製程式碼

    原始碼如下

    Tapable.prototype.applyPluginsAsyncSeries = Tapable.prototype.applyPluginsAsync = function applyPluginsAsyncSeries(name) {
      var args = Array.prototype.slice.call(arguments, 1);
      var callback = args.pop();
      var plugins = this._plugins[name];
      if(!plugins || plugins.length === 0) return callback();
      var i = 0;
      var _this = this;
      args.push(copyProperties(callback, function next(err) {
        if(err) return callback(err);
        i++;
        if(i >= plugins.length) {
          return callback();
        }
        plugins[i].apply(_this, args);
      }));
      plugins[0].apply(this, args);
    };
    複製程式碼

    applyPluginsAsyncSeries 內部維護了一個 next 函式,這個函式作為每個 handler 的最後一個引數傳入,handler 內部支援非同步操作,但是必須手動呼叫 next 函式,才能執行到下一個 handler。

  6. applyPluginsAsyncSeriesBailResult(支援非同步)

    void applyPluginsAsyncSeriesBailResult(
      name: string,
      args: any...,
      callback: (result: any) -> void
    )
    複製程式碼

    函式支援非同步,只要在 handler 裡面呼叫 next 回撥函式,並且傳入任意引數,就會直接執行 callback。

    t.plugin(`asyncSeriesBailResult`, (...args) => {
      // handler 的最後一個引數一定是 next 函式
      const next = args.pop()
      // 因為傳了字串,導致直接執行 callback
      next(`跳過 handler 函式`)
    })
    
    t.plugin(`asyncSeriesBailResult`, (...args) => {
      
    })
    
    t.applyPluginsAsyncSeriesBailResult(`asyncSeriesBailResult`, `引數1`, (...args) => {
      console.log(`這是 applyPluginsAsyncSeriesBailResult 的 callback`)
    })
    // print `這是 applyPluginsAsyncSeriesBailResult 的 callback`
    複製程式碼

    原始碼如下

    Tapable.prototype.applyPluginsAsyncSeriesBailResult = function applyPluginsAsyncSeriesBailResult(name) {
      var args = Array.prototype.slice.call(arguments, 1);
      var callback = args.pop();
      if(!this._plugins[name] || this._plugins[name].length === 0) return callback();
      var plugins = this._plugins[name];
      var i = 0;
      var _this = this;
      args.push(copyProperties(callback, function next() {
        if(arguments.length > 0) return callback.apply(null, arguments);
        i++;
        if(i >= plugins.length) {
          return callback();
        }
        plugins[i].apply(_this, args);
      }));
      plugins[0].apply(this, args);
    };
    複製程式碼

    applyPluginsAsyncSeriesBailResult 內部維護了一個 next 函式,這個函式作為每個 handler 的最後一個引數傳入,handler 內部支援非同步操作,但是必須手動呼叫 next 函式,才能執行到下一個 handler,next 函式可以傳入引數,這樣會直接執行 callback。

  7. applyPluginsAsyncWaterfall(支援非同步)

    void applyPluginsAsyncWaterfall(
      name: string,
      init: any,
      callback: (err: Error, result: any) -> void
    )
    複製程式碼

    函式支援非同步,handler 的接收兩個引數,第一個引數是上一個 handler 通過 next 函式傳過來的 value,第二個引數是 next 函式。next 函式接收兩個引數,第一個是 error,如果 error 存在,就直接執行 callback。第二個 value 引數,是傳給下一個 handler 的引數。

    t.plugin(`asyncWaterfall`, (value, next) => {
    // handler 的最後一個引數一定是 next 函式
      console.log(value)
      next(null, `來自第一個 handler`)
    })
    
    t.plugin(`asyncWaterfall`, (value, next) => {
      console.log(value)
      next(null, `來自第二個 handler`)
    })
    
    t.applyPluginsAsyncWaterfall(`asyncWaterfall`, `引數1`, (err, value) => {
      if (!err) {
        console.log(value)
      }
    })
    
    // 列印如下
    
    引數1
    來自第一個 handler
    來自第二個 handler
    複製程式碼

    原始碼如下

    Tapable.prototype.applyPluginsAsyncWaterfall = function applyPluginsAsyncWaterfall(name, init, callback) {
      if(!this._plugins[name] || this._plugins[name].length === 0) return callback(null, init);
      var plugins = this._plugins[name];
      var i = 0;
      var _this = this;
      var next = copyProperties(callback, function(err, value) {
        if(err) return callback(err);
        i++;
        if(i >= plugins.length) {
          return callback(null, value);
        }
        plugins[i].call(_this, value, next);
      });
      plugins[0].call(this, init, next);
    };
    複製程式碼

    applyPluginsAsyncWaterfall 內部維護了一個 next 函式,這個函式作為每個 handler 的最後一個引數傳入,handler 內部支援非同步操作,但是必須手動呼叫 next 函式,才能執行到下一個 handler,next 函式可以傳入引數,第一個引數為 err, 第二引數為上一個 handler 返回值。

  8. applyPluginsParallel(支援非同步)

    void applyPluginsParallel(
      name: string,
      args: any...,
      callback: (err?: Error) -> void
    )
    複製程式碼

    並行的執行函式,每個 handler 的最後一個引數都是 next 函式,這個函式用來檢驗當前的 handler 是否已經執行完。

    t.plugin(`parallel`, (...args) => {
      const next = args.pop()
      console.log(1)
      // 必須呼叫 next 函式,要不然 applyPluginsParallel 的 callback 永遠也不會回撥
      next(`丟擲錯誤了1`, `來自第一個 handler`)
    })
    
    t.plugin(`parallel`, (...args) => {
      const next = args.pop()
      console.log(2)
      // 必須呼叫 next 函式,要不然 applyPluginsParallel 的 callback 永遠也不會回撥
      next(`丟擲錯誤了2`)
    })
    
    t.applyPluginsParallel(`parallel`, `引數1`, (err) => {
      // print `丟擲錯誤了1`
      console.log(err)
    })
    複製程式碼

    原始碼如下

    Tapable.prototype.applyPluginsParallel = function applyPluginsParallel(name) {
      var args = Array.prototype.slice.call(arguments, 1);
      var callback = args.pop();
      if(!this._plugins[name] || this._plugins[name].length === 0) return callback();
      var plugins = this._plugins[name];
      var remaining = plugins.length;
      args.push(copyProperties(callback, function(err) {
        if(remaining < 0) return; // ignore
        if(err) {
          remaining = -1;
          return callback(err);
        }
        remaining--;
        if(remaining === 0) {
          return callback();
        }
      }));
      for(var i = 0; i < plugins.length; i++) {
        plugins[i].apply(this, args);
        if(remaining < 0) return;
      }
    };
    複製程式碼

    applyPluginsParallel 並行地呼叫 handler。內部通過閉包維護了 remaining 變數,用來判斷內部的函式是否真正執行完,handler 的最後一個引數是一個函式 check。如果 handler 內部使用者想要的邏輯執行完,必須呼叫 check 函式來告訴 tapable,進而才會執行 args 陣列的最後一個 check 函式。

  9. ** applyPluginsParallelBailResult **(支援非同步)

    void applyPluginsParallelBailResult(
      name: string,
      args: any...,
      callback: (err: Error, result: any) -> void
    )
    複製程式碼

    並行的執行函式,每個 handler 的最後一個引數都是 next 函式,next 函式必須呼叫,如果給 next 函式傳參,會直接走到 callback 的邏輯。callback 執行的時機是跟 handler 註冊的順序有關,而不是跟 handler 內部呼叫 next 的時機有關。

    t.plugin(`applyPluginsParallelBailResult`, (next) => {
      console.log(1)
      setTimeout(() => {
        next(`has args 1`)
      }, 3000)
    })
    
    t.plugin(`applyPluginsParallelBailResult`, (next) => {
      console.log(2)
      setTimeout(() => {
        next(`has args 2`)
      })
    })
    
    t.plugin(`applyPluginsParallelBailResult`, (next) => {
      console.log(3)
      next(`has args 3`)
    })
    
    t.applyPluginsParallelBailResult(`applyPluginsParallelBailResult`, (result) => {
      console.log(result)
    })
    
    // 列印如下
    1
    2
    3
    has args 1
    
    雖然第一個 handler 的 next 函式是延遲 3s 才執行,但是註冊的順序是在最前面,所以 callback 的 result 引數值是 `has args 1`複製程式碼

    原始碼如下

    Tapable.prototype.applyPluginsParallelBailResult = function applyPluginsParallelBailResult(name) {
      var args = Array.prototype.slice.call(arguments, 1);
      var callback = args[args.length - 1];
      if(!this._plugins[name] || this._plugins[name].length === 0) return callback();
      var plugins = this._plugins[name];
      var currentPos = plugins.length;
      var currentResult;
      var done = [];
      for(var i = 0; i < plugins.length; i++) {
        args[args.length - 1] = (function(i) {
          return copyProperties(callback, function() {
            if(i >= currentPos) return; // ignore
            done.push(i);
            if(arguments.length > 0) {
              currentPos = i + 1;
              done = fastFilter.call(done, function(item) {
                return item <= i;
              });
              currentResult = Array.prototype.slice.call(arguments);
            }
            if(done.length === currentPos) {
              callback.apply(null, currentResult);
              currentPos = 0;
            }
          });
        }(i));
        plugins[i].apply(this, args);
      }
    };
    複製程式碼

    for 迴圈裡面並行的執行 handler,handler 的最後一個引數是一個匿名回撥函式,這個匿名函式必須在每個 handler 裡面手動的執行。而 callback 的執行時機就是根據 handler 的註冊順序有關。

從原始碼上來看,tapable 是提供了很多 API 來對應不同呼叫 handler 的場景,有同步執行,有非同步執行,還有序列非同步,並行非同步等。這些都是一些高階的技巧,不管是 express,還是 VueRouter 的原始碼,都利用這些同非同步執行機制,但是可以看出程式是有邊界的。也就是約定成俗,從最後一個 applyPluginsParallel 函式來看,使用者必須呼叫匿名回撥函式,否則 tapable 怎麼知道你內部是否有非同步操作,並且非同步操作在某個時候執行完了呢。

既然知道了 0.2.8 的核心思想,那麼 2.0.0-beta 版的重構更是讓人驚豔,目前的原始碼分析正在整理,連結如下

相關文章