1. 程式人生 > 實用技巧 >關於 tapable 你需要知道這些

關於 tapable 你需要知道這些

基本使用

想要了解 tapable 的實現,那就必然得知道 tapable 的用法以及有哪些使用姿勢。tapable 中主要提供了同步與非同步兩種鉤子。我們先從簡單的同步鉤子開始說起。

同步鉤子

SyncHook

以最簡單的 SyncHook 為例:

const { SyncHook } = require('tapable');
const hook = new SyncHook(['name']);
hook.tap('hello', (name) => {
    console.log(`hello ${name}`);
});
hook.tap('hello again', (name) => {
    console.log(`hello ${name}, again`);
});

hook.call('ahonn');
// hello ahonn
// hello ahonn, again

可以看到當我們執行hook.call('ahonn')時會依次執行前面hook.tap(name, callback)中的回撥函式。通過SyncHook建立同步鉤子,使用tap註冊回撥,再呼叫call來觸發。這是 tapable 提供的多種鉤子中比較簡單的一種,通過 EventEmitter 也能輕鬆的實現這種效果。

此外,tapable 還提供了很多有用的同步鉤子:

  • SyncBailHook:類似於 SyncHook,執行過程中註冊的回撥返回非 undefined 時就停止不在執行。
  • SyncWaterfallHook:接受至少一個引數,上一個註冊的回撥返回值會作為下一個註冊的回撥的引數。
  • SyncLoopHook:有點類似 SyncBailHook,但是是在執行過程中回撥返回非 undefined 時繼續再次執行當前的回撥。

非同步鉤子

除了同步執行的鉤子之外,tapable 中還有一些非同步鉤子,最基本的兩個非同步鉤子分別是 AsyncParallelHook 和 AsyncSeriesHook 。其他的非同步鉤子都是在這兩個鉤子的基礎上添加了一些流程控制,類似於 SyncBailHook 之於 SyncHook 的關係。

AsyncParallelHook

AsyncParallelHook 顧名思義是並行執行的非同步鉤子,當註冊的所有非同步回撥都並行執行完畢之後再執行 callAsync 或者 promise 中的函式。

const { AsyncParallelHook } = require('tapable');
const hook = new AsyncParallelHook(['name']);

console.time('cost');

hook.tapAsync('hello', (name, cb) => {
  setTimeout(() => {
    console.log(`hello ${name}`);
    cb();
  }, 2000);
});
hook.tapPromise('hello again', (name) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(`hello ${name}, again`);
      resolve();
    }, 1000);
  });
});

hook.callAsync('ahonn', () => {
  console.log('done');
  console.timeEnd('cost');
});
// hello ahonn, again
// hello ahonn
// done
// cost: 2008.609ms

// 或者通過 hook.promise() 呼叫
// hook.promise('ahonn').then(() => {
//  console.log('done');
//  console.timeEnd('cost');
// });

可以看到 AsyncParallelHook 比 SyncHook 複雜很多,SyncHook 之類的同步鉤子只能通過 tap 來註冊, 而非同步鉤子還能夠通過 tapAsync 或者 tapPromise 來註冊回撥,前者以 callback 的方式執行,而後者則通過 Promise 的方式來執行。非同步鉤子沒有 call 方法,執行註冊的回撥通過 callAsync 與 promise 方法進行觸發。兩者間的不同如上程式碼所示。

AsyncSeriesHook

如果你想要順序的執行非同步函式的話,顯然 AsyncParallelHook 是不適合的。所以 tapable 提供了另外一個基礎的非同步鉤子:AsyncSeriesHook。

const { AsyncSeriesHook } = require('tapable');
const hook = new AsyncSeriesHook(['name']);

console.time('cost');

hook.tapAsync('hello', (name, cb) => {
  setTimeout(() => {
    console.log(`hello ${name}`);
    cb();
  }, 2000);
});
hook.tapPromise('hello again', (name) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(`hello ${name}, again`);
      resolve();
    }, 1000);
  });
});

hook.callAsync('ahonn', () => {
  console.log('done');
  console.timeEnd('cost');
});
// hello ahonn
// hello ahonn, again
// done
// cost: 3011.162ms

上面的示例程式碼與 AsyncParallelHook 的示例程式碼幾乎相同,不同的是 hook 是通過new AsyncSeriesHook()例項化的。通過 AsyncSeriesHook 就能夠順序的執行註冊的回撥,除此之外註冊與觸發的用法都是相同的。

