Tapable: типы хуков (SyncHook, AsyncHook, Waterfall, Bail)

Внутреннее устройство Webpack построено вокруг библиотеки Tapable, которая реализует систему хуков (hooks) для подключения расширяемой логики на разных этапах компиляции. Хуки позволяют внедряться в жизненный цикл сборки, модифицировать данные, останавливать выполнение, запускать асинхронные операции и строить сложные цепочки плагинов.

Каждый хук в Tapable — это объект с определённой семантикой исполнения: синхронной, асинхронной, каскадной или прерываемой. Тип хуков определяет поведение подписчиков и способ передачи данных между ними.


SyncHook: синхронное выполнение без возврата значений

SyncHook представляет собой базовый тип хуков, где все подписчики выполняются последовательно и синхронно. Возвращаемые значения игнорируются.

Основная характеристика:

  • выполнение строго по порядку регистрации
  • отсутствие механизма остановки цепочки
  • отсутствие передачи результата между обработчиками

Используется там, где требуется просто уведомить подписчиков о событии.

Пример поведения

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

const hook = new SyncHook(["name"]);

hook.tap("PluginA", (name) => {
  console.log("A:", name);
});

hook.tap("PluginB", (name) => {
  console.log("B:", name);
});

hook.call("webpack");

Вывод будет строго последовательным:

  • PluginA
  • PluginB

Особенности SyncHook

  • отсутствует контроль возврата значений
  • нельзя прервать выполнение
  • минимальные накладные расходы
  • используется для событий уведомления

AsyncHook: базовая модель асинхронных хуков

AsyncHook не является самостоятельной конечной реализацией, а представляет концептуальную основу для асинхронных хуков в Tapable. На практике используются его специализации:

  • AsyncParallelHook
  • AsyncSeriesHook

Асинхронность реализуется через:

  • callback-стиль
  • Promise-стиль

AsyncSeriesHook: последовательное асинхронное выполнение

AsyncSeriesHook выполняет подписчики строго по очереди, ожидая завершения каждого шага перед переходом к следующему.

Поддерживает три формы регистрации:

  • tap (синхронный стиль внутри серии)
  • tapAsync (callback)
  • tapPromise (Promise)

Поведение через callback

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

const hook = new AsyncSeriesHook(["arg"]);

hook.tapAsync("A", (arg, callback) => {
  setTimeout(() => {
    console.log("A:", arg);
    callback();
  }, 100);
});

hook.tapAsync("B", (arg, callback) => {
  setTimeout(() => {
    console.log("B:", arg);
    callback();
  }, 50);
});

hook.callAsync("data", () => {
  console.log("done");
});

Выполнение строго последовательное: сначала A, затем B.

Promise-вариант

hook.tapPromise("A", (arg) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("A");
      resolve();
    }, 100);
  });
});

AsyncParallelHook: параллельное асинхронное выполнение

AsyncParallelHook запускает все обработчики одновременно и завершает выполнение после завершения всех задач.

Особенности:

  • отсутствует последовательность выполнения
  • нет ожидания завершения одного обработчика перед другим
  • общий финальный callback вызывается после завершения всех задач

Пример

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

const hook = new AsyncParallelHook(["task"]);

hook.tapAsync("A", (task, cb) => {
  setTimeout(() => {
    console.log("A");
    cb();
  }, 200);
});

hook.tapAsync("B", (task, cb) => {
  setTimeout(() => {
    console.log("B");
    cb();
  }, 100);
});

hook.callAsync("job", () => {
  console.log("all done");
});

Завершение произойдёт после выполнения всех подписчиков, независимо от порядка их завершения.


WaterfallHook: каскадная передача значения

WaterfallHook реализует цепочку, где результат каждого обработчика передаётся следующему как входное значение.

Ключевая особенность:

  • каждый следующий обработчик получает модифицированное значение
  • результат последнего обработчика становится итоговым

Пример

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

const hook = new WaterfallHook(["value"]);

