Author: Cui Jing

Last time we looked at the overall WebPack compilation process, this time we’ll look at the basics of Tapable.

introduce

Webpack has exposed a large number of hooks for internal/external plug-ins, and supports the extension of various plug-ins. The internal processing code also relies on hooks and plug-ins, and this part of the functionality depends on Tapable. The overall implementation of WebPack is, in general, event-driven. From one event to the next. Tapable is used to provide various types of hooks. Let’s take a look at Tapable with an intuitive example:

const {
  SyncHook
} = require('tapable')

// Create a synchronization Hook, specifying parameters
const hook = new SyncHook(['arg1'.'arg2'])

/ / register
hook.tap('a'.function (arg1, arg2) {
	console.log('a')
})

hook.tap('b'.function (arg1, arg2) {
	console.log('b')
})

hook.call(1.2)
Copy the code

It appears to function similar to EventEmit, registering an event and then firing it. However, Tapable is more powerful than EventEmit. From the official introduction, it can be seen that Tapable provides many types of hooks, which are divided into synchronous and asynchronous categories (asynchronous is divided into asynchronous parallel and asynchronous serial). According to the different termination conditions of event execution, the Bail/Waterfall/Loop type is derived.

The following figure shows what each type does:

  • AsyncParallelHook AsyncSeriesHook AsyncSeriesHook AsyncSeriesHook

    This type of hook is very common in the eventEmit type we normally use.

  • BailHook: execute Hook in order, and the first result is result! == undefined returns and the execution is not continued. There are SyncBailHook, AsyncSeriseBailHook, and AsyncParallelBailHook.

    In what situations do you use bailhooks? Consider the following example: Suppose we have A module M and package it as A single module if it satisfies any of the conditions of A or B or C. AsyncParallelBailHook = AsyncParallelBailHook = AsyncParallelBailHook = AsyncParallelBailHook

    X.hoooks. Split module hook.tap ('A', () = > {if(A) {return true}}) x.hoooks. Split module Hook. Tap ('B', () = > {if(B) {return true}}) x.hoooks. Split module Hook. Tap ('C', () = > {if(C) {return true}})Copy the code

    If true is returned in A, there is no need to determine B and C. However, when A, B, and C are checked in strict order, we need to use sequential SyncBailHook(used when A, B, and C are synchronous functions) or AsyncSeriseBailHook(used when A, B, and C are asynchronous functions).

  • WaterfallHook: Similar to reduce, if the result of the previous Hook function result! == undefined, then result will be the first argument to the Hook function. Sync and AsyncSeries classes only provide this Hook: SyncWaterfallHook, AsyncSeriesWaterfallHook

    When A data needs to go through A, B and C stages to get the final result, and if A satisfies the condition A, it will be processed; otherwise, it will not be processed. B and C are the same, then the following can be used

    x.hooks.tap('A', (data) => {
       if(satisfies the condition that A needs to process) {// Processing data
         return data
       } else {
         return
       }
     })
    x.hooks.tap('B', (data) => {
       if(satisfies the condition that B needs to process) {// Processing data
         return data
       } else {
         return
       }
     })
     x.hooks.tap('C', (data) => {
       if(C needs to deal with the condition) {// Processing data
         return data
       } else {
         return}})Copy the code
  • LoopHook: LoopHook until all functions result === undefined. Also, because of the dependency on serialization, only SyncLoopHook and AsyncSeriseLoopHook are available.

The principle of

Let’s start with the main context of the Tapable code:

Hook event registration — > Hook trigger — > generate hook execution code — > Execute

The hook class diagram is simple. Each hook inherits from a basic abstract hook class and contains an xxxCodeFactory class inside, which will be used to generate the execution code of the hook.

Event registration

The basic logic of Tapable is to register the handler function of the corresponding Hook via the tap method of the class instance:

Tapable provides three ways to register events (implementation logic in Hook base class), one for tap/tapAsync/tapPromise, and the other for tapAsync/tapPromise. Assign a different type value to the content to be pushed into the TAPS, as shown in the figure above.

For SyncHook, SyncBailHook, SyncLoopHook, and SyncWaterfallHook, the tapAsync and tapPromise methods in the base class will be overwritten. Prevents consumers from misusing asynchronous methods in synchronous hooks.

	tapAsync() {
		throw new Error("tapAsync is not supported on a SyncHook");
	}
	tapPromise() {
		throw new Error("tapPromise is not supported on a SyncHook");
	}