同樣的,非同步鉤子也有一些帶流程控制的鉤子:

  • AsyncParallelBailHook:執行過程中註冊的回撥返回非 undefined 時就會直接執行 callAsync 或者 promise 中的函式(由於並行執行的原因,註冊的其他回撥依然會執行)。
  • AsyncSeriesBailHook:執行過程中註冊的回撥返回非 undefined 時就會直接執行 callAsync 或者 promise 中的函式,並且註冊的後續回撥都不會執行。
  • AsyncSeriesWaterfallHook:與 SyncWaterfallHook 類似,上一個註冊的非同步回撥執行之後的返回值會傳遞給下一個註冊的回撥。

其他

tapable 中除了這一些核心的鉤子之外還提供了一些功能,例如HookMap,MultiHook等。這裡就不詳細描述它們了,有興趣的可以自行前往遊覽。

資源搜尋網站大全 https://www.renrenfan.com.cn 廣州VI設計公司https://www.houdianzi.com

具體實現

想知道 tapable 的具體實現就必須去閱讀相關的原始碼。由於篇幅有限,這裡我們就通過閱讀 SyncHook 相關的程式碼來看看相關實現,其他的鉤子思路上大體一致。我們通過以下程式碼來慢慢深入 tapable 的實現:

const { SyncHook } = require('tapable');
const hook = new SyncHook(['name']);
hook.tap('hello', (name) => {
    console.log(`hello ${name}`);
});
hook.call('ahonn');

入口

首先,我們例項化了SyncHook,通過 package.json 可以知道 tapable 的入口在/lib/index.js,這裡匯出了上面提到的那些同步/非同步的鉤子。SyncHook 對應的實現在/lib/SyncHook.js。

在這個檔案中,我們可以看到 SyncHook 類的結構如下:

class SyncHook exntends Hook {
    tapAsync() { ... }
    tapPromise() { ... }
    compile(options) { ... }
}

在new SyncHook()之後,我們會呼叫對應例項的tap方法進行註冊回撥。很明顯,tap不是在 SyncHook 中實現的,而是在父類中。

註冊回撥

可以看到/lib/Hook.js檔案中 Hook 類中實現了 tapable 鉤子的絕大多數方法,包括tap,tapAsync,tapPromise,call,callAsync等方法。