hook.tap("A", (value) => {
  return value + 1;
});

hook.tap("B", (value) => {
  return value * 2;
});

const result = hook.call(5);
console.log(result);

Порядок вычисления:

  • A: 5 → 6
  • B: 6 → 12

Итог: 12

Особенности WaterfallHook

  • всегда есть передача значения дальше по цепочке
  • отсутствие асинхронной логики в базовом виде
  • каждый обработчик может трансформировать данные

BailHook: прерываемое выполнение при наличии результата

BailHook прекращает выполнение цепочки, если любой из обработчиков возвращает значение, отличное от undefined.

Это позволяет реализовать поведение «первого успешного результата».

Пример

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

const hook = new BailHook(["arg"]);

hook.tap("A", (arg) => {
  console.log("A");
  return null;
});

hook.tap("B", (arg) => {
  console.log("B");
  return "result from B";
});

hook.tap("C", (arg) => {
  console.log("C");
});

const result = hook.call("data");
console.log(result);

Поведение:

  • A выполняется
  • B возвращает значение → цепочка останавливается
  • C не выполняется
  • результат = “result from B”

Особенности BailHook

  • ранний выход при наличии результата
  • используется для поиска первого подходящего значения
  • снижает количество выполняемых обработчиков

Сравнение типов хуков

SyncHook

  • синхронный
  • без возврата
  • без остановки

AsyncSeriesHook

  • асинхронный
  • последовательный
  • ожидает каждый шаг

AsyncParallelHook

  • асинхронный
  • параллельный
  • завершение по всем задачам

WaterfallHook

  • передача результата между обработчиками
  • трансформация данных на каждом шаге

BailHook

  • прерывание при наличии результата
  • поиск первого валидного значения

Поведение передачи данных между хуками

В Tapable параметры всегда передаются по фиксированной сигнатуре, заданной при создании хука. Однако интерпретация этих параметров зависит от типа хука:

  • SyncHook: параметры используются только как входные данные
  • WaterfallHook: первый параметр становится аккумулятором результата
  • Async hooks: параметры передаются через callback или Promise
  • BailHook: возвращаемое значение влияет на контроль потока

Комбинированные сценарии в Webpack

Внутри Webpack разные типы хуков используются на разных этапах:

  • инициализация сборки → SyncHook
  • построение графа зависимостей → AsyncSeriesHook
  • оптимизация модулей → BailHook
  • трансформация конфигурации → WaterfallHook
  • параллельная обработка ресурсов → AsyncParallelHook

Такая модель позволяет гибко балансировать между производительностью и управляемостью потока выполнения.


Внутренняя модель исполнения Tapable

Каждый хук компилирует список подписчиков в оптимизированную функцию вызова. Это означает:

  • отсутствие интерпретации на этапе выполнения
  • генерация оптимизированного JS-кода
  • минимизация накладных расходов на вызов плагинов

Различия типов хуков влияют на генерируемую структуру исполнения:

  • последовательные циклы для Series
  • промисы или callback-цепочки для Async
  • ранние return для Bail
  • накопление аргумента для Waterfall

Поведение ошибок и исключений

В SyncHook ошибки пробрасываются напрямую и могут остановить выполнение всей цепочки.

В Async хуках обработка ошибок зависит от реализации:

  • callback-стиль: передача ошибки первым аргументом
  • Promise-стиль: rejection прерывает цепочку

Waterfall и BailHooks не имеют отдельного механизма восстановления после ошибки — выполнение прерывается на уровне вызова.


Архитектурные последствия выбора типа хука

Выбор конкретного типа хука влияет на:

  • предсказуемость выполнения
  • производительность сборки
  • возможность расширения через плагины
  • контроль потока данных

SyncHook обеспечивает максимальную простоту, AsyncSeriesHook — строгий контроль порядка, AsyncParallelHook — масштабирование, WaterfallHook — трансформацию данных, BailHook — ранний выход при достижении результата.