webpack核心模塊tapable用法解析

前不久寫了一篇webpack基本原理和AST用法的文章,原本想接着寫webpack plugin的原理的,可是發現webpack plugin高度依賴tapable這個庫,不清楚tapable而直接去看webpack plugin始終有點霧裏看花的意思。因此就先去看了下tapable的文檔和源碼,發現這個庫很是有意思,是加強版的發佈訂閱模式發佈訂閱模式在源碼世界實在是太常見了,咱們已經在多個庫源碼裏面見過了:javascript

  1. reduxsubscribedispatch
  2. Node.jsEventEmitter
  3. redux-sagatakeput

這些庫基本都本身實現了本身的發佈訂閱模式,實現方式主要是用來知足本身的業務需求,而tapable並無具體的業務邏輯,是一個專門用來實現事件訂閱或者他本身稱爲hook(鉤子)的工具庫,其根本原理仍是發佈訂閱模式,可是他實現了多種形式的發佈訂閱模式,還包含了多種形式的流程控制。前端

tapable暴露多個API,提供了多種流程控制方式,連使用都是比較複雜的,因此我想分兩篇文章來寫他的原理:java

  1. 先看看用法,體驗下他的多種流程控制方式
  2. 經過用法去看看源碼是怎麼實現的

本文就是講用法的文章,知道了他的用法,你們之後若是有本身實現hook或者事件監聽的需求,能夠直接拿過來用,很是強大!node

本文例子已經所有上傳到GitHub,你們能夠拿下來作個參考:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/tapable-usagewebpack

tapable是什麼

tapablewebpack的核心模塊,也是webpack團隊維護的,是webpack plugin的基本實現方式。他的主要功能是爲使用者提供強大的hook機制,webpack plugin就是基於hook的。git

主要API

下面是官方文檔中列出來的主要API,全部API的名字都是以Hook結尾的:github

const {
    SyncHook,
    SyncBailHook,
    SyncWaterfallHook,
    SyncLoopHook,
    AsyncParallelHook,
    AsyncParallelBailHook,
    AsyncSeriesHook,
    AsyncSeriesBailHook,
    AsyncSeriesWaterfallHook
 } = require("tapable");

這些API的名字其實就解釋了他的做用,注意這些關鍵字:Sync, Async, Bail, Waterfall, Loop, Parallel, Series。下面分別來解釋下這些關鍵字:web

Sync:這是一個同步的hook編程

Async:這是一個異步的hookredux

BailBail在英文中的意思是保險,保障的意思,實現的效果是,當一個hook註冊了多個回調方法,任意一個回調方法返回了不爲undefined的值,就再也不執行後面的回調方法了,就起到了一個「保險絲」的做用。

WaterfallWaterfall在英語中是瀑布的意思,在編程世界中表示順序執行各類任務,在這裏實現的效果是,當一個hook註冊了多個回調方法,前一個回調執行完了纔會執行下一個回調,而前一個回調的執行結果會做爲參數傳給下一個回調函數。

LoopLoop就是循環的意思,實現的效果是,當一個hook註冊了回調方法,若是這個回調方法返回了true就重複循環這個回調,只有當這個回調返回undefined才執行下一個回調。

ParallelParallel是並行的意思,有點相似於Promise.all,就是當一個hook註冊了多個回調方法,這些回調同時開始並行執行。

SeriesSeries就是串行的意思,就是當一個hook註冊了多個回調方法,前一個執行完了纔會執行下一個。

ParallelSeries的概念只存在於異步的hook中,由於同步hook所有是串行的。

下面咱們分別來介紹下每一個API的用法和效果。

同步API

同步API就是這幾個:

const {
    SyncHook,
    SyncBailHook,
    SyncWaterfallHook,
    SyncLoopHook,
 } = require("tapable");

前面說了,同步API所有是串行的,因此這幾個的區別就在流程控制上。

SyncHook

SyncHook是一個最基礎的hook,其使用方法和效果接近咱們常用的發佈訂閱模式,注意tapable導出的全部hook都是類,基本用法是這樣的:

const hook = new SyncHook(["arg1", "arg2", "arg3"]);

