Tapable library as a core of webpack architecture
TL;DR
Webpack architecture is heavily based on events. Each webpack plugin is basically a set of listeners hooked on different events during compilation phases. Under the hood, webpack uses a library called tapable
to encapsulate "publish-subscribe" implementation.
Tapable provides different "hooks" classes (SyncBailHook
, AsyncParallelHook
, etc.) to "hook" on events with some extra rich functionality (e.g. interceptions or cross-listeners integration).
For example, DefinePugin
(used to define environment variables, e.g. NODE_ENV
) and SizeLimitsPlugin
(reports oversized chunks, e.g. size > 250kb) tap into compiler instance hooks: the first one listens to compilation event in order to insert extra variables and the latter to afterEmit event - to proceed with assets evaluation once they were emitted.
Let's have a quick look under the hood of webpack
at SizeLimitsPlugin
integration.
483 }484 }485486 if (options.performance) {487 const SizeLimitsPlugin = require("./performance/SizeLimitsPlugin");488 new SizeLimitsPlugin(options.performance).apply(compiler);489 }490491 new TemplatedPathPlugin().apply(compiler);492
10/** @typedef {import("../Compiler")} Compiler */11/** @typedef {import("../Entrypoint")} Entrypoint */1213module.exports = class SizeLimitsPlugin {+25 apply(compiler) {+32 compiler.hooks.afterEmit.tap("SizeLimitsPlugin", compilation => {33 const warnings = [];3435 /**
0 | SizeLimitsPlugin is instantiated and assigned to compiler in WebpackOptionsApply if performance option is enabled from webpack config (highlight |
1 | Then SizeLimitsPlugin taps on afterEmit event and will sit still until most of the compilation flow is done and that particular event is called. |
29 const assetFilter =30 this.assetFilter || ((name, source, info) => !info.development);3132 compiler.hooks.afterEmit.tap("SizeLimitsPlugin", compilation => {+74 const entrypointsOverLimit = [];75 for (const [name, entry] of compilation.entrypoints) {76 const size = getEntrypointSize(entry);7778 if (size > entrypointSizeLimit) {79 entrypointsOverLimit.push({80 name: name,81 size: size,82 files: entry.getFiles().filter(fileFilter)83 });84 /** @type {any} */ (entry).isOverSizeLimit = true;85 }
351 }352353 emitAssets(compilation, callback) {+358 asyncLib.forEachLimit(359 compilation.getAssets(),360 15,361 ({ name: file, source }, callback) => {+482 this.hooks.afterEmit.callAsync(compilation, err => {483 if (err) return callback(err);484
Once the event is called
Indeed, all plugins are assigned similarly, so then you have a pipeline of plugins performing different operations but staying very loosely coupled.
Slightly longer read
Let's learn more about tapable
library.
Have a look at this classic events-based architecture all of us have used many times.
1// pseudo code23pubSub.subscribe('myEvent', () => console.log('Handled by 1st subscriber'))45pubSub.subscribe('myEvent', () => console.log('Handled by 2nd subscriber'))67pubSub.subscribe('myEvent', () => console.log('Handled by 3rd subscriber'))8910pubSub.publish('myEvent')11
Usually, Pub-Sub (Observer or Listener) behavioral pattern is used to establish a one-to-many relationship: publisher dispatches events 0 and multiple subscribers can listen to them accordingly 1.
Tapable is the Pub-Sub implementation on steroids. One key difference - instead of just string event names, uses the "Hook" class instance per event. It implements tap
/tapAsync
with call
/callAsync
methods to listen and publish events, or even intercept
them.
39 * @property {Set<string>} compilationDependencies40 */4142class Compiler extends Tapable {43 constructor(context) {44 super();45 this.hooks = {+72 /** @type {AsyncSeriesHook<CompilationParams>} */73 beforeCompile: new AsyncSeriesHook(["params"]),74 /** @type {SyncHook<CompilationParams>} */75 compile: new SyncHook(["params"]),76 /** @type {AsyncParallelHook<Compilation>} */77 make: new AsyncParallelHook(["compilation"]),78 /** @type {AsyncSeriesHook<Compilation>} */79 afterCompile: new AsyncSeriesHook(["compilation"]),80
On top of classic "Sync" (0) events handling, it also adds "AsyncSeries" (1), "AsyncParallel" (2) (handlers called in a row or parallel, respectively).
On the scheme below you can see another example where plugins use a few more advanced hooks sub-classes from tapable
("Waterfall" and "Bail").
49 });50 });51 compiler.hooks.compilation.tap("SideEffectsFlagPlugin", compilation => {52 compilation.hooks.optimizeDependencies.tap(53 "SideEffectsFlagPlugin",54 modules => {55 /** @type {Map<Module, Map<string, ExportInModule>>} */56 const reexportMaps = new Map();5758 // Capture reexports of sideEffectFree modules
25class FlagDependencyUsagePlugin {26 apply(compiler) {27 compiler.hooks.compilation.tap("FlagDependencyUsagePlugin", compilation => {28 compilation.hooks.optimizeDependencies.tap(29 "FlagDependencyUsagePlugin",30 modules => {31 const processModule = (module, usedExports) => {32 module.used = true;33 if (module.usedExports === true) return;34 if (usedExports === true) {
1284 this.hooks.seal.call();12851286 while (1287 this.hooks.optimizeDependenciesBasic.call(this.modules) ||1288 this.hooks.optimizeDependencies.call(this.modules) ||1289 this.hooks.optimizeDependenciesAdvanced.call(this.modules)1290 ) {1291 /* empty */1292 }1293 this.hooks.afterOptimizeDependencies.call(this.modules);
Using them with callback
allows us to pass data between different subscribers (Waterfall) or exit early (Bail).
Event-driven architecture has some pros&cons. Like any other architectures there is no silver bullet. Within webpack, however, it brings endless scalability and works very well with a pipeline of loosely-coupled plugins.
To learn more about tapable
library checkout tapable repository.