Copy the code

Events trigger

Corresponding to TAP /tapAsync/tapPromise, Tapable provides three methods for triggering events: call/callAsync/promise. These three methods are also located in the base class Hook, and the specific logic is as follows

this.call = this._call = this._createCompileDelegate("call"."sync");
this.promise = this._promise = this._createCompileDelegate("promise"."promise");
this.callAsync = this._callAsync = this._createCompileDelegate("callAsync"."async"); 
   // ...
_createCall(type) {
	return this.compile({
		taps: this.taps,
		interceptors: this.interceptors,
		args: this._args,
		type: type
	});
}

_createCompileDelegate(name, type) {
	const lazyCompileHook = (. args) = > {
		this[name] = this._createCall(type);
		return this[name](... args); };return lazyCompileHook;
}
Copy the code

Both call, callAsync, and Promise are eventually called to the compile method. Before that, the difference is the type value passed in the compile method. Compile generates an executable function based on the different types and then executes the function.

Note that the above code has a variable named lazyCompileHook, lazy compilation. When we new the Hook, it actually becomes the CompileDelegate code for promise, Call, callAsync, and its actual structure is

this.call = (. args) = > {
	this[name] = this._createCall('sync');
	return this['call'] (... args); }this.promise = (. args) = > {
	this[name] = this._createCall('promise');
	return this['promise'] (... args); }this.callAsync = (. args) = > {
	this[name] = this._createCall('async');
	return this['callAsync'] (... args); }Copy the code

The corresponding execution function is compiled when the hook is triggered, such as when xxhook.call() is executed. This process is known as “lazy compilation”, that is, compiling when it is time to achieve optimal performance.

Next we’ll focus on the compile logic, which is where most of the logic in Tapable lies.

Performing code generation

Before looking at the source code, we can write a few simple demos to see what kind of execution code Tapable will eventually generate, to get a feel for it:

Above are respectively SyncHook. Call, AsyncSeriesHook callAsync and AsyncSeriesHook promise generated code. _x saves the registered event functions, _fn${index} is the execution of each function, and the generated code will have different execution methods according to different hooks and different invocation modes. How are these differences generated through the code? Let’s look closely at the compile method.

The compile method is not implemented in the base class; it is implemented in the derived classes. Take a look at SyncHook, for example

class SyncHookCodeFactory extends HookCodeFactory {
	content({ onError, onResult, onDone, rethrowIfPossible }) {
		return this.callTapsSeries({
			onError: (i, err) = >onError(err), onDone, rethrowIfPossible }); }}const factory = new SyncHookCodeFactory();

class SyncHook extends Hook {
   / /... Omit other code
	compile(options) {
		factory.setup(this, options);
		returnfactory.create(options); }}Copy the code

The factory pattern is used to generate executable code: HookCodeFactory is a factory base class used to generate code, with one subclass derived from each Hook. Compile calls the create method in all hooks. So let’s look at what this create method does.

create(options) {
	this.init(options);
	switch(this.options.type) {
		case "sync":
			return new Function(this.args(), "\"use strict\"; \n" + this.header() + this.content({
				onError: err= > `throw ${err}; \n`.onResult: result= > `return ${result}; \n`.onDone: (a)= > "".rethrowIfPossible: true
			}));
		case "async":
			return new Function(this.args({
				after: "_callback"
			}), "\"use strict\"; \n" + this.header() + this.content({
				onError: err= > `_callback(${err}); \n`.onResult: result= > `_callback(null, ${result}); \n`.onDone: (a)= > "_callback(); \n"
			}));
		case "promise":
			let code = "";
			code += "\"use strict\"; \n";
			code += "return new Promise((_resolve, _reject) => {\n";
			code += "var _sync = true; \n";
			code += this.header();
			code += this.content({
				onError: err= > {
					let code = "";
					code += "if(_sync)\n";
					code += `_resolve(Promise.resolve().then(() => { throw ${err}; })); \n`;
					code += "else\n";
					code += `_reject(${err}); \n`;
					return code;
				},
				onResult: result= > `_resolve(${result}); \n`.onDone: (a)= > "_resolve(); \n"
			});
			code += "_sync = false; \n";
			code += "}); \n";
			return new Function(this.args(), code); }}Copy the code

At first glance, it looks like a lot of code, but let’s simplify it and draw a diagram. Here’s the process:

As you can see, create implements only the main template of the code, implements the common parts (the function arguments and the public arguments at the beginning of the function), and leaves the different parts of the content to each subclass to implement. Then compare the children of HookCodeFactory in each Hook to see how content is implemented:

//syncHook
class SyncHookCodeFactory extends HookCodeFactory {
	content({ onError, onResult, onDone, rethrowIfPossible }) {
		return this.callTapsSeries({
			onError: (i, err) = >onError(err), onDone, rethrowIfPossible }); }}//syncBailHook
content({ onError, onResult, onDone, rethrowIfPossible }) {
	return this.callTapsSeries({
		onError: (i, err) = > onError(err),
		onResult: (i, result, next) = > `if(${result}! == undefined) {\n${onResult(result)}; \n} else {\n${next()}}\n`,
		onDone,
		rethrowIfPossible
	});
}
//AsyncSeriesLoopHook
class AsyncSeriesLoopHookCodeFactory extends HookCodeFactory {
	content({ onError, onDone }) {
		return this.callTapsLooping({
			onError: (i, err, next, doneBreak) = > onError(err) + doneBreak(true), onDone }); }}// The other structures are similar, so I won't post code here
Copy the code

As you can see, in all of the subclasses, the content method is implemented, depending on the process of executing the hook, Call the callTapsSeries callTapsParallel/callTapsLooping and onError, onResult, onDone, rethrowIfPossible this 4 cases, the code snippet.

CallTapsSeries/callTapsParallel/callTapsLooping in the base class method, the three methods will all come to a callTap method. Take a look at the callTap method first. The code is quite long, so if you don’t want to look at the code you can just look at the diagram.

callTap(tapIndex, { onError, onResult, onDone, rethrowIfPossible }) {
	let code = "";
	let hasTapCached = false;
	// In this case, the interceptors are ignored
	for(let i = 0; i < this.options.interceptors.length; i++) {
		const interceptor = this.options.interceptors[i];
		if(interceptor.tap) {
			if(! hasTapCached) { code +=`var _tap${tapIndex} = The ${this.getTap(tapIndex)}; \n`;
				hasTapCached = true;
			}
			code += `The ${this.getInterceptor(i)}.tap(${interceptor.context ? "_context, " : ""}_tap${tapIndex}); \n`;
		}
	}
	code += `var _fn${tapIndex} = The ${this.getTapFn(tapIndex)}; \n`;
	const tap = this.options.taps[tapIndex];
	switch(tap.type) {
		case "sync":
			if(! rethrowIfPossible) { code +=`var _hasError${tapIndex}= false; \n`;
				code += "try {\n";
			}
			if(onResult) {
				code += `var _result${tapIndex} = _fn${tapIndex}(The ${this.args({
					before: tap.context ? "_context" : undefined
				})}); \n`;
			} else {
				code += `_fn${tapIndex}(The ${this.args({
					before: tap.context ? "_context" : undefined
				})}); \n`;
			}
			if(! rethrowIfPossible) { code +="} catch(_err) {\n";
				code += `_hasError${tapIndex}= true; \n`;
				code += onError("_err");
				code += "}\n";
				code += `if(! _hasError${tapIndex}) {\n`;
			}
			if(onResult) {
				code += onResult(`_result${tapIndex}`);
			}
			if(onDone) {
				code += onDone();
			}
			if(! rethrowIfPossible) { code +="}\n";
			}
			break;
		case "async":
			let cbCode = "";
			if(onResult)
				cbCode += `(_err${tapIndex}, _result${tapIndex}) => {\n`;
			else
				cbCode += `_err${tapIndex} => {\n`;
			cbCode += `if(_err${tapIndex}) {\n`;
			cbCode += onError(`_err${tapIndex}`);
			cbCode += "} else {\n";
			if(onResult) {
				cbCode += onResult(`_result${tapIndex}`);
			}
			if(onDone) {
				cbCode += onDone();
			}
			cbCode += "}\n";
			cbCode += "}";
			code += `_fn${tapIndex}(The ${this.args({
				before: tap.context ? "_context" : undefined,
				after: cbCode
			})}); \n`;
			break;
		case "promise":
			code += `var _hasResult${tapIndex}= false; \n`;
			code += `_fn${tapIndex}(The ${this.args({
				before: tap.context ? "_context" : undefined
			})}).then(_result${tapIndex} => {\n`;
			code += `_hasResult${tapIndex}= true; \n`;
			if(onResult) {
				code += onResult(`_result${tapIndex}`);
			}
			if(onDone) {
				code += onDone();
			}
			code += `}, _err${tapIndex} => {\n`;
			code += `if(_hasResult${tapIndex}) throw _err${tapIndex}; \n`;
			code += onError(`_err${tapIndex}`);
			code += "}); \n";
			break;
	}
	return code;
}
Copy the code