由於SyncHook是一個類,因此使用new來生成一個實例,構造函數接收的參數是一個數組["arg1", "arg2", "arg3"],這個數組有三項,表示生成的這個實例註冊回調的時候接收三個參數。實例hook主要有兩個實例方法:

  1. tap:就是註冊事件回調的方法。
  2. call:就是觸發事件,執行回調的方法。

下面咱們擴展下官方文檔中小汽車加速的例子來講明下具體用法:

const { SyncHook } = require("tapable");

// 實例化一個加速的hook
const accelerate = new SyncHook(["newSpeed"]);

// 註冊第一個回調,加速時記錄下當前速度
accelerate.tap("LoggerPlugin", (newSpeed) =>
  console.log("LoggerPlugin", `加速到${newSpeed}`)
);

// 再註冊一個回調,用來檢測是否超速
accelerate.tap("OverspeedPlugin", (newSpeed) => {
  if (newSpeed > 120) {
    console.log("OverspeedPlugin", "您已超速!!");
  }
});

// 再註冊一個回調,用來檢測速度是否快到損壞車子了
accelerate.tap("DamagePlugin", (newSpeed) => {
  if (newSpeed > 300) {
    console.log("DamagePlugin", "速度實在太快,車子快散架了。。。");
  }
});

// 觸發一下加速事件,看看效果吧
accelerate.call(500);

而後運行下看看吧,當加速事件出現的時候,會依次執行這三個回調:

image-20210309160302799

上面這個例子主要就是用了tapcall這兩個實例方法,其中tap接收兩個參數,第一個是個字符串,並無實際用處,僅僅是一個註釋的做用,第二個參數就是一個回調函數,用來執行事件觸發時的具體邏輯。

accelerate.tap("LoggerPlugin", (newSpeed) =>
  console.log("LoggerPlugin", `加速到${newSpeed}`)
);

上述這種寫法其實與webpack官方文檔中對於plugin的介紹很是像了,由於webpackplguin就是用tapable實現的,第一個參數通常就是plugin的名字:

image-20210309154641835

call就是簡單的觸發這個事件,在webpackplguin中通常不須要開發者去觸發事件,而是webpack本身在不一樣階段會觸發不一樣的事件,好比beforeRun, run等等,plguin開發者更多的會關注這些事件出現時應該進行什麼操做,也就是在這些事件上註冊本身的回調。

SyncBailHook

上面的SyncHook其實就是一個簡單的發佈訂閱模式SyncBailHook就是在這個基礎上加了一點流程控制,前面咱們說過了,Bail就是個保險,實現的效果是,前面一個回調返回一個不爲undefined的值,就中斷這個流程。好比咱們如今將前面這個例子的SyncHook換成SyncBailHook,而後在檢測超速的這個插件裏面加點邏輯,當它超速了就返回錯誤,後面的DamagePlugin就不會執行了:

const { SyncBailHook } = require("tapable");    // 使用的是SyncBailHook

// 實例化一個加速的hook
const accelerate = new SyncBailHook(["newSpeed"]);

accelerate.tap("LoggerPlugin", (newSpeed) =>
  console.log("LoggerPlugin", `加速到${newSpeed}`)
);

// 再註冊一個回調,用來檢測是否超速
// 若是超速就返回一個錯誤
accelerate.tap("OverspeedPlugin", (newSpeed) => {
  if (newSpeed > 120) {
    console.log("OverspeedPlugin", "您已超速!!");

    return new Error('您已超速!!');
  }
});

accelerate.tap("DamagePlugin", (newSpeed) => {
  if (newSpeed > 300) {
    console.log("DamagePlugin", "速度實在太快,車子快散架了。。。");
  }
});

accelerate.call(500);

而後再運行下看看:

image-20210309161001682

能夠看到因爲OverspeedPlugin返回了一個不爲undefined的值,DamagePlugin被阻斷,沒有運行了。

SyncWaterfallHook

SyncWaterfallHook也是在SyncHook的基礎上加了點流程控制,前面說了,Waterfall實現的效果是將上一個回調的返回值做爲參數傳給下一個回調。因此經過call傳入的參數只會傳遞給第一個回調函數,後面的回調接受都是上一個回調的返回值,最後一個回調的返回值會做爲call的返回值返回給最外層:

const { SyncWaterfallHook } = require("tapable");