我們主要關注tap方法,可以看到該方法除了做了一些引數的檢查之外還呼叫了另外的兩個內部方法:_runRegisterInterceptors和_insert。_runRegisterInterceptors()是執行 register 攔截器,我們暫且忽略它(有關攔截器可以檢視tapable#interception)。

重點關注一下_insert方法:

_insert(item) {
  this._resetCompilation();
  let before;
  if (typeof item.before === 'string') before = new Set([item.before]);
  else if (Array.isArray(item.before)) {
    before = new Set(item.before);
  }
  let stage = 0;
  if (typeof item.stage === 'number') stage = item.stage;
  let i = this.taps.length;
  while (i > 0) {
    i--;
    const x = this.taps[i];
    this.taps[i + 1] = x;
    const xStage = x.stage || 0;
    if (before) {
      if (before.has(x.name)) {
        before.delete(x.name);
        continue;
      }
      if (before.size > 0) {
        continue;
      }
    }
    if (xStage > stage) {
      continue;
    }
    i++;
    break;
  }
  this.taps[i] = item;
}

這裡分成三個部分看,第一部分是this. _resetCompilation(),這裡主要是重置一下call,callAsync,promise這三個函式。至於為什麼要這麼做,我們後面再講,這裡先插個眼。

第二部分是一堆複雜的邏輯,主要是通過 options 中的 before 與 stage 來確定當前tap註冊的回撥在什麼位置,也就是提供了優先順序的配置,預設的話是新增在當前現有的this.taps後。將 before 與 stage 相關程式碼去除後_insert就變成了這樣:

_insert(item) {
  this._resetCompilation();
  let i = this.taps.length;
  this.taps[i] = item;
}

觸發

到目前為止還沒有什麼特別的騷操作,我們繼續看。當我們註冊了回撥之後就可以通過call來進行觸發了。通過 Hook 類的建構函式我們可以看到。

constructor(args) {
  if (!Array.isArray(args)) args = [];
  this._args = args;
  this.taps = [];
  this.interceptors = [];
  this.call = this._call;
  this.promise = this._promise;
  this.callAsync = this._callAsync;
  this._x = undefined;
}

這時候可以發現call,callAsync,promise都指向了下劃線開頭的同名函式,在檔案底部我們看到了如下程式碼:


function createCompileDelegate(name, type) {
    return function lazyCompileHook(...args) {
        this[name] = this._createCall(type);
        return this[name](...args);
    };
}

Object.defineProperties(Hook.prototype, {
    _call: {
        value: createCompileDelegate("call", "sync"),
        configurable: true,
        writable: true
    },
    _promise: {
        value: createCompileDelegate("promise", "promise"),
        configurable: true,
        writable: true
    },
    _callAsync: {
        value: createCompileDelegate("callAsync", "async"),
        configurable: true,
        writable: true
    }
});

這裡可以看到第一次執行call的時候實際上跑的是lazyCompileHook這個函式,這個函式會呼叫this._createCall('sync')來生成新函式執行,後面再次呼叫call時其實也是執行的生成的函式。

到這裡其實我們就可以明白前面在呼叫tap時執行的this. _resetCompilation()的作用了。也就是說,只要沒有新的tap來註冊回撥,call呼叫的就都會是同一個函式(第一次呼叫call生成的)。 執行新的tap來註冊回撥後的第一次call方法呼叫都會重新生成函式。

這裡其實我不太明白為什麼要通過Object.defineProperties在原型鏈上新增方法,直接寫在 Hook class 中的效果應該是一樣的。tapable 目前的 v2.0.0 beta 版本中已經不這樣實現了,如果有人知道為什麼。請評論告訴我吧。

為什麼需要重新生成函式呢?祕密就在this._createCall('sync')中的this.complie()裡。

_createCall(type) {
  return this.compile({
    taps: this.taps,
    interceptors: this.interceptors,
    args: this._args,
    type: type
  });
}

編譯函式

this.complie()不是在 Hook 中實現的,我們跳回到 SyncHook 中可以看到:

compile(options) {
  factory.setup(this, options);
  return factory.create(options);
}

這裡出現了一個factory,可以看到 factory 是上面的SyncHookCodeFactory類的例項,SyncHookCodeFactory中只實現了content。所以我們往上繼續看父類HookCodeFactory(lib/HookCodeFactory.js)中的setup與create。

這裡 setup 函式把 Hook 類中傳過來的options.taps中的回撥函式(呼叫 tap 時傳入的函式)賦值給了 SyncHook 裡的this._x:

setup(instance, options) {
  instance._x = options.taps.map(t => t.fn);
}

然後factory.create()執行之後返回,這裡我們可以知道create()返回的返回值必然是一個函式(供 call 來呼叫)。看到對應的原始碼,create()方法的實現有一個 switch,我們著重關注case 'sync'。將多餘的程式碼刪掉之後我們可以看到create()方法是這樣的:

create(options) {
  this.init(options);
  let fn;
  switch (this.options.type) {
    case "sync":
      fn = new Function(
        this.args(),
        '"use strict";\n' +
          this.header() +
          this.content({
            onError: err => `throw ${err};\n`,
            onResult: result => `return ${result};\n`,
            resultReturns: true,
            onDone: () => "",
            rethrowIfPossible: true
          })
      );
      break;
  }
  this.deinit();
  return fn;
}

可以看到這裡用到了new Function()來生成函式並返回 ,這是 tapable 的關鍵。通過例項化 SyncHook 時傳入的引數名列表與後面註冊的回撥資訊,生成一個函式來執行它們。對於不同的 tapable 鉤子,最大的不同就是這裡生成的函式不一樣,如果是帶有流程控制的鉤子的話,生成的程式碼中也會有對應的邏輯。

這裡我們在return fn之前加一句fn.toString()來看看生成出來的函式是什麼樣的:

function anonymous(name) {
  'use strict';
  var _context;
  var _x = this._x;
  var _fn0 = _x[0];
  _fn0(name);
}

由於我們的程式碼比較簡單,生成出來的程式碼就非常簡單了。主要的邏輯就是獲取this._x裡的第一個函式並傳入引數執行。如果我們在call之前再通過tap註冊一個回撥。那麼生成的程式碼中也會對應的獲取_x[1]來執行第二個註冊的回撥函式。