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.
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
hooksproperty. 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.
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.
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
Hookclass, which stores registration methods (
tap,
tapAsync,
tapPromise) and execution methods (
call,
callAsync,
promise). The
HookCodeFactorycreates 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
stageor
before).
When the hook is invoked,
_createCallbuilds 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
_nextfunction 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.hooksor
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
Compilerclass the
runhook 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
Functionconstructor.
Webpack uses Tapable to manage its entire build pipeline, allowing flexible customization of each step.
WeDoctor Frontend Technology
Official WeDoctor Group frontend public account, sharing original tech articles, events, job postings, and occasional daily updates from our tech team.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.