const accelerate = new SyncWaterfallHook(["newSpeed"]);

accelerate.tap("LoggerPlugin", (newSpeed) => {
  console.log("LoggerPlugin", `加速到${newSpeed}`);

  return "LoggerPlugin";
});

accelerate.tap("Plugin2", (data) => {
  console.log(`上一個插件是: ${data}`);

  return "Plugin2";
});

accelerate.tap("Plugin3", (data) => {
  console.log(`上一個插件是: ${data}`);

  return "Plugin3";
});

const lastPlugin = accelerate.call(100);

console.log(`最後一個插件是:${lastPlugin}`);

而後看下運行效果吧:

image-20210309162008465

SyncLoopHook

SyncLoopHook是在SyncHook的基礎上添加了循環的邏輯,也就是若是一個插件返回true就會一直執行這個插件,直到他返回undefined纔會執行下一個插件:

const { SyncLoopHook } = require("tapable");

const accelerate = new SyncLoopHook(["newSpeed"]);

accelerate.tap("LoopPlugin", (newSpeed) => {
  console.log("LoopPlugin", `循環加速到${newSpeed}`);

  return new Date().getTime() % 5 !== 0 ? true : undefined;
});

accelerate.tap("LastPlugin", (newSpeed) => {
  console.log("循環加速總算結束了");
});

accelerate.call(100);

執行效果以下:

image-20210309163514680

異步API

所謂異步API是相對前面的同步API來講的,前面的同步API的全部回調都是按照順序同步執行的,每一個回調內部也所有是同步代碼。可是實際項目中,可能須要回調裏面處理異步狀況,也可能但願多個回調能夠同時並行執行,也就是Parallel。這些需求就須要用到異步API了,主要的異步API就是這些:

const {
    AsyncParallelHook,
    AsyncParallelBailHook,
    AsyncSeriesHook,
    AsyncSeriesBailHook,
    AsyncSeriesWaterfallHook
 } = require("tapable");

既然涉及到了異步,那確定還須要異步的處理方式,tapable支持回調函數和Promise兩種異步的處理方式。因此這些異步API除了用前面的tap來註冊回調外,還有兩個註冊回調的方法:tapAsynctapPromise,對應的觸發事件的方法爲callAsyncpromise。下面分別來看下每一個API吧:

AsyncParallelHook

AsyncParallelHook從前面介紹的命名規則能夠看出,他是一個異步並行執行的Hook,咱們先用tapAsync的方式來看下怎麼用吧。

tapAsync和callAsync

仍是那個小汽車加速的例子,只不過這個小汽車加速沒那麼快了,須要一秒才能加速完成,而後咱們在2秒的時候分別檢測是否超速和是否損壞,爲了看出並行的效果,咱們記錄下整個過程從開始到結束的時間:

const { AsyncParallelHook } = require("tapable");

const accelerate = new AsyncParallelHook(["newSpeed"]);

console.time("total time"); // 記錄起始時間

// 注意註冊異步事件須要使用tapAsync
// 接收的最後一個參數是done,調用他來表示當前任務執行完畢
accelerate.tapAsync("LoggerPlugin", (newSpeed, done) => {
  // 1秒後加速才完成
  setTimeout(() => {
    console.log("LoggerPlugin", `加速到${newSpeed}`);

    done();
  }, 1000);
});

accelerate.tapAsync("OverspeedPlugin", (newSpeed, done) => {
  // 2秒後檢測是否超速
  setTimeout(() => {
    if (newSpeed > 120) {
      console.log("OverspeedPlugin", "您已超速!!");
    }
    done();
  }, 2000);
});

accelerate.tapAsync("DamagePlugin", (newSpeed, done) => {
  // 2秒後檢測是否損壞
  setTimeout(() => {
    if (newSpeed > 300) {
      console.log("DamagePlugin", "速度實在太快,車子快散架了。。。");
    }

    done();
  }, 2000);
});

accelerate.callAsync(500, () => {
  console.log("任務所有完成");
  console.timeEnd("total time"); // 記錄總共耗時
});

上面代碼須要注意的是,註冊回調要使用tapAsync,並且回調函數裏面最後一個參數會自動傳入done,你能夠調用他來通知tapable當前任務已經完成。觸發任務須要使用callAsync,他最後也接收一個函數,能夠用來處理全部任務都完成後須要執行的操做。因此上面的運行結果就是:

image-20210309171527773

從這個結果能夠看出,最終消耗的時間大概是2秒,也就是三個任務中最長的單個任務耗時,而不是三個任務耗時的總額,這就實現了Parallel並行的效果。

tapPromise和promise

如今都流行Promise,因此tapable也是支持的,執行效果是同樣的,只是寫法不同而已。要用tapPromise,須要註冊的回調返回一個promise,同時觸發事件也須要用promise,任務運行完執行的處理能夠直接使用then,因此上述代碼改成:

const { AsyncParallelHook } = require("tapable");

const accelerate = new AsyncParallelHook(["newSpeed"]);

console.time("total time"); // 記錄起始時間

// 注意註冊異步事件須要使用tapPromise
// 回調函數要返回一個promise
accelerate.tapPromise("LoggerPlugin", (newSpeed) => {
  return new Promise((resolve) => {
    // 1秒後加速才完成
    setTimeout(() => {
      console.log("LoggerPlugin", `加速到${newSpeed}`);

      resolve();
    }, 1000);
  });
});

accelerate.tapPromise("OverspeedPlugin", (newSpeed) => {
  return new Promise((resolve) => {
    // 2秒後檢測是否超速
    setTimeout(() => {
      if (newSpeed > 120) {
        console.log("OverspeedPlugin", "您已超速!!");
      }
      resolve();
    }, 2000);
  });
});

accelerate.tapPromise("DamagePlugin", (newSpeed) => {
  return new Promise((resolve) => {
    // 2秒後檢測是否損壞
    setTimeout(() => {
      if (newSpeed > 300) {
        console.log("DamagePlugin", "速度實在太快,車子快散架了。。。");
      }

      resolve();
    }, 2000);
  });
});

// 觸發事件使用promise,直接用then處理最後的結果
accelerate.promise(500).then(() => {
  console.log("任務所有完成");
  console.timeEnd("total time"); // 記錄總共耗時
});

這段代碼的邏輯和運行結果和上面那個是同樣的,只是寫法不同:

image-20210309172537951

tapAsync和tapPromise混用

既然tapable支持這兩種異步寫法,那這兩種寫法能夠混用嗎?咱們來試試吧:

const { AsyncParallelHook } = require("tapable");

const accelerate = new AsyncParallelHook(["newSpeed"]);

console.time("total time"); // 記錄起始時間

// 來一個promise寫法
accelerate.tapPromise("LoggerPlugin", (newSpeed) => {
  return new Promise((resolve) => {
    // 1秒後加速才完成
    setTimeout(() => {
      console.log("LoggerPlugin", `加速到${newSpeed}`);

      resolve();
    }, 1000);
  });
});

// 再來一個async寫法
accelerate.tapAsync("OverspeedPlugin", (newSpeed, done) => {
  // 2秒後檢測是否超速
  setTimeout(() => {
    if (newSpeed > 120) {
      console.log("OverspeedPlugin", "您已超速!!");
    }
    done();
  }, 2000);
});

// 使用promise觸發事件
// accelerate.promise(500).then(() => {
//   console.log("任務所有完成");
//   console.timeEnd("total time"); // 記錄總共耗時
// });

// 使用callAsync觸發事件
accelerate.callAsync(500, () => {
  console.log("任務所有完成");
  console.timeEnd("total time"); // 記錄總共耗時
});

這段代碼不管我是使用promise觸發事件仍是callAsync觸發運行的結果都是同樣的,因此tapable內部應該是作了兼容轉換的,兩種寫法能夠混用:

image-20210309173217034

因爲tapAsynctapPromise只是寫法上的不同,我後面的例子就所有用tapAsync了。

AsyncParallelBailHook

前面已經看了SyncBailHook,知道帶Bail的功能就是當一個任務返回不爲undefined的時候,阻斷後面任務的執行。可是因爲Parallel任務都是同時開始的,阻斷是阻斷不了了,實際效果是若是有一個任務返回了不爲undefined的值,最終的回調會當即執行,而且獲取Bail任務的返回值。咱們將上面三個任務執行時間錯開,分別爲1秒,2秒,3秒,而後在2秒的任務觸發Bail就能看到效果了:

