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

817 阅读12分钟

前言

在我们日常前端开发中,插件这个词可以说是再熟悉不过了,出现的频率相当之高。

不论是古早的 JQuery, Backbone, KnockOut 还是现今流行的 React, Vue3, Axios, Webpack 亦或是 Koa, Express 中都有插件的身影,可谓无所不在,无孔不入。

那插件到底有什么神奇之处呢,本文就挑选几个使用最频繁的框架,对比其插件系统的实现,来彻底弄明白这个神器,以帮助我们构建出鲁棒性更强的代码,或是更好的和面试官谈笑风生。

这四种插件系统,分别是在 ReduxKoaWebpackAxios 中。

Redux 中的插件系统

闲言少叙,言归正传,我们先来回顾下 Redux 是如何践行单项数据流模型的(数据的流向和插件的构建息息相关)。

redux.svg 数据模型还是比较清晰的,

  • view 中的某些操作如点击、滚动会触发 dispatch 函数(自于 redux,入参为 action)。
  • dispatch 会将 action 传入到 reducer 中,reducer 则根据传入的 action 更新 store
  • store 更新后触发 view 组件的更新,页面上即显示最新的数据。

Redux 中的插件就是在 dispatch(action) 阶段前后做文章,先看下 Redux 官方文档是怎么描述插件的,

Redux middleware provides a third-party extension point between dispatching an action, and the moment it reaches the reducer. People use Redux middleware for logging, crash reporting, talking to an asynchronous API, routing, and more.

Redux 通过中间件机制在 dispatching action 之间,引入了第三方扩展点的能力。通过扩展点可以实现统一的日志打印、崩溃上报、异步调用等诸多功能。

到这里,思考 1 秒中,要实现这样的功能我们会怎么做?

问题拆解

我们对这个功能进行一下拆分,拆解成多个子问题

  • 如何做到不侵入业务代码完成相关功能?
  • 多个插件如何进行有效整合?
  • 碰到异步情况如何解决?

如何做到不侵入业务代码完成相关功能?

设想一个实际场景,日志打印功能,该功能需要打印 dispatch(action) 前后 store 状态的变化。那这个问题如果用侵入方案来解决,想必大家应该都知道该怎么做,无非就是在调用dispatch(action) 代码前后调用console.log

那如何做到不侵入调用 dispatch(action) 的业务代码呢?

大胆假设是否可以对 dispatch 函数进行重写?其实是可以当,我们先来看dispatch的源码,

export function createStore(reducer, preloadedState, enhancer) {
  function dispatch(action) {
    ...
    try {
      isDispatching = true
      currentState = currentReducer(currentState, action)
    } finally {
      isDispatching = false
    }
    ...
  }
}

这里就是 reduxdispatch 函数的关键部分,其中 currentState = currentReducer(currentState, action),就是调用 reducer 函数传入当前 stateaction 然后返回新的 state。所以假如我们能在这个地方前后打上日志,不就解决了侵入业务代码的问题么。但直接改 redux 的代码显然也是不现实的。

我们再来看一段源码,

export function createStore(reducer, preloadedState, enhancer) {
  ...
  const store = {
    dispatch: dispatch as Dispatch<A>,
    subscribe,
    getState,
    replaceReducer,
    [$$observable]: observable
  }
  return store
  ...
}

dispatch 方法其实在调用 createStore 后导出了,这样在我们调用完 createStore 时就可以在业务层面拿到 dispatch 方法了,也就意味着可以在业务层面对 dispatch 进行包装了。 通常我们在项目中是会调用 createStore 来创建全局状态的,此时我们可以进行一下修改,如下代码,

const store = createStore(...);
+const dispatch = store.dispatch;
+
+store.dispatch = (action) => {
+  console.log(`before dispatch`, action, store.getState())
+  dispatch(action);
+  console.log(`after dispatch`, action, store.getState())
+}

这样我们就在 dispatch(action) 前后完成了日志的打印,做到了不侵入业务代码完成相关功能。到这里其实代码就能用了,根据又不是不能用原则,理论上到这里大家就可以下课了。

多个插件如何进行有效整合?

首先出现在脑海的,一种简单粗暴的方式就是,

// 第一个插件
let dispatch = store.dispatch;

store.dispatch = (action) => {
  console.log(`before dispatch 0`, action, store.getState())
  dispatch(action);
  console.log(`after dispatch 0`, action, store.getState())
}

// 第二个插件
dispatch = store.dispatch;

store.dispatch = (action) => {
  console.log(`before dispatch 1`, action, store.getState())
  dispatch(action);
  console.log(`after dispatch 1`, action, store.getState())
}

// 第三个插件
...

这样的话,碰到小项目,插件不多的情况下勉强能用,但是一旦项目有点规模这种做法就容易产生意大利面代码了,面吃多了久而久之就成了屎山。所以需要一定的模式规范,来约束多插件的使用场景。

分析下暴力使用多插件场景,像不像高阶函数层层包裹。

