源码解读系列之 koa-compose

560 阅读6分钟

Koa 框架介绍

Koa 是一个 web 框架,目的是成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。Koa 和 Express 一样,提供中间件机制,但本身并没有提供任何中间件,能够让开发者更加快速而愉快地编写服务端应用程序。

Koa 框架精髓之一 - 洋葱模型

Koa 封装了很多很棒的功能。笔者认为其中最具代表性的就是洋葱模型。洋葱模型可以让开发者快速地将函数进行组合,通过对功能简单的函数组合从而实现复杂的功能。

这里借用一下 eggjs 官网的洋葱模型图片:

在请求到达服务器后,服务器可以根据自身的业务需求,像剥洋葱一样,对请求一步步进行拆解,然后对信息进行一步步的组装,最后将组装好的响应返回给客户端。

洋葱模型的思想不仅可以用于服务器端,也可以用于各个场景下的复杂逻辑拆分。

洋葱模型的应用实例

上面说过,洋葱模型的设计思想不仅仅可以作用于服务器的中间件逻辑,也可以应用于方方面面的逻辑组装于设计。

假设我们有这样一个客户端的逻辑:

1、往页面插入一个 dom 节点;

2、等待 1 秒;

3、对 dom 节点注入 css 动画,开始播放;

4、不断检测 dom 节点的屏幕位置,当到达某个位置后,暂停动画;

5、等待 1 秒;

5、把 dom 节点从页面上移除;

如果我们不使用任何设计模式,我们会把上述的逻辑写成 1 个大函数和 5 个小函数,每个小函数做的事情分别对应上述步骤,大函数包含着小函数的调用顺序以及相关逻辑,表现方式如下:

async function startAnimation() {
  creatDom();
  await delay(1000);
  startAnimation();
  await detectDom();
  pauseAnimation();
  await delay(1000);
  removeDom();
}

这其中有个问题是需要考虑的,createDom 和 removeDom,startAnimation 和 pauseAnimation,这两对函数,它们的逻辑其实是非常类似的,而且它们对应的操作对象应该是同一个,如果一个函数发生了变更,势必要修改另外一个函数,否则会引起不可预料的错误。

而这,往往就是不好的设计的开始,随着系统越来越复杂,代码的维护成本会越来越高。

那有没有更好的组织方式,可以使得这个逻辑更加抽象,更加容易调整和复用呢?这时候,我们可以考虑利用洋葱模型对逻辑进行统一的规范和组织。

利用洋葱模型,我们可以进行如下设计:

有 4 个小函数:

1、dom 操作函数 - 负责 dom 的插入以及移除;

2、延迟函数 - 负责延迟指定时间,然后回调(不要求精准,只用 setTimeout 即可);

3、动画处理函数 - 负责动画开启 和 暂停;

4、检测函数 - 根据特定规则检测 dom;

完成后的调用代码如下:

async function start() {

    const startAmination = compose([
        domCtrl,
        delay(1000),
        animationCtrl,
        detectDom,
    ]);

    const ctx = {};
    await startAmination(ctx);
}

调用逻辑顺序如下:

domCtrl - start // create dom
	delay - start // delay 1s
		animationCtrl - start // start animation
			detectDom - start, end // detecting dom status until matched
		animationCtrl - end // pause animation
	delay - end // delay 1s
domCtrl -end // remove dom

domCtrl 代码:

async function domCtrl(ctx = {}, next) {

    const dom = document.createElement('div');
    const mainPanel = document.querySelector('#main-panel');
    // 创建 dom
    ctx.dom = dom;
    mainPanel.appendChild(dom);
    // 往洋葱模型深处走
    await next();
    // 销毁 dom
    mainPanel.removeChild(dom);
    delete ctx.dom;    
}

animationCtrl 代码:

async function animationCtrl(ctx = {}, next) {
    const { dom } = ctx;

    if(dom === undefined) {
        return next();
    }

    const rawClass = dom.className || '';
    // 开始动画
    dom.className = `${rawClass} custom-animation`;
    // 往洋葱模型深处走
    await next();
    // 暂停动画
    dom.className = `${dom.className} animation-paused`;
}

新的代码组织方式对比旧代码,有个明显的好处就是,dom 的创建和删除统一整合到了 domCtrl 函数中,动画的播放和暂停,统一整合到了 animationCtrl 中,每个函数只需要关注好自己的职责即可。意味着以后无论是修改 dom 的处理流程,还是改变动画的处理过程,只需要修改一个函数即可。

可见,利用洋葱模型的思想,有助于我们在日常开发过程中对复杂逻辑进行抽象和拆解,同时提高系统的健壮性和可扩展性。

完整的例子可以查看:compose example

洋葱模型的实现关键 - compose

compose 源码

版本:4.1.0

function compose (middleware) {
  if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
  for (const fn of middleware) {
    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
  }

  return function (context, next) {
    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 {
        // 关键点 1
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

compose 源码异常的少,但它的功能非常赞

画图理解源码

如何直观地理解上面的源码是个关键,我们尝试用文字和图来表现里面的逻辑

关键点在于如何形成链式调用,所以一开始我们先忽略 context

接下来,我们可以把 compose 执行后返回的函数看成如下的代码:

dispatch.bind(null, 0);

这样就对应上源码中标注着 关键点 1 的地方了

接下来,结合上述的例子,我们尝试把 middleware 数组里面的函数具名化,这样 middleware 的内容就如下所示:

[
  domCtrl,
  delay,
  animationCtrl,
  detectDom,
]

当执行 dispatch.bind(null, 0)() 时,意味着执行 middleware 中的第一个函数,返回的内容为

Promise.resolve( domCtrl(context, dispatch.bind(null, 1)) );

我们可以看到,上面以 Promise 的方式执行 domCtrl 函数,同时在 domCtrl 函数的第二个参数中,注入了 dispatch.bind(null, 1)

可以想象,当 dispatch.bind(null, 1) 被 domCtrl 调用时,会返回

Promise.resolve( delay(context, dispatch.bind(null, 2)) );

到了这里,我们就可以得到规律,middleware 中的函数会按照上面的方式被依次执行(每个函数都要执行 next),从而实现链式调用的效果

用图来表示,就是下面的效果:

心得

1、洋葱模型可以帮助我们在特定场景优化设计,提高代码的健壮性;

2、使用洋葱模型时,中间件函数需要遵循特定的 入参和出参 规范;(笔者观点:一定程度上的规范是保证系统可维护性和健壮性的关键)

3、中间件函数使用 next 函数时,如果直接同步调用 next() ,会导致整个调用链提前退出,所以使用 next 函数时,有两个可选规范:

a、await next()

b、return next()

聪明的读者肯定能很快地理解上面两个规范对应着不同的设计场景;

我们的目标应该是不仅仅会使用 Koa,而是能够把 Koa 优秀的设计吃透并扩展到多个不同场景中