Sync/Async/Promise. The above code is translated into the figure below

  • Sync type:

  • Async type:

  • Promise type

In general, callTap is a template for the execution of a function, which can be divided into sync/async/promise according to the different invocation modes.

And then if I look at the callTapsSeries method,

callTapsSeries({ onError, onResult, onDone, rethrowIfPossible }) {
	if(this.options.taps.length === 0)
		return onDone();
	const firstAsync = this.options.taps.findIndex(t= >t.type ! = ="sync");
	const next = i= > {
		if(i >= this.options.taps.length) {
			return onDone();
		}
		const done = (a)= > next(i + 1);
		const doneBreak = (skipDone) = > {
			if(skipDone) return "";
			return onDone();
		}
		return this.callTap(i, {
			onError: error= > onError(i, error, done, doneBreak),
			OnResult = onDone; onResult = onDone
			onResult: onResult && ((result) = > {
				return onResult(i, result, done, doneBreak);
			}),
			onDone: !onResult && (() = > {
				return done();
			}),
			rethrowIfPossible: rethrowIfPossible && (firstAsync < 0 || i < firstAsync)
		});
	};
	return next(0);
}
Copy the code

Note the condition for onResult and onDone in this.callTap, which means that either onResult or onDone is executed. Let’s start with the simple onDone logic. Then combined with the above callTap process, taking Sync as an example, we can get the following figure:

In this case, the result of callTapsSeries is to recursively generate the code for each call until the last one, when the terminating code is obtained by calling the onDone method directly from the outside, and the recursion ends. For the process of executing onResult, look at the onResult code: return onResult(I, result, done, doneBreak). Simple understanding, the process is the same as the above figure, but in the done with onResult wrapped in a layer of logic about onResult.

Next, let’s look at the code for callTapsLooping:

callTapsLooping({ onError, onDone, rethrowIfPossible }) {
	if(this.options.taps.length === 0)
		return onDone();
	const syncOnly = this.options.taps.every(t= > t.type === "sync");
	let code = "";
	if(! syncOnly) { code +="var _looper = () => {\n";
		code += "var _loopAsync = false; \n";
	}
	// Add the do logic before the code starts
	code += "var _loop; \n";
	code += "do {\n";
	code += "_loop = false; \n";
	// Interceptors ignore the main part
	for(let i = 0; i < this.options.interceptors.length; i++) {
		const interceptor = this.options.interceptors[i];
		if(interceptor.loop) {
			code += `The ${this.getInterceptor(i)}.loop(The ${this.args({
				before: interceptor.context ? "_context" : undefined
			})}); \n`;
		}
	}
	code += this.callTapsSeries({
		onError,
		onResult: (i, result, next, doneBreak) = > {
			let code = "";
			code += `if(${result}! == undefined) {\n`;
			code += "_loop = true; \n";
			if(! syncOnly) code +="if(_loopAsync) _looper(); \n";
			code += doneBreak(true);
			code += `} else {\n`;
			code += next();
			code += `}\n`;
			return code;
		},
		onDone: onDone && (() = > {
			let code = "";
			code += "if(! _loop) {\n";
			code += onDone();
			code += "}\n";
			return code;
		}),
		rethrowIfPossible: rethrowIfPossible && syncOnly
	})
	code += "} while(_loop); \n";
	if(! syncOnly) { code +="_loopAsync = true; \n";
		code += "}; \n";
		code += "_looper(); \n";
	}
	return code;
}
Copy the code

So the simplest logic to simplify to is this one right here, very simple do/while logic.

var _loop
do {
  _loop = false
  // callTapsSeries generates the middle part of the code
} while(_loop)
Copy the code

So callTapsSeries, you know the code, but when you call callTapsSeries, you have onResult logic, which means that the middle part will generate something like this (again, sync)