HoC.svg

那么现在我们的问题就转化为如何将层层包裹的高阶函数转化为符合人体工学的编程方式。函数一层层的执行其实就是执行完一个再执行下一个,这种 One by One 的方式是不是很像数组的执行顺序,通过 for 循环依次拿到数组中的数据,如果数据是函数,那就去执行。很清晰得到如下代码,

// logger1, logger2 为插件函数
const plugins = [dispatch, logger1, logger2];
const action = { type:"action" }
for(let i = 0; i < plugins.length; i++) {
  const fn = plugins[i];
  fn(action);
}

但是这样只是执行了函数,并没有将函数串联起来,也没法获取执行结果。要怎么办呢,这样修改试试看,

const dispatch = (action) => { console.log(action); return "res"; }
const plugins = [dispatch, logger1, logger2];
const action = { type:"action" }
let res = null;
for(let i = 0; i < plugins.length; i++) {
  const fn = plugins[i];
  res = fn(action, res);
}

// 而在插件内部,需要对执行结果进行返回,如:
function logger1(action, res) {
  console.log("logger1", action, res);
  return res;
}

function logger2(action, res) {
  console.log("logger2", action, res);
  return res;
}

这样就在每个插件中拿到了action 和 最终执行的结果,这样只是拿到了处理结果,如果想要在下个函数处理前做一些预操作,需要进一步改造。需要将插件改造成接收 action 和下一个插件函数的形式。这样我们在插件内部调用下一个插件了。具体做法如下,

const dispatch = (action) => { console.log(action); return "res"; }
const plugins = [dispatch, logger1, logger2];
let i = 0;
const action = { type:"action" }
const next = (action) => {
  const reservedPlugins = plugins.reserve();
  const fn = plugins[i++];
  return fn(action, next);
}
next(action);

function logger1(action, next) {
  console.log("before logger1");
  const res = next(action);
  console.log("after logger1", res);
  return res;
}

function logger2(action, next) {
  console.log("before logger2");
  const res = next(action);
  console.log("after logger2", res);
  return res;
}

到此,我们就将各个插件函数串联起来了,并且能完美的在函数调用前后进行一些操作。而其中有一行 const reservedPlugins = plugins.reserve(); 比较关键,如果这里不进行反转操作的话,会先执行原生的dispatch这样就无法串联起来了。这也是为什么 redux 中的 compose 函数中会用 reduce 对插件数组进行反转。

异步情况如何解决?

到目前离 redux 中的最终形态还有一步之遥,我们和异步情况一并解决。

由于插件是链式调用的,所以我们可以在任意插件中对链式调用进行打断。利用这个特性,我们可以做到异步操作。简单实现如下,我们以 redux-thunk 的做法为例

// redux 插件
function thunk(action, next) {
  if(typeof action === "function") {
    return action(dispatch)
  }
  return next(action);
}

// 请求函数
function fetch(dispatch) {
  new Promise((resolve,reject)=>{
    setTimeout(()=>{
      const payload = { data: { users: [] } }
      dispatch(action, payload)
    }, 2000)
  })
}

大体思路就是构建一个 thunk 插件,插件内会判断传入的 action 类型,如果是 function 则意味着需要被执行。此外,action 需要接受 dispatch 函数,用于在函数执行完毕后调用,以便于能对 store 提进行修改。

然后,假如我们需要在插件中拿到上一个插件的执行结果,并且也需要获得最后 reducer 执行的结果。此时我们的代码还做不到。一种做法就是在插件中传入原始 store 这种,

function logger1(store, action, next) {
  console.log("before logger1");
  const res = next(action);
  console.log("after logger1", res, store.getStore());
  return res;
}

而另一种就是改造成高阶函数的形式,

const logger1 = store => next => action => {
  console.log("before logger1");
  const res = next(action);
  console.log("after logger1", res, store.getStore());
  return res;
}

不论哪种形式都需要和 redux 里的 createStore 配合使用,redux 在这里采用了高阶函数的形式来处理,也就是第二种。该形式符合函数为一等公民的原则,且redux中大量使用了柯里化函数,所以与其他模块在开发模式上也保持了一致。但本质上这两种形式并没有太多的优劣之分。

Koa 中的插件系统

Koa 可以说是面试最高频的类型之一,Koa算是较早的把洋葱模型引入到前端生态里来的框架了,影响了诸多后来的框架包括Redux,我们来探究下 Koa 中是如何实现插件系统的。

得益于 NodeJSHTTP 模块的用法,

import http from 'node:http';

// Create a local server to receive data from
const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({
    data: 'Hello World!',
  }));
});

server.listen(8000);

createServer 方法接受一个函数,该函数有两个入参,req 对象参数代表请求,res 对象参数代表响应。这两个对象上均有多个属性、方法可以使用。具体有哪些我们本文不赘述,只需要了解有了这两个就可以方便的处理请求和响应。所有的业务逻辑都是围绕这两个参数来进行的。那问题就转变成了,如何更好的组织代码Koa 采用了洋葱模型,插件的形式来组织。有一张经典图:

onion.png

这张图比较形象的展示了,Koa的执行模型,就像洋葱一样一层层剥开。

再附一张偏向代码实现的图:

koa.svg

从上到下看,在每个函数的内部都会执行下一个函数,那么就天然的把函数分成了两部分,下个函数执行前和下个函数执行后。每个函数都会接受 reqres 参数。这样就把整个链路串起来了。

同样的我们可以先想一下,如何实现这套系统,和 redux 插件不同的是,这里入参只有 reqres,听起来会简单许多。

如何实现?

Koa 同样也是采用了数组将所有插件保存起来。

class Application extends Emitter {
  constructor (options) {
    super()
    ...
    this.middleware=[]
    ...
   }

   use (fn) {
     ...
     this.middleware.push(fn)
     return this
   }
}

很清晰,声明一个 middleware 成员变量和一个 use 成员函数,调用 use 函数即可把插件存入 middleware 中。

插件数组创建完了,那怎么用呢。

...
const compose = require('koa-compose')

constructor (options) {
  super()
  ...
  this.compose = options.compose || compose
  ...
}
...
handleRequest (ctx, fnMiddleware) {
  const res = ctx.res
  res.statusCode = 404
  const onerror = err => ctx.onerror(err)
  const handleResponse = () => respond(ctx)
  onFinished(res, onerror)
  return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}
...
callback () {
  const fn = this.compose(this.middleware)

  if (!this.listenerCount('error')) this.on('error', this.onerror)

  const handleRequest = (req, res) => {
    const ctx = this.createContext(req, res)
    if (!this.ctxStorage) {
      return this.handleRequest(ctx, fn)
    }
    return this.ctxStorage.run(ctx, async () => {
      return await this.handleRequest(ctx, fn)
    })
  }

  return handleRequest
}
...
listen(...args) {
  const server = http.createServer(this.callback())
  return server.listen(...args)
}
...

这里相关的代码比较多,我们一个个来看。

  • 首先是 listen 函数,此函数就是启动 HTTPServer 的入口,调用该函数就意外着要启动了。注意到,该函数调用了 this.callback 方法。
  • this.callback 方法里引入了 this.compose 方法,这个方法引入了一个外部模块我们待会儿再看。只需要知道这个方法调用后返回了一个可执行函数 fn。接着this.callback函数返回了一个新函数 handleRequest,而 handleRequest 接受两个参数reqres,这就和 HTTPServer 对起来了。
  • 然后我们看 this.callback 函数中的 handleRequest 是如何实现的,该函数首先对 reqres 进行了包装统一成一个变量 ctx,然后调用 this.handleRequestctxcompose 方法返回的 fn 传了进去。
  • 然后对于 this.handleRequest 函数,直接看最后一行,刚刚传入的 fn 这里转换为了实参 fnMiddleware 并且是一个能够返回 Promise 的函数。由于 fn 是来自于 this.compose 方法,接下来我们来看 this.compose 怎么做的。
  • 从构造函数中可以看到 this.compose 来自于第三方库。这个库就是专门用来处理中间件合并的。

Compose 库

这个库代码较少,直接看源码,

function compose (middleware) {
  ...
  return function (context, next) {
    // last called middleware #
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}
  • compose 一经调用就返回了一个新函数,新函数接受两个参数 contextnext,这里我们就知道在上文 handleRequest 成员函数中调用 fn 传入的 ctx 就是指这边的 context
  • 接下来的代码实现的比较“巧妙”,直接返回了 dispatch(0),而 dispatch 函数是在 return 后定义的,这里隐含了一个知识点函数提升,不了解 JavaScript 的小伙伴可能看到这里会一脸懵逼。而dispatch 函数的关键两行在这里,
let fn = middleware[i]
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))

这两行是什么意思呢,我们先回顾下Koa插件一般是怎么写的,

const logger = async (ctx, next) => {
  console.log(`before logger`, ctx);
  await next();
  console.log(`after logger`, ctx);
}

这里的 next 就是上面代码里的 dispatch.bind(null, i + 1),每次调用next其实就是调用了dispatch(i+1),在dispatch又能通过插件数组和索引值拿到要执行的函数,巧妙地将插件串联了起来。

最后我们用一张流程图来回顾一下核心调用流程,

koainner.svg

未完待续

本篇我们介绍了两种插件系统的实现方式,其实这两种实现本质最核心的部分是一样的,即通过数组的形式对插件进行组织,通过一个辅助函数next将插件链接起来。从而实现了插件的层层链式调用,而在调用过程中对我们需要的数据进行传递,这样当插件函数链执行完,就拿到了最终的结果。在我们日常开发中如果碰到需要开发插件系统场景的话可以尝试仿照实现一波。

下篇将介绍WebpackAxios中的插件系统是如何实现的。