Webpack整体架构的实现就是靠它的插件系统,其中Compiler和Compilation负责管理整个构建流程,同时暴露出一些Hook
,然后由不同职责的插件来监听这些Hook
,并在合适的时机完成具体的工作。Tapable
是整个Webpack插件系统的核心,Webpack中的所有插件都继承了Tapable
类(Webpack5中已不再继承)。而从Tapable
的一些特性中可以看出,Tapable
其实是一种增强版的发布订阅模式。先看看Tapable
提供了哪些Hook
:
序号 | Hook类型 | Hook名称 | 监听方法 | 是否可并行 |
---|---|---|---|---|
1 | SyncHook | 同步钩子 | tap | 否 |
2 | SyncBailHook | 同步熔断钩子 | tap | 否 |
3 | SyncWaterfallHook | 同步瀑布钩子 | tap | 否 |
4 | SyncLoopHook | 同步循环钩子 | tap | 否 |
5 | AsyncParallelHook | 异步并行钩子 | tap | tapAsync |
6 | AsyncParallelBailHook | 异步并行熔断钩子 | tap | tapAsync |
7 | AsyncSeriesHook | 异步串行钩子 | tap | tapAsync |
8 | AsyncSeriesBailHook | 异步串行熔断钩子 | tap | tapAsync |
9 | AsyncSeriesLoopHook | 异步串行循环钩子 | tap | tapAsync |
10 | AsyncSeriesWaterfallHook | 异步串行瀑布钩子 | tap | tapAsync |
Sync:同步。> Async:异步。> Bail:当一个
hook
注册了多个回调方法,若任意一个回调方法返回了不为undefined
的值,就不再执行后面的回调方法。> Waterfall:当一个hook
注册了多个回调方法,前一个回调执行完了才会执行下一个回调,而前一个回调的执行结果会作为参数传给下一个回调函数。> Loop:当一个hook
注册了回调方法,如果这个回调方法返回了true
就重复循环这个回调,只有当这个回调返回undefined
才执行下一个回调。> Parallel:当一个hook
注册了多个回调方法,这些回调同时开始并行执行。> Series:当一个hook
注册了多个回调方法,前一个执行完了才会执行下一个。
下载Tapable
(v2.2.1)的源码,打开/index.js:
exports.SyncHook = require("./SyncHook");
exports.SyncBailHook = require("./SyncBailHook");
exports.SyncWaterfallHook = require("./SyncWaterfallHook");
exports.SyncLoopHook = require("./SyncLoopHook");
exports.AsyncParallelHook = require("./AsyncParallelHook");
exports.AsyncParallelBailHook = require("./AsyncParallelBailHook");
exports.AsyncSeriesHook = require("./AsyncSeriesHook");
exports.AsyncSeriesBailHook = require("./AsyncSeriesBailHook");
exports.AsyncSeriesLoopHook = require("./AsyncSeriesLoopHook");
exports.AsyncSeriesWaterfallHook = require("./AsyncSeriesWaterfallHook");
exports.HookMap = require("./HookMap");
exports.MultiHook = require("./MultiHook");
可以看到,Tapable
导出了10个钩子函数和2个工具类。其中每一个…Hook.js文件都对应一个钩子函数的实现。而HookMap.js和MultiHook.js分别定义了Tapable
的两个工具类:HookMap和MultiHook。余下两个文件:Hook.js和HookCodeFactory.js分别定义了Tapable的核心类:Hook
和HookCodeFactory
,Tapable
中的所有钩子函数都继承自这两个类。
在软件架构中,发布订阅是一种消息范式,消息的发送者(称为发布者)不会将消息直接发送给特定的接收者(称为订阅者)。而是将发布的消息分为不同的类别,无需了解哪些订阅者(如果有的话)可能存在。同样的,订阅者可以表达对一个或多个类别的兴趣,只接收感兴趣的消息,无需了解哪些发布者(如果有的话)存在。
SyncHook
是Tapable
中最基础的一个钩子,也最接近发布订阅模式的一个钩子。其余的钩子除了实现发布订阅外,还包含了不同类型的流程控制。
我们即将通过一个SyncHook
钩子的简单应用示例来了解SyncHook
的内部实现,先来看一个简单的应用示例:
const { SyncHook } = require('tapable');
// 实例化
const hook = new SyncHook(['width', 'height']);
const options = { name: "synchook" };
// 订阅
hook.tap(options, (width, height) => {console.log('callback1', width, height);
});
// 订阅
hook.tap(options, (width, height) => {console.log('callback2', width, height);
});
// 订阅
hook.tap(options, (width, height) => {console.log('callback3', width, height);
});
// 执行
hook.call(100, 200);
console.log(hook.call)
输出结果:
callback1 100 200
callback2 100 200
callback3 100 200
ƒ anonymous(width, height) {
"use strict";
var _context;
var _x = this._x;
var _fn0 = _x[0];
_fn0(width, height);
var _fn1 = _x[1];
_fn1(width, height);
var _fn2 = _x[2];
_fn2(width, height);
}
hook.tap()
以上的订阅过程发生了什么?我们来看/SyncHook.js文件的源码:
const Hook = require("./Hook");
const HookCodeFactory = require("./HookCodeFactory");
class SyncHookCodeFactory extends HookCodeFactory {content({ onError, onDone, rethrowIfPossible }) {return this.callTapsSeries({onError: (i, err) => onError(err),onDone,rethrowIfPossible});}
}
const factory = new SyncHookCodeFactory();
const TAP_ASYNC = () => {throw new Error("tapAsync is not supported on a SyncHook");
};
const TAP_PROMISE = () => {throw new Error("tapPromise is not supported on a SyncHook");
};
const COMPILE = function (options) {factory.setup(this, options);return factory.create(options);
};
function SyncHook(args = [], name = undefined) {const hook = new Hook(args, name);hook.constructor = SyncHook;hook.tapAsync = TAP_ASYNC;hook.tapPromise = TAP_PROMISE;hook.compile = COMPILE;return hook;
}
SyncHook.prototype = null;
module.exports = SyncHook;
可以看到,SyncHook
函数的tap
方法继承自Hook
类,所以打开/hook.js,找到Hook
的tap
方法:
tap()
tap(options, fn) {this._tap("sync", options, fn);
}
Hook
中的tap
方法调用_tap
方法并传入三个参数,其中"sync"
表示当前订阅的函数为同步。接下来看_tap
的内容:
_tap()
_tap(type, options, fn) {if (typeof options === "string") {options = {name: options.trim()};} else if (typeof options !== "object" || options === null) {throw new Error("Invalid tap options");}if (typeof options.name !== "string" || options.name === "") {throw new Error("Missing name for tap");}if (typeof options.context !== "undefined") {deprecateContext();}options = Object.assign({ type, fn }, options);options = this._runRegisterInterceptors(options);this._insert(options);
}
_tap
做了以下工作:
那么_insert
做了什么工作呢?来看_insert
的内部代码:
_insert()
_insert(item) {this._resetCompilation();let before;if (typeof item.before === "string") {before = new Set([item.before]);} else if (Array.isArray(item.before)) {before = new Set(item.before);}let stage = 0;if (typeof item.stage === "number") {stage = item.stage;}let i = this.taps.length;while (i > 0) {i--;const x = this.taps[i];this.taps[i + 1] = x;const xStage = x.stage || 0;if (before) {if (before.has(x.name)) {before.delete(x.name);continue;}if (before.size > 0) {continue;}}if (xStage > stage) {continue;}i++;break;}this.taps[i] = item;
}
_insert
方法做了以下工作:
控制台输出hook.taps
,可以看到this.taps
存储的内容如下:
console.log(hook.taps)
// 输出:
[{ "type": "sync", "name": "synchook", fn: ƒ },{ "type": "sync", "name": "synchook", fn: ƒ },{ "type": "sync", "name": "synchook", fn: ƒ }
]
到此,hook
函数的一个订阅过程就基本完成了。
hook.call()
hook
的执行过程即hook.call()
调用后发生了什么?从上文/SyncHook.js文件的源码中我们可以看到,SyncHook
函数的call
方法继承自Hook
类。
call()
打开/Hook.js文件查看源码,找到Hook
的call
方法:
const CALL_DELEGATE = function (...args) {this.call = this._createCall("sync");return this.call(...args);
};
class Hook {constructor(args = [], name = undefined) {this._args = args;this.name = name;this.taps = [];this.interceptors = [];// 省略部分代码...this.call = CALL_DELEGATE;// 省略部分代码...}// 省略部分代码..._createCall(type) {return this.compile({taps: this.taps, // 保存的是此前的订阅s,即hook.tapsinterceptors: this.interceptors, // 拦截器args: this._args, // 创建实例时传入的参数:['width', 'height']type: type // 'sync'});}// 省略部分代码...
}
由此可知,call()
内部调用了hook
的compile
方法,接下来看compile
方法的内容:
compile()
compile
方法实际在/SyncHook.js文件中被重写,所以看/SyncHook.js文件的源码:
function SyncHook(args = [], name = undefined) {// 省略部分代码...hook.compile = COMPILE;// 省略部分代码...
}
const factory = new SyncHookCodeFactory();
const COMPILE = function (options) {factory.setup(this, options);return factory.create(options);
};
可以看到,compile
做了两件事:
1.factory.setup(this, options);
2.return factory.create(options);
factory.setup()
// 此段代码来自源文件/HookCodeFactory.js
setup(instance, options) {instance._x = options.taps.map(t => t.fn);
}
将保存在taps
上的fn
提取并保存到this._x
上。打印结果:
console.log(hook._x)
// 输出:
(3) [ƒ, ƒ, ƒ]0: ƒ (width, height)1: ƒ (width, height)2: ƒ (width, height)
factory.create()
create
方法的作用是根据type不同,拼接出不同的代码字符串并且通过new Function
创建一个函数,赋值给fn,最后返回fn。从上文可知this.options.type
的值为'sync'
,所以我们现在只需要关注type是'sync'
的case分支:
// 此段代码来自源文件/HookCodeFactory.js
create(options) {this.init(options);let fn;switch (this.options.type) {case "sync":fn = new Function(this.args(),'"use strict";\n' +this.header() +this.contentWithInterceptors({onError: err => `throw ${err};\n`,onResult: result => `return ${result};\n`,resultReturns: true,onDone: () => "",rethrowIfPossible: true}));break;case "async":// 省略部分代码...break;case "promise":// 省略部分代码...break;}this.deinit();return fn;
}
为了便于理解create
方法,我们先了解新建函数的语法:
let fn = new Function ([arg1[, arg2[, ...argN]],] functionBody)
可见create
中的this.args()对应返回新建函数fn的参数,this.header() + this.contentWithInterceptors()对应返回新建函数fn的函数体字符串。其中this.args()
和 this.header()
的逻辑比较简单:
args()
args({ before, after } = {}) {let allArgs = this._args;if (before) allArgs = [before].concat(allArgs);if (after) allArgs = allArgs.concat(after);if (allArgs.length === 0) {return "";} else {return allArgs.join(", ");}
}
args
方法将传入的参数用,
拼接为字符串并返回。
header()
header() {let code = "";if (this.needContext()) {code += "var _context = {};\n";} else {code += "var _context;\n";}code += "var _x = this._x;\n";if (this.options.interceptors.length > 0) {code += "var _taps = this.taps;\n";code += "var _interceptors = this.interceptors;\n";}return code;
}
header
方法做了以下工作:
contentWithInterceptors()
contentWithInterceptors
主要为了处理拦截器。因为本例不含拦截器,所以略过,直接进入下一步。
contentWithInterceptors(options) {if (this.options.interceptors.length > 0) {// 省略部分代码...} else {return this.content(options);}
}
content()
content
方法见/SyncHook.js文件,在HookCodeFactory的子类SyncHookCodeFactory中被定义:
class SyncHookCodeFactory extends HookCodeFactory {content({ onError, onDone, rethrowIfPossible }) {return this.callTapsSeries({onError: (i, err) => onError(err),onDone,rethrowIfPossible});}
}
content
方法调用并返回callTapsSeries()
,接下来分析callTapsSeries()
:
callTapsSeries()
callTapsSeries({onError,onResult,resultReturns,onDone,doneReturns,rethrowIfPossible}) {if (this.options.taps.length === 0) return onDone();const firstAsync = this.options.taps.findIndex(t => t.type !== "sync");const somethingReturns = resultReturns || doneReturns;let code = "";let current = onDone;let unrollCounter = 0;for (let j = this.options.taps.length - 1; j >= 0; j--) {const i = j;const unroll =current !== onDone &&(this.options.taps[i].type !== "sync" || unrollCounter++ > 20);if (unroll) {unrollCounter = 0;code += `function _next${i}() {\n`;code += current();code += `}\n`;current = () => `${somethingReturns ? "return " : ""}_next${i}();\n`;}const done = current;const doneBreak = skipDone => {if (skipDone) return "";return onDone();};const content = this.callTap(i, {onError: error => onError(i, error, done, doneBreak),onResult:onResult &&(result => {return onResult(i, result, done, doneBreak);}),onDone: !onResult && done,rethrowIfPossible:rethrowIfPossible && (firstAsync < 0 || i < firstAsync)});current = () => content;}code += current();return code;}
callTapsSeries
方法做了以下工作:
1.判断是否存在订阅函数,若不存在则直接返回空字符串;
2.定义函数current,并将其赋值为初始传入的onDone: () => “”;
3.倒序遍历taps,将current赋值给函数done;
4.将done传入this.callTap()并执行,再将this.callTap的返回值重新赋值给current;
5.重复步骤3-4;
6.执行current并返回拼接的字符串code。
也就是说函数current和done的作用就是为了缓存前一次循环拼接出的字符串并将此次拼接的字符串传递到下一次循环。
那么,想知道在循环中拼接字符串的具体内容?重点看下面的callTap
方法:
callTap()
由于本例中的type
的值为'sync'
,且不存在拦截器,所以重点看以下相关代码即可:
callTap(tapIndex, { onError, onResult, onDone, rethrowIfPossible }) {let code = "";let hasTapCached = false;for (let i = 0; i < this.options.interceptors.length; i++) {// 省略部分代码...}code += `var _fn${tapIndex} = ${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}(${this.args({before: tap.context ? "_context" : undefined})});\n`;} else {code += `_fn${tapIndex}(${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;// 省略余下代码...}return code;
}
callTap
方法做了以下工作(以第一个tap为例):
1.初始变量code为空字符串;
2.拼接当前订阅函数的定义:var _fn0 = _x[0];
;
3.进入case 'sync’分支;
4.判断rethrowIfPossible,值为false则拼接var _hasError0 = false;
和try {
;
5.判断是否存在onResult,为true则拼接var _result0 = _fn0(args);
,为false则拼接_fn0(args);
;
6.判断rethrowIfPossible,值为false则拼接} catch(_err) {
和_hasError0 = true;
和throw _err;}
和if(!_hasError0) {
;
7.判断是否存在onResult,为true则拼接return _result0;
;
8.判断是否存在onDone,为true则拼接done,即上一次循环缓存的current;
9.判断rethrowIfPossible,值为false则拼接}
,即闭合代码块;
10.返回拼接的字符串code;
从上文的内容可知,本例中的rethrowIfPossible == true;
,onResult == undfined;
,因此步骤4-6-7-9均不会执行。
那么重新梳理逻辑可知本例中的callTap
方法实际做了以下工作(以第一个tap为例):
1.初始变量code为空字符串;
2.拼接当前订阅函数的定义:var _fn0 = _x[0];
;
3.进入case 'sync’分支;
4.判断onResult,拼接_fn0(args);
;
5.判断是否存在onDone,为true则拼接done,即上一次循环缓存的current;
6.返回拼接的字符串code;
**以上便是完整的拼接流程,最终的拼接结果将通过new Function
创建出一个新的函数并赋值给call
方法。**最后,整理了75个JS高频面试题,并给出了答案和解析,基本上可以保证你能应付面试官关于JS的提问。
有需要的小伙伴,可以点击下方卡片领取,无偿分享