对比4种插件系统,彻底拿捏相关问题(下)

前言

在开始前,先说下为什么要做对比,以及这么做的好处是什么?

大概原因是这样的,假如我们分开去看各自的源码实现了解其内部原理,当然也会有一定的收获,但会由于信息来源比较单一没有对比参照,了解的可能不会太深入也较为容易遗忘,同时我们是跟着作者的思路走,比较被动的接受没有一个主动输出的过程。

而如果能把类似的系统放在一起多元化的对比,我们就能吸收到其他作者的精髓,货比三家,从而能进行一个主动的思考,为什么 A 这么实现而 B 那样实现?两种实现有什么异同,适用的场景有什么异同等等。

这样我们就会有更多主动思考的过程,能对其中原理吃的更透更深,印象也就更加深刻,反过来在我们日常工作开发中碰到类似的情况,也更容易回忆起来要怎么择优运用。

上文对比 4 种插件系统, 彻底拿捏相关问题(上)我们讲了 ReduxKoa 中的插件系统实现方案,本篇我们接着来看下大名鼎鼎的 AxiosWebpack 是如何实现插件系统的。

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 对象,其中包含两个属性 requestresponse ,这两个分别来保存请求前返回后 的插件,且都是 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 数组,以及 useforEach 成员方法。

use 方法接受三个参数:

  • fulfilled:成功时的回调
  • rejected:失败时的回调
  • options:额外参数

而在方法体中,将 fulfilledrejected 和额外参数包裹为对象插入 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"]),
        ...
      })
    }
    ...
}

从这里可以看到 hooksCompiler 类的一个成员是个大对象,其中包含了许多属性,这些属性代表整个编译过程的不同时机。例如 initialize 就是说在初始化时要出发哪些插件,而 run 就是说在开始编译时要触发哪些插件等等。

这些属性所对应的值均是通过实例化 XXXHook 来实现的,从这里开始就触及到了插件系统的核心部分了。不论是 SyncHookAsyncSeriesHook 还是 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 类。并重写了 tapAsynctapPromise 方法,因为这两个方法都是和异步相关的这里并不需要所以就直接 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,如:environmentafterEnvironmentinitialize。这即表明,如果我们的插件需要在这个阶段做一些工作,那么此时插件就会被调用了。进一步的,在后续的生命周期阶段,只需调用与之对应 hook 上的 call 方法即可触发插件的调用。

最后,在 hook 中有个特殊的方法 compile ,这里比较复杂(关系到惰性编译,隐藏函数,内联缓存等),介于篇幅原因不在本文展开,大家有兴趣的话后续可以单独开一篇介绍,只需要知道这里是最终执行插件的函数即可。

对比总结

对比下来不难发现,插件就是链式的执行函数,下一个接受上一个函数的返回值或者对一个全局对象就行修改,这样一个个执行下去直到最后一个或者中间某个插件提前结束。差异点在于怎么把链式的功能实现出来,

  • Redux 通过高阶函数层层包裹来实现;
  • Koa 通过一个 next 辅助函数配合 async await 来实现;
  • Axios 通过两个数组(请求侧,返回侧),遍历来实现;
  • Webpack 通过本质也是通过数组遍历来实现,但由于糅合了生命周期所有要复杂一点。

就像老头环中不同的武器流派,这四种方式也代表了不同的流派,我们可以根据具体的场景和偏好去选择最适合的方案。

相关推荐
也无晴也无风雨25 分钟前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
Martin -Tang1 小时前
Vue 3 中,ref 和 reactive的区别
前端·javascript·vue.js
FakeOccupational2 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
阮少年、4 小时前
java后台生成模拟聊天截图并返回给前端
java·开发语言·前端
郝晨妤5 小时前
鸿蒙ArkTS和TS有什么区别?
前端·javascript·typescript·鸿蒙
AvatarGiser6 小时前
《ElementPlus 与 ElementUI 差异集合》Icon 图标 More 差异说明
前端·vue.js·elementui
喝旺仔la6 小时前
vue的样式知识点
前端·javascript·vue.js
别忘了微笑_cuicui6 小时前
elementUI中2个日期组件实现开始时间、结束时间(禁用日期面板、控制开始时间不能超过结束时间的时分秒)实现方案
前端·javascript·elementui