const { AsyncParallelBailHook } = require("tapable");

const accelerate = new AsyncParallelBailHook(["newSpeed"]);

console.time("total time"); // 記錄起始時間

accelerate.tapAsync("LoggerPlugin", (newSpeed, done) => {
  // 1秒後加速才完成
  setTimeout(() => {
    console.log("LoggerPlugin", `加速到${newSpeed}`);

    done();
  }, 1000);
});

accelerate.tapAsync("OverspeedPlugin", (newSpeed, done) => {
  // 2秒後檢測是否超速
  setTimeout(() => {
    if (newSpeed > 120) {
      console.log("OverspeedPlugin", "您已超速!!");
    }

    // 這個任務的done返回一個錯誤
    // 注意第一個參數是node回調約定俗成的錯誤
    // 第二個參數纔是Bail的返回值
    done(null, new Error("您已超速!!"));
  }, 2000);
});

accelerate.tapAsync("DamagePlugin", (newSpeed, done) => {
  // 3秒後檢測是否損壞
  setTimeout(() => {
    if (newSpeed > 300) {
      console.log("DamagePlugin", "速度實在太快,車子快散架了。。。");
    }

    done();
  }, 3000);
});

accelerate.callAsync(500, (error, data) => {
  if (data) {
    console.log("任務執行出錯:", data);
  } else {
    console.log("任務所有完成");
  }
  console.timeEnd("total time"); // 記錄總共耗時
});

能夠看到執行到任務2時,因爲他返回了一個錯誤,因此最終的回調會當即執行,可是因爲任務3以前已經同步開始了,因此他本身仍然會運行完,只是已經不影響最終結果了:

image-20210311142451224

AsyncSeriesHook

AsyncSeriesHook是異步串行hook,若是有多個任務,這多個任務之間是串行的,可是任務自己卻多是異步的,下一個任務必須等上一個任務done了才能開始:

const { AsyncSeriesHook } = require("tapable");

const accelerate = new AsyncSeriesHook(["newSpeed"]);

console.time("total time"); // 記錄起始時間

accelerate.tapAsync("LoggerPlugin", (newSpeed, done) => {
  // 1秒後加速才完成
  setTimeout(() => {
    console.log("LoggerPlugin", `加速到${newSpeed}`);

    done();
  }, 1000);
});

accelerate.tapAsync("OverspeedPlugin", (newSpeed, done) => {
  // 2秒後檢測是否超速
  setTimeout(() => {
    if (newSpeed > 120) {
      console.log("OverspeedPlugin", "您已超速!!");
    }
    done();
  }, 2000);
});

accelerate.tapAsync("DamagePlugin", (newSpeed, done) => {
  // 2秒後檢測是否損壞
  setTimeout(() => {
    if (newSpeed > 300) {
      console.log("DamagePlugin", "速度實在太快,車子快散架了。。。");
    }

    done();
  }, 2000);
});

accelerate.callAsync(500, () => {
  console.log("任務所有完成");
  console.timeEnd("total time"); // 記錄總共耗時
});

每一個任務代碼跟AsyncParallelHook是同樣的,只是使用的Hook不同,而最終效果的區別是:AsyncParallelHook全部任務同時開始,因此最終總耗時就是耗時最長的那個任務的耗時;AsyncSeriesHook的任務串行執行,下一個任務要等上一個任務完成了才能開始,因此最終總耗時是全部任務耗時的總和,上面這個例子就是1 + 2 + 2,也就是5秒:

image-20210311144738884

AsyncSeriesBailHook

AsyncSeriesBailHook就是在AsyncSeriesHook的基礎上加上了Bail的邏輯,也就是中間任何一個任務返回不爲undefined的值,終止執行,直接執行最後的回調,而且將這個返回值傳給最終的回調:

const { AsyncSeriesBailHook } = require("tapable");

const accelerate = new AsyncSeriesBailHook(["newSpeed"]);

console.time("total time"); // 記錄起始時間

accelerate.tapAsync("LoggerPlugin", (newSpeed, done) => {
  // 1秒後加速才完成
  setTimeout(() => {
    console.log("LoggerPlugin", `加速到${newSpeed}`);

    done();
  }, 1000);
});

