koa洋葱结构解析

经常在使用 koa 的时候,通过 .use 的形式来注册各种中间件,例如下面一段代码

js 复制代码
app.use(async (ctx, next) => {
  console.log(1);
  await next();
  console.log(2);
});
app.use(async (ctx, next) => {
  console.log(3);
  await next();
  console.log(4);
});

这里会输出 1,3,4,2,下面就来翻看一下源码看看这个中间件实现的具体原理。

在看具体代码之前,先温习一下,使用 koa 的最小运行代码是什么样的

js 复制代码
const Koa = require("koa");
const app = new Koa();

// response
app.use((ctx) => {
  ctx.body = "Hello Koa";
});

app.listen(3000);

可以看到,最后通过 listen 方法来启动服务,那我们重点先看下 use 和 listen 做了什么事情。

use

js 复制代码
  use (fn) {
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!')
    debug('use %s', fn._name || fn.name || '-')
    this.middleware.push(fn)
    return this
  }

代码比较少,这里直接贴上去了,use 的主要作用就是给 middleware 添加相对应的 fn。

listen

js 复制代码
  listen (...args) {
    debug('listen')
    const server = http.createServer(this.callback())
    return server.listen(...args)
  }

这里 server 是 http 的库的方法,我们先不管,主要看一下 this.callback 做了什么事情。

js 复制代码
  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
  }

handleRequest 这个函数的实现如下

js 复制代码
  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)
  }

可以看到,最终是把 this.compose 返回的 fn 进行了调用,那么由此可以知道 this.compose 就是具体中间件调度的具体实现。

compose

this.compose 可以在 constructor 中看到,默认情况下就是 koa-compose,这个库也非常精简只有 50 行代码,下面会通过注释的形式来对源码进行一个说明。

js 复制代码
// 省略部分注释和部分代码

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!");
  }
  // 这里默认返回一个函数,handleRequest函数会调用这个返回的函数,并且传递 context
  return function (context, next) {
    let index = -1;
    // 默认情况下执行一次 dispatch ,dispatch因为是函数声明所以会提升到做作用域顶部
    return dispatch(0);
    function dispatch(i) {
      // 通常情况下不会遇到,但是如果执行两次就会抛出异常,例如第一次调用index为-1,i为0,第二次执行则变成index为0,i也为0则抛出错误
      if (i <= index)
        return Promise.reject(new Error("next() called multiple times"));
      index = i;
      let fn = middleware[i];
      // 这里从koa的实现可以看到,是没有传递next的,所以这行代码可以跳过
      if (i === middleware.length) fn = next;
      // 执行到最后一项的时候直接返回不再继续递归下去
      if (!fn) return Promise.resolve();
      try {
        // 这里实现很巧妙,利用了bind的原理,bind的第一个参数为this,之后的参数为函数的预设值,最后返回一个函数
        // 然后根据Promise.resolve的实现规范,如果传递的Promise.resolve是一个Promise要等待新的Promise执行完成之后决定状态
        // 这里推荐看下PromiseA+规范实现
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err);
      }
    }
  };
}

ok,这里基本上就讲完了,还是对照最初的示例来看

js 复制代码
app.use(async (ctx, next) => {
  console.log(1);
  await next();
  console.log(2);
});
app.use(async (ctx, next) => {
  console.log(3);
  await next();
  console.log(4);
});

这里传递给 compose(fn1, fn2),之后 i 为 0,返回

js 复制代码
const fn = async (ctx, next) => {
  console.log(1);
  await next();
  console.log(2);
};
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));

之后 i 为 1 的时候

js 复制代码
const fn = async (ctx, next) => {
  console.log(3);
  await next();
  console.log(4);
};
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));

此时 i 为 2,发现数组取不到值了,执行

js 复制代码
if (!fn) return Promise.resolve();

回到最最后一步,还没有解释为什么会洋葱结构这样来执行代码

js 复制代码
app.use(async (ctx, next) => {
  console.log(1);
  await next();
  console.log(2);
});
app.use(async (ctx, next) => {
  console.log(3);
  await next();
  console.log(4);
});

这里我们知道先执行第一个函数

  1. 输出 console.log(1);
  2. 执行的过程中遇到 await next() 会执行 next,而从源码 dispatch.bind(null, i + 1) 可以知道下一项就是第二个函数
  3. 执行第二个函数
  4. 输出 console.log(3)
  5. 继续执行 await next() 从源码知道 i 为 2 的时候,返回的是 if (!fn) return Promise.resolve();
  6. 执行 conosle.log(4),返回 Promise 状态为已完成,结果为 undefined
  7. 执行 console.log(2)

上面可能有点绕,但是其实 koa-compose 利用了事件循环的机制,对于微任务每次执行都会放到微任务队列,等待主线程执行栈调用,而栈的特点就是先进后出,所以这也是为什么会输出 1,3,4,2 的原因了。

最后

如果文章有说的不对地方欢迎指出,最后本人正在找工作,有相关 hc 岗位欢迎滴滴。

相关推荐
FungLeo7 小时前
成为全栈·Node 后端篇·阅读量防刷:去重、冷却与计数写分离
node.js·读写分离·数据去重·接口防刷·成为全栈·数据冷却
CharlesYu018 小时前
前端性能优化的第一性原理,是不断缩短“用户发起意图 → 获得可用结果”之间的时间
前端
平头哥技术团队9 小时前
Day 21 _ 页内锚点_给每段起个 id,目录写 href=_#id_,点一下页面就滚到那一段
前端·html·html5
子兮曰10 小时前
Bun v1.4.1 深度解析:从 Zig 到 Rust,一场 11 天、64 个 AI 代理的语言迁徙
前端·后端·bun
人民广场吃泡面10 小时前
什么是AI Agent?它又能给前端带来哪些效率提升?
前端·人工智能
中科三方10 小时前
两家域名注册商资质被ICANN终止:企业域名资产安全再受关注
前端·网络·安全·域名
bug总结11 小时前
uniapp vue3全局方法注册使用
前端·javascript·uni-app
华无丽言11 小时前
如何在宜搭中实现获取子表中的字段值赋值到父表中?
前端·javascript·低代码
IT_陈寒12 小时前
Vue的computed属性竟然坑了我一把
前端·人工智能·后端
威斯软科的老司机12 小时前
通俗讲解 CNN 图像识别、向量 Embedding、Softmax 概率计算这三块的简化原理
前端·人工智能·ui·数字孪生