前言
在开始前,先说下为什么要做对比,以及这么做的好处是什么?
大概原因是这样的,假如我们分开去看各自的源码实现了解其内部原理,当然也会有一定的收获,但会由于信息来源比较单一没有对比参照,了解的可能不会太深入也较为容易遗忘,同时我们是跟着作者的思路走,比较被动的接受没有一个主动输出的过程。
而如果能把类似的系统放在一起多元化的对比,我们就能吸收到其他作者的精髓,货比三家,从而能进行一个主动的思考,为什么 A 这么实现而 B 那样实现?两种实现有什么异同,适用的场景有什么异同等等。
这样我们就会有更多主动思考的过程,能对其中原理吃的更透更深,印象也就更加深刻,反过来在我们日常工作开发中碰到类似的情况,也更容易回忆起来要怎么择优运用。
上文对比 4 种插件系统, 彻底拿捏相关问题(上)我们讲了 Redux
和 Koa
中的插件系统实现方案,本篇我们接着来看下大名鼎鼎的 Axios
和 Webpack
是如何实现插件系统的。
Axios 中的插件系统
在 Axios
中, Interceptors
其实就是插件,可以当作插件来看待。
Interceptors
有两种使用场景(类型),分为请求发出前调用( axios.interceptors.request.use
)和请求返回后调用( axios.interceptors.response.use
)。
请求发出前主要是对请求参数做一些调整,请求返回后主要是对返回结果进行一些调整。
根据用法我们来一步步探究其内部实现,首先在 Axios.js
文件中,
JavaScript
import InterceptorManager from './InterceptorManager.js';
class Axios {
constructor(instanceConfig) {
this.defaults = instanceConfig;
this.interceptors = {
request: new InterceptorManager(),
response: new InterceptorManager()
};
}
}
Axios
类的构造函数里初始化了 interceptors
对象,其中包含两个属性 request
和 response
,这两个分别来保存请求前 和返回后 的插件,且都是 InterceptorManager
类的实例。那我们下一步看 InterceptorManager
里都有什么了。
InterceptorManager 文件
在 InterceptorManager
中,
JavaScript
class InterceptorManager {
constructor() {
this.handlers = [];
}
...
use(fulfilled, rejected, options) {
this.handlers.push({
fulfilled,
rejected,
synchronous: options ? options.synchronous : false,
runWhen: options ? options.runWhen : null
});
return this.handlers.length - 1;
}
...
forEach(fn) {
utils.forEach(this.handlers, function forEachHandler(h) {
if (h !== null) {
fn(h);
}
});
}
构造函数初始化了 this.handlers
数组,以及 use
、 forEach
成员方法。
use
方法接受三个参数:
- fulfilled:成功时的回调
- rejected:失败时的回调
- options:额外参数
而在方法体中,将 fulfilled
、 rejected
和额外参数包裹为对象插入 this.handlers
数组中就完了。那什么时候消费这些信息呢,我们回到 Axios
文件中。
forEach
方法用来遍历 this.handlers
数组。
Axios 文件
上文我们提到 Axios
类的构造函数中初始化了 interceptors
。接下来我们就看下,初始化后如何使用。
JavaScript
class Axios {
...
request(configOrUrl, config) {
...
const requestInterceptorChain = [];
this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
...
requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected);
});x
const responseInterceptorChain = [];
this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
responseInterceptorChain.push(interceptor.fulfilled, interceptor.rejected);
});
}
}
`
这里首先对 this.interceptors.request
进行了遍历,拿到其中保存的对象,并将其存入到 requestInterceptorChain
数组中。此处的实现有点巧妙, 从 requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected)
可知,其实是从对象中分别拿到 成功回调
和 失败回调
,然后再插入到 requestInterceptorChain
头中。同理针对 response
也进行了类似的操作,不同的是插入到了 responseInterceptorChain
末尾。这么做的巧妙之处在于,方便了后续的调用。
JavaScript
let i = 0;
while (i < len) {
const onFulfilled = responseInterceptorChain[i++];
const onRejected = responseInterceptorChain[i++];
try {
newConfig = onFulfilled(newConfig);
} catch (error) {
onRejected.call(this, error);
break;
}
}
这里会遍历之前插入到 responseInterceptorChain
中的所有函数,通过 while
循环和两次 i++
即可拿到成功和失败回调,继而执行 onFulfilled
方法并把 config
传入,因为是 while
循环所以这里就形成了类似链式调用的效果。同理失败的话执行取出的 onRejected
函数即可。
而对于函数返回值为 promise
类型的函数,就通过 promise.then
包裹一下再进行调用即可。
到此, request
类型的插件就处理完毕了,接着我们看 response
类型的插件如何处理。
JavaScript
try {
promise = dispatchRequest.call(this, newConfig);
} catch (error) {
return Promise.reject(error);
}
i = 0;
len = responseInterceptorChain.length;
while (i < len) {
promise = promise.then(responseInterceptorChain[i++], responseInterceptorChain[i++]);
}
在调用 response
类型插件前,会先进行请求发送 。请求完毕返回值是一个 promise
对象,同样地通过 while
循环和两次 i++
取出成功和失败回调,并依次执行即可。
整体可以看出,在流程上 Axios
的插件调用方式和 Koa
很类似,都是通过链式调用的形式依次执行插件,不同的是 Axios
在执行过程中会有一个请求发送的阶段。
至此, Axios
插件机制其实就清楚了,我们接下来看 Webpack
是怎么做的。
Webpack
Webpack
官方网站有一段对插件的描述,我们一起看下,
Plugins are the backbone of webpack. Webpack itself is built on the same plugin system that you use in your webpack configuration!
插件是
Webpack
的基石能力,并且Webpack
内置的一些插件和用户定义的插件用的是同一套插件系统。
可知插件是它的重中之重,这么重要的能力当然要设计的足够强大,所以它将核心的插件系统抽成了一个独立的库 Tapable
来托管。那我们的分析就变成了两部分,一是探究下 Tapable
源码是如何实现的,二是看下 Tapable
是如何整合到 Webpack
中的。
在分析前我们先来回顾下, Webpack
插件的用法和写法。
插件的用法和写法
插件用法
JavaScript
// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack'); //to access built-in plugins
module.exports = {
module: {
rules: [{
test: /\.txt$/,
use: 'raw-loader'
}],
},
plugins: [new HtmlWebpackPlugin({
template: './src/index.html'
})],
};
简单说,插件需要通过 new
实例化并传入相关的参数,然后将其插入数组一并赋值给 plugins
属性。
插件写法
JavaScript
const pluginName = 'ConsoleLogOnBuildWebpackPlugin';
class ConsoleLogOnBuildWebpackPlugin {
constructor(options = {}) {
this.options = options;
}
apply(compiler) {
compiler.hooks.run.tap(pluginName, (compilation) => {
console.log('The webpack build process is starting!');
});
}
}
module.exports = ConsoleLogOnBuildWebpackPlugin;
插件可以用类的写法或者 Function
写法,区别在于用类的写法必须要有 apply
方法, Function
的话原型 prototype
上必须有 apply
方法。
首先,构造函数 constructor
里接受插件被实例化时传入的参数,可供插件执行过程中使用。
其次,这里的 apply
方法即是插件核心功能实现的入口了,其中 compiler.hooks.run.tap
这一行大意是注册一个函数,在 Webpack
开始执行时调用。
Tapable
接着来分析 compiler.hooks.run.tap
这一行是怎么被执行以及从何而来的。先来看一段源码。
触发流程
JavaScript
// webpack/lib/webpack.js
const webpack = (options, callback) => {
let compiler;
let watch = false;
let watchOptions;
...
const webpackOptions = options;
compiler = createCompiler(webpackOptions);
watch = webpackOptions.watch;
watchOptions = webpackOptions.watchOptions || {};
}
...
const createCompiler = rawOptions => {
...
const compiler = new Compiler(options);
...
if (Array.isArray(options.plugins)) {
for (const plugin of options.plugins) {
if (typeof plugin === "function") {
plugin.call(compiler, compiler);
} else if (plugin) {
plugin.apply(compiler);
}
}
}
...
}
...
首先我们知道 Webpack
执行的入口文件是 webpack/lib/webpack.js
,另外从 plugin.apply(compiler)
可知, compiler.hooks.run.tap
上的 compiler
来自于此。 在这里我们一行行往上按图索骥可知调用链是这样的 create -> createCompiler -> plugin.apply
。一个个来分析。
- create 方法里包含多个分支判断,会通过传入的 option 决定采取哪种策略,这里我们只看关键的调用
createCompiler
这部分。 - createCompiler 中会首先实例化
new Compiler()
对象。然后遍历options.plugins
属性(这里就是我们在配置Webpack
时传入的插件数组)。 - 遍历
plugins
时,取到每个plugin
上的apply
方法然后调用,这里的apply
方法就是我们在写插件的时候定义的那个。
到这里我们就把插件表层的注册和定义闭环起来了。
插件注册
Tapable
其实就是类似 NodeJS
中的 EventEmitter
,是一个事件系统,包含了事件注册 和事件触发 两大部分。不过 Tapable
做的更加细致更加完善。
在 Webpack
源码里可以看到,事件注册是通过 compiler
上的 hooks
方法实现的。那我们来看下 hooks
是从哪来的。首先从 createCompiler
中得知, compiler
是来自于 Compiler.js
文件导出的 Compiler
类。
JavaScript
// webpack/lib/Compiler.js
class Compiler {
constructor(context, options = {}) {
this.hooks = Object.freeze({
...
initialize: new SyncHook([]),
...
run: new AsyncSeriesHook(["compiler"]),
emit: new AsyncSeriesHook(["compilation"]),
...
})
}
...
}
从这里可以看到 hooks
是 Compiler
类的一个成员是个大对象,其中包含了许多属性,这些属性代表整个编译过程的不同时机。例如 initialize
就是说在初始化时要出发哪些插件,而 run
就是说在开始编译时要触发哪些插件等等。
这些属性所对应的值均是通过实例化 XXXHook
来实现的,从这里开始就触及到了插件系统的核心部分了。不论是 SyncHook
、 AsyncSeriesHook
还是 AsyncParallelHook
均来自于 Tapable
库。
Tapable
通过 Hook
基类派生出了多个子类,每个基类都代表一种独特的插件类型。基本上根据字面意思就可知其作用了,如 SyncHook
是说在这里注册的插件是按照同步模型来执行的, AsyncSeriesHook
是说按照异步串行的模型来执行, AsyncParallelHook
按照异步并行的模型来执行。
SyncHook
我们通过一个子类实现来详细探究下 Tapable
的精髓。
JavaScript
// SyncHook.js
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");
};
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;
首先, SyncHook
通过 new
和重定向 constructor
的方式继承了 Hook
类。并重写了 tapAsync
和 tapPromise
方法,因为这两个方法都是和异步相关的这里并不需要所以就直接 throw
了。然后,对于 compile
方法。
JavaScript
class SyncHookCodeFactory extends HookCodeFactory {
content({
onError,
onDone,
rethrowIfPossible
}) {
return this.callTapsSeries({
onError: (i, err) => onError(err),
onDone,
rethrowIfPossible
});
}
}
const factory = new SyncHookCodeFactory();
const COMPILE = function(options) {
factory.setup(this, options);
return factory.create(options);
};
compile
方法主要是用作代码的编译生成,这里通过一个工厂类来实现,由于每种类型的插件生成的代码会有差别所以这里通过创建 SyncHookCodeFactory
来继承 HookCodeFactory
并重写其中的 content
方法来达到目的。这里我们先大概看,下文会做补充。
那 compile
是在什么时候触发的呢,这就需要去看 Hook.js
类中了。
JavaScript
// Hooks.js
...
const CALL_DELEGATE = function(...args) {
this.call = this._createCall("sync");
return this.call(...args);
};
...
class Hook {
...
_createCall(type) {
return this.compile({
taps: this.taps,
interceptors: this.interceptors,
args: this._args,
type: type
});
}
...
}
在 Hook
类中可以看到 compile
是被 _createCall
方法调用,而 _createCall
又被 CALL_DELEGATE
方法调用,且在其方法内部又会将 _createCall
的返回值直接赋值给 this.call
,然后调用 this.call
方法立即执行。
那我们再看 CALL_DELEGATE
,
JavaScript
// Hooks.js
class Hook {
constructor(args = [], name = undefined) {
...
this._call = CALL_DELEGATE;
this.call = CALL_DELEGATE;
...
}
}
CALL_DELEGATE
复制给了 Hook
类的 call
方法,这就意味着调用 call
方法其实是执行的 CALL_DELEGATE
方法,上文我们分析过在 CALL_DELEGATE
方法内部又对 call
方法进行了重写,所以当再次调用 call
方法的时候其实就不走 CALL_DELEGATE
了,这样做的好处是代码会有一个惰性编译的特性,只会在真实实际执行的时候才会进行编译。
到这里 Webpack
插件系统的整体流程已经清晰了,如下图所示:
我们来把整个流程串起来做一下回顾和总结,
- 阶段 1,就是我们在
webpack
配置中声明的各种插件。 - 阶段 2,
webpack
会创建一个compiler
对象,该对象就是webpack
的核心贯穿了整个系统。 - 阶段 3,在
compiler
构造函数中定义了hooks
成员属性,拆分了webpack
编译过程中各个阶段,也就是我们所熟知的声明周期 。每个阶段都绑定了一个sub hook
实例。这里就是指Tapable
库里的那些hook
子类。 - 阶段 4,遍历在
webpack
配置中声明的插件,并调用其上的apply
方法进行注册。其中会传入compiler
对象,这也是为何我们在apply
函数里定义compiler
参数的原因。 - 阶段 5,到这一步,
webpack
会直接执行生命周期最初的几个hook
,如:environment
,afterEnvironment
,initialize
。这即表明,如果我们的插件需要在这个阶段做一些工作,那么此时插件就会被调用了。进一步的,在后续的生命周期阶段,只需调用与之对应hook
上的call
方法即可触发插件的调用。
最后,在 hook
中有个特殊的方法 compile
,这里比较复杂(关系到惰性编译,隐藏函数,内联缓存等),介于篇幅原因不在本文展开,大家有兴趣的话后续可以单独开一篇介绍,只需要知道这里是最终执行插件的函数即可。
对比总结
对比下来不难发现,插件就是链式的执行函数,下一个接受上一个函数的返回值或者对一个全局对象就行修改,这样一个个执行下去直到最后一个或者中间某个插件提前结束。差异点在于怎么把链式的功能实现出来,
Redux
通过高阶函数层层包裹来实现;Koa
通过一个next
辅助函数配合async await
来实现;Axios
通过两个数组(请求侧,返回侧),遍历来实现;Webpack
通过本质也是通过数组遍历来实现,但由于糅合了生命周期所有要复杂一点。
就像老头环中不同的武器流派,这四种方式也代表了不同的流派,我们可以根据具体的场景和偏好去选择最适合的方案。