var _fn${tapIndex} = _x[${tapIndex}];
var _hasError${tapIndex} = false; 
  try {

    fn1(${this.args({
        before: tap.context ? "_context" : undefined
    })});
} catch(_err) { 
  _hasError${tapIndex} = true;
  onError("_err");
}
if(! _hasError${tapIndex}) {// The code generated in onResult
   if(${result} ! = =undefined) {
	  _loop = true;
	  // doneBreak is in the callTapsSeries code
	  //(skipDone) => {
	  // if(skipDone) return "";
	  // return onDone();
	  //	}
	  doneBreak(true); // The actual statement is null
	} else {
	  next()
	}
}
Copy the code

The LoopHook logic is derived from the callTapsSeries by controlling the difference between the generated code in the onResult between the time a function completes execution and the time it executes the next function.

Then let’s look at callTapsParallel

callTapsParallel({ onError, onResult, onDone, rethrowIfPossible, onTap = (i, run) = > run() }) {
	if(this.options.taps.length <= 1) {
		return this.callTapsSeries({ onError, onResult, onDone, rethrowIfPossible })
	}
	let code = "";
	code += "do {\n";
	code += `var _counter = The ${this.options.taps.length}; \n`;
	if(onDone) {
		code += "var _done = () => {\n";
		code += onDone();
		code += "}; \n";
	}
	for(let i = 0; i < this.options.taps.length; i++) {
		const done = (a)= > {
			if(onDone)
				return "if(--_counter === 0) _done(); \n";
			else
				return "--_counter;";
		};
		const doneBreak = (skipDone) = > {
			if(skipDone || ! onDone)return "_counter = 0; \n";
			else
				return "_counter = 0; \n_done(); \n";
		}
		code += "if(_counter <= 0) break; \n";
		code += onTap(i, () => this.callTap(i, {
			onError: error= > {
				let code = "";
				code += "if(_counter > 0) {\n";
				code += onError(i, error, done, doneBreak);
				code += "}\n";
				return code;
			},
			onResult: onResult && ((result) = > {
				let code = "";
				code += "if(_counter > 0) {\n";
				code += onResult(i, result, done, doneBreak);
				code += "}\n";
				return code;
			}),
			onDone: !onResult && (() = > {
				return done();
			}),
			rethrowIfPossible
		}), done, doneBreak);
	}
	code += "} while(false); \n";
	return code;
}
Copy the code

Because callTapsParallel ultimately generates code that executes concurrently, the code flow is quite different from the two. The above code looks like a lot of code, but to clarify the main structure, it is actually the following diagram (again using sync as an example)

Summary callTap implements sync/ Promise /async three basic one-time function execution templates, while leaving the onError/onDone/onResult section of the code involving the process of function execution. CallTapsSeries/callTapsLooping/callTapsParallel, by passing in different onError/onDone/onResult achieve different process templates. CallTapsParallel, gradient based onTap, gradient based onTap, gradient based onTap, gradient based onTap

We have obtained the base templates for series/ Looping/PARALLEL. We have noticed that callTapsSeries/callTapsLooping/callTapsParallel also exposed the onError, onResult, onDone, rethrowIfPossible, onTap, Thus, each sub-hook can be customized to the base template according to different situations. In the case of SyncBailHook, the main difference between it and the base template from callTapsSeries is when the function ends. So for the SyncBailHook, you can modify onResult:

class SyncBailHookCodeFactory extends HookCodeFactory {
	content({ onError, onResult, onDone, rethrowIfPossible }) {
		returnthis.callTapsSeries({ onError: (I, err) => onError(err), onResult (err) => onResult (err), onResult (err) => onResult (err), onResult (err) => onResult (err), onResult (err) => onResult (err), onResult (err) => onResult (err); (i, result, next) => `if(${result}! == undefined) {\n${onResult(result)}; \n}else {\n${next()}}\n`, onDone, rethrowIfPossible }); }}Copy the code

Finally, let’s use a graph to summarize the idea of generating the final executed code in the compile part as a whole: summarize the common code template, split the differentiated part into functions and expose it to the external implementation.

conclusion

As a webPack underlying event stream library, Tapable provides a rich set of events compared to simple EventEmit. After the final event is triggered, the code for execution is generated dynamically first, and then executed through new Function. This is a much more efficient method of execution than if we were to simply walk through or recursively call each event. While there is little efficiency in writing code to see whether a loop is broken up into pieces or just for loops, there is a lot of this logic in WebPack because the whole thing is driven by events. The advantage of taking each function apart and executing it directly is obvious.