accelerate.tapAsync("OverspeedPlugin", (newSpeed, done) => {
  // 2秒後檢測是否超速
  setTimeout(() => {
    if (newSpeed > 120) {
      console.log("OverspeedPlugin", "您已超速!!");
    }

    // 這個任務的done返回一個錯誤
    // 注意第一個參數是node回調約定俗成的錯誤
    // 第二個參數纔是Bail的返回值
    done(null, new Error("您已超速!!"));
  }, 2000);
});

accelerate.tapAsync("DamagePlugin", (newSpeed, done) => {
  // 2秒後檢測是否損壞
  setTimeout(() => {
    if (newSpeed > 300) {
      console.log("DamagePlugin", "速度實在太快,車子快散架了。。。");
    }

    done();
  }, 2000);
});

accelerate.callAsync(500, (error, data) => {
  if (data) {
    console.log("任務執行出錯:", data);
  } else {
    console.log("任務所有完成");
  }
  console.timeEnd("total time"); // 記錄總共耗時
});

這個執行結果跟AsyncParallelBailHook的區別就是AsyncSeriesBailHook被阻斷後,後面的任務因爲還沒開始,因此能夠被徹底阻斷,而AsyncParallelBailHook後面的任務因爲已經開始了,因此還會繼續執行,只是結果已經不關心了。

image-20210311145241190

AsyncSeriesWaterfallHook

Waterfall的做用是將前一個任務的結果傳給下一個任務,其餘的跟AsyncSeriesHook同樣的,直接來看代碼吧:

const { AsyncSeriesWaterfallHook } = require("tapable");

const accelerate = new AsyncSeriesWaterfallHook(["newSpeed"]);

console.time("total time"); // 記錄起始時間

accelerate.tapAsync("LoggerPlugin", (newSpeed, done) => {
  // 1秒後加速才完成
  setTimeout(() => {
    console.log("LoggerPlugin", `加速到${newSpeed}`);

    // 注意done的第一個參數會被當作error
    // 第二個參數纔是傳遞給後面任務的參數
    done(null, "LoggerPlugin");
  }, 1000);
});

accelerate.tapAsync("Plugin2", (data, done) => {
  setTimeout(() => {
    console.log(`上一個插件是: ${data}`);

    done(null, "Plugin2");
  }, 2000);
});

accelerate.tapAsync("Plugin3", (data, done) => {
  setTimeout(() => {
    console.log(`上一個插件是: ${data}`);

    done(null, "Plugin3");
  }, 2000);
});

accelerate.callAsync(500, (error, data) => {
  console.log("最後一個插件是:", data);
  console.timeEnd("total time"); // 記錄總共耗時
});

運行效果以下:

image-20210311150510851

總結

本文例子已經所有上傳到GitHub,你們能夠拿下來作個參考:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/tapable-usage

  1. tapablewebpack實現plugin的核心庫,他爲webpack提供了多種事件處理和流程控制的Hook
  2. 這些Hook主要有同步(Sync)和異步(Async)兩種,同時還提供了阻斷(Bail),瀑布(Waterfall),循環(Loop)等流程控制,對於異步流程還提供了並行(Paralle)和串行(Series)兩種控制方式。
  3. tapable其核心原理仍是事件的發佈訂閱模式,他使用tap來註冊事件,使用call來觸發事件。
  4. 異步hook支持兩種寫法:回調和Promise,註冊和觸發事件分別使用tapAsync/callAsynctapPromise/promise
  5. 異步hook使用回調寫法的時候要注意,回調函數的第一個參數默認是錯誤,第二個參數纔是向外傳遞的數據,這也符合node回調的風格。

這篇文章主要講述了tapable的用法,後面我會寫一篇文章來分析他的源碼,點個關注不迷路,哈哈~

文章的最後,感謝你花費寶貴的時間閱讀本文,若是本文給了你一點點幫助或者啓發,請不要吝嗇你的贊和GitHub小星星,你的支持是做者持續創做的動力。

歡迎關注個人公衆號進擊的大前端第一時間獲取高質量原創~

「前端進階知識」系列文章源碼地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二維碼_2.png

相關文章
相關標籤/搜索