Frontend Development 19 min read

How Tapable Powers Webpack: Inside the Hook System

This article explains the relationship between Tapable and webpack, detailing how Tapable implements a flexible hook system that powers webpack's plugin architecture, covering core concepts, hook classifications, usage patterns, internal mechanics, and practical examples for building custom webpack plugins.

WeDoctor Frontend Technology
WeDoctor Frontend Technology
WeDoctor Frontend Technology
How Tapable Powers Webpack: Inside the Hook System

Tapable and webpack relationship

Tapable is a library similar to Node.js EventEmitter, focusing on custom event triggering and handling. Webpack uses Tapable to decouple implementation from process, with all concrete implementations existing as plugins.

What is webpack?

Webpack is a static module bundler for modern JavaScript applications. It builds a dependency graph that maps each module required by the project and generates one or more bundles.

Important webpack modules

entry

output

loader (transforms source code of modules)

plugin (injects extension logic at specific points in the build process)

Plugins are the backbone of webpack; webpack itself is built on the same plugin system used in its configuration.

Webpack build process

Webpack essentially works as an event flow mechanism, chaining plugins together. The core of this mechanism is Tapable. The Compiler (responsible for compilation) and Compilation (responsible for creating bundles) are instances of Tapable (pre‑webpack 5). After webpack 5 they are defined via a

hooks

property. Tapable implements a complex publish‑subscribe pattern.

Example with Compiler:

<code>// webpack5 before, using inheritance
...
const { Tapable, SyncHook, SyncBailHook, AsyncParallelHook, AsyncSeriesHook } = require("tapable");
...
class Compiler extends Tapable {
  constructor(context) {
    super();
    ...
  }
}

// webpack5
...
const { SyncHook, SyncBailHook, AsyncParallelHook, AsyncSeriesHook } = require("tapable");
...
class Compiler {
  constructor(context) {
    this.hooks = Object.freeze({
      /** @type {SyncHook<[]>} */
      initialize: new SyncHook([]),

      /** @type {SyncBailHook<[Compilation], boolean>} */
      shouldEmit: new SyncBailHook(["compilation"]),
      ...
    })
  }
  ...
}
</code>

How to use Tapable

Tapable exposes nine Hook classes. Instantiating a Hook creates an execution flow and provides registration and execution methods. Different Hook types lead to different execution flows.

Classification by sync/async

Sync – can only be registered with synchronous functions, e.g.,

myHook.tap()

AsyncSeries – can be registered with synchronous, callback‑based, or promise‑based functions; execution is serial.

AsyncParallel – same registration options; execution is parallel.

Hook classification
Hook classification

Classification by execution mode

Basic – executes every registered function, ignoring return values.

Bail – stops after the first function returns a non‑undefined result.

Waterfall – passes the result of the previous function as the first argument to the next.

Loop – repeatedly executes functions until all return undefined.

Execution mode diagram
Execution mode diagram

Hook usage pattern

Instantiate a Hook class.

Register one or more callbacks.

Call the hook with arguments.

(Optional) Add interceptors to listen to registration or execution.

Simple SyncHook example:

<code>const hook = new SyncHook(["arg1", "arg2", "arg3"]);
hook.tap("1", (arg1, arg2, arg3) => {
  console.log(1, arg1, arg2, arg3);
  return 1;
});
hook.tap("2", (arg1, arg2, arg3) => {
  console.log(2, arg1, arg2, arg3);
  return 2;
});
hook.tap("3", (arg1, arg2, arg3) => {
  console.log(3, arg1, arg2, arg3);
  return 3;
});
hook.call("a", "b", "c");
// Output:
// 1 a b c
// 2 a b c
// 3 a b c
</code>

Asynchronous AsyncSeriesHook example:

<code>let { AsyncSeriesHook } = require("tapable");
let queue = new AsyncSeriesHook(["name"]);
console.time("cost");
queue.tapPromise("1", function (name) {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log(1, name);
      resolve();
    }, 1000);
  });
});
queue.tapPromise("2", function (name) {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log(2, name);
      resolve();
    }, 2000);
  });
});
queue.tapPromise("3", function (name) {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log(3, name);
      resolve();
    }, 3000);
  });
});
queue.promise("weiyi").then(data => {
  console.log(data);
  console.timeEnd("cost");
});
</code>

HookMap usage

A HookMap helps manage a map of hooks. Official recommendation is to instantiate all hooks on a class property

hooks

:

<code class="language-js">class Car {
  constructor() {
    this.hooks = {
      accelerate: new SyncHook(["newSpeed"]),
      brake: new SyncHook(),
      calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"])
    };
  }
  setSpeed(newSpeed) {
    this.hooks.accelerate.call(newSpeed);
  }
}
const myCar = new Car();
myCar.hooks.accelerate.tap("LoggerPlugin", newSpeed => console.log(`Accelerating to ${newSpeed}`));
myCar.setSpeed(1);
</code>

MultiHook usage

MultiHook redirects taps to multiple other hooks:

<code>this.hooks.allHooks = new MultiHook([this.hooks.hookA, this.hooks.hookB]);
</code>

Tapable internals

The core of Tapable is the

Hook

class, which stores registration methods (

tap

,

tapAsync

,

tapPromise

) and execution methods (

call

,

callAsync

,

promise

). The

HookCodeFactory

creates the actual function body that runs the registered callbacks, handling sync, async series, async parallel, and loop execution strategies.

Key steps in the execution flow:

Collect taps into

this.taps

(optionally sorted by

stage

or

before

).

When the hook is invoked,

_createCall

builds a compile function via

HookCodeFactory

.

The compile function is generated with

new Function(...)

, assembling a header and a body that iterates over taps according to the hook type.

For sync hooks the generated code simply calls each tap in order:

<code>var _fn0 = _x[0];
_fn0(arg1, arg2, arg3);
var _fn1 = _x[1];
_fn1(arg1, arg2, arg3);
...</code>

Async series hooks wrap each tap in a

_next

function that calls the next tap after the previous one invokes its callback. Promise hooks create a promise chain, checking that each tap returns a promise and propagating results or errors.

How Tapable empowers webpack plugins

Plugins register callbacks to webpack’s hooks (exposed via

compiler.hooks

or

compilation.hooks

). When webpack reaches a specific step, it triggers the corresponding hook, executing all registered plugin logic.

Example plugin that logs when the build starts:

<code>const pluginName = 'ConsoleLogOnBuildWebpackPlugin';
class ConsoleLogOnBuildWebpackPlugin {
  apply(compiler) {
    compiler.hooks.run.tap(pluginName, compilation => {
      console.log('webpack build started!');
    });
  }
}
module.exports = ConsoleLogOnBuildWebpackPlugin;
</code>

In the

Compiler

class the

run

hook is an

AsyncSeriesHook

. During the build process webpack calls

this.hooks.run.callAsync(this, …)

, which invokes the plugin’s callback.

Summary

Tapable exposes nine hook types; core methods are registration, execution, and interceptors.

Tapable implements hooks by concatenating strings based on hook and registration types, then feeding them to the

Function

constructor.

Webpack uses Tapable to manage its entire build pipeline, allowing flexible customization of each step.

Conclusion
Conclusion
JavaScriptPluginBuild ToolWebpackHookTapable
WeDoctor Frontend Technology
Written by

WeDoctor Frontend Technology

Official WeDoctor Group frontend public account, sharing original tech articles, events, job postings, and occasional daily updates from our tech team.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.