Inside unjs/hookable

4 min read

參考版本為 v5.5.3

hookableUnJS 團隊釋出的套件,我認為其優點有:

不過若是沒有非同步需求,且有效能考量,event emitter 或許是比較好的選擇,這部分可以參考 issue#64

Internal data structure

hookable 實作上,是用 object 儲存 hook, hook callbacks 之間一對多的關係

type HookCallback = (...arguments_: any) => Promise<void> | void;
 
class Hookable<
  HooksT extends Record<string, any> = Record<string, HookCallback>,
  HookNameT extends HookKeys<HooksT> = HookKeys<HooksT>
> {
  private _hooks: { [key: string]: HookCallback[] };
  // skip
  constructor() {
    this._hooks = {};
    // skip
  }
 
  hook<NameT extends HookNameT>(
    name: NameT,
    function_: InferCallback<HooksT, NameT>,
    options: { allowDeprecated?: boolean } = {}
  ) {
    // skip
    this._hooks[name] = this._hooks[name] || [];
    this._hooks[name].push(function_);
    // skip
  }
  // skip
}

Awaitable hook callbacks

簡單說明下方程式碼:

  1. foo, bar 為回傳 promise 的 asynchronous function
  2. 註冊 foo, barhello hook
  3. 調用 await callHook('hello') 查看 log 順序
  4. 從 log 順序可以得知,hook callback 會依照順序,等到當前的 promise resolved,再執行下一個 callback
function foo() {
  return new Promise((resolve) => {
    console.log('foo started');
    setTimeout(() => {
      console.log('foo resolved');
      resolve();
    }, 200);
  });
}
 
function bar() {
  return new Promise((resolve) => {
    console.log('bar started');
    setTimeout(() => {
      console.log('bar resolved');
      resolve();
    }, 200);
  });
}
 
const hooks = createHooks();
hooks.hook('hello', foo);
hooks.hook('hello', bar);
 
async function run() {
  await hooks.callHook('hello');
  console.log('hook finished');
}
 
run();
// "foo started"
// "foo resolved"
// "bar started"
// "bar resolved"
// "hook finished"

對應到 hookable 實作,當調用 hooks.callHook(event),最終是透過 serialTaskCaller(this.hooks[name], arguments_) 來執行 hook callbacks

class Hookable<
  HooksT extends Record<string, any> = Record<string, HookCallback>,
  HookNameT extends HookKeys<HooksT> = HookKeys<HooksT>
> {
  private _hooks: { [key: string]: HookCallback[] };
  // skip
  constructor() {
    this._hooks = {};
    // skip
  }
 
  hook<NameT extends HookNameT>(
    name: NameT,
    function_: InferCallback<HooksT, NameT>,
    options: { allowDeprecated?: boolean } = {}
  ) {
    // skip
    this._hooks[name] = this._hooks[name] || [];
    this._hooks[name].push(function_);
    // skip
  }
  // skip
  callHook<NameT extends HookNameT>(
    name: NameT,
    ...arguments_: Parameters<InferCallback<HooksT, NameT>>
  ): Promise<any> {
    arguments_.unshift(name);
    return this.callHookWith(serialTaskCaller, name, ...arguments_);
  }
 
  callHookWith<
    NameT extends HookNameT,
    CallFunction extends (
      hooks: HookCallback[],
      arguments_: Parameters<InferCallback<HooksT, NameT>>
    ) => any
  >(
    caller: CallFunction,
    name: NameT,
    ...arguments_: Parameters<InferCallback<HooksT, NameT>>
  ): ReturnType<CallFunction> {
    // skip
    const result = caller(
      name in this._hooks ? [...this._hooks[name]] : [],
      arguments_
    );
    // skip
    return result;
  }
}

接著看到 serialTaskCaller,為了讓非同步的 log 更好被追蹤,在調用 hook callbacks 前,先透過 console.createTask 建立關聯。第 2-6 行的 createTask 是為了兼容不同瀏覽器或較舊的版本,因為 console.createTask 可能不存在,更多細節可參考 pull#69

// https://developer.chrome.com/blog/devtools-modern-web-debugging/#linked-stack-traces
type CreateTask = typeof console.createTask;
const defaultTask: ReturnType<CreateTask> = { run: (function_) => function_() };
const _createTask: CreateTask = () => defaultTask;
const createTask =
  typeof console.createTask !== "undefined" ? console.createTask : _createTask;
  
function serialTaskCaller(hooks: HookCallback[], args: any[]) {
  const name = args.shift();
  const task = createTask(name);
  // skip
}

serialTaskCaller 調用的 hook callbacks 的流程如下:

  1. 第一個 hook callback 是透過 Promise.resolve 產生一個已實現的 promise
  2. 委派 fulfill callback 給已實現的 promise,在 fufill callback 內會調用下一個 hook callback
  3. hooks.reduce 會回傳最後一個 hook callback 對應的 promise,當最後一個 promise 的狀態變更為已實現,即代表所有 hook callbacks 依序執行完成
// skip
function serialTaskCaller(hooks: HookCallback[], args: any[]) {
  const name = args.shift();
  const task = createTask(name);
  // eslint-disable-next-line unicorn/no-array-reduce
  return hooks.reduce(
    (promise, hookFunction) =>
      promise.then(() => task.run(() => hookFunction(...args))),
    Promise.resolve()
  );
}

Reference