Node<十二>——Koa核心用法和源码解读

215 阅读20分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情

前面我们已经学习了express,另外一个非常流行的 Node ``Web服务器框架就是Koa

Koa官方的介绍:

  • koa:next generation web framework for node.js
  • koa:node.js的下一代web框架

事实上,koa是express同一个团队开发的一个新的Web框架

  • 目前团队的核心开发者TJ的主要精力也在维护Koa,express已经交给团队维护了
  • Koa旨在为Web应用和API提供更小、更丰富和更强大的能力
  • 相对于express具有更强的异步处理能力
  • Koa的核心代码只有1600+行,是一个更加轻量级的框架,我们可以根据需要安装和使用中间件

事实上学习了express之后,学习koa的过程是很简单的

Koa初体验

从下面这个简单的代码中,我们就可以发现koaexpress几个比较明显的区别:

  1. 引入koa实际上得到的是一个类,在express中引入的是一个普通的application函数
  2. koa在把所有的中间件遍历完之后,依然还没有返回结果的话,那么默认就会返回给客户端的是一个not found,而express返回的是一个包含错误信息的html文件
  3. 注册中间件函数时,express是要接受三个参数的,而koa只需要接收两个参数,第一个参数ctxcontext的缩写,代表着上下文的意思,next和express中的next函数类似,都是去执行下一个匹配的中间件
  4. koa的requestresponse集成在了ctx对象中,为客户端响应数据不能像http或者express那样使用end方法进行返回,而是需要给ctx.response.body赋值对应的响应结果才可以
const koa = require('koa')

const app = new koa()

app.use((ctx, next) => {
  ctx.response.body = 'Hello World'
})

app.listen(3000, () => {
  console.log('Koa初体验服务器启动成功~');
})

Koa中间件

Koa通过创建的app对象来注册中间件,而且注册中间件只能通过use方法

  • Koa并没有提供methods的方法来注册中间件
  • 也没有提供path中间件来匹配路径,这就意味着我们不能像express那样来注册中间件:app.use(path, callback)
  • Koa中也不可以像express那样连续注册中间件即app(callback,callback,callback),其一次只能注册一个中间件

但是真实开发中我们如何将路径和methods分离呢

  • 方法一:根据request自己来判断
const koa = require('koa')

const app = new koa()

app.use((ctx, next) => {
  // 获取url和method的方法很像原生的http模块
  if (ctx.request.url === '/login') {
    if (ctx.request.method === 'POST') {
      ctx.response.body = 'login successfully'
    }
  } else {
    ctx.response.body = 'else operation'
  }
})

app.listen(3000, () => {
  console.log('Koa初体验服务器启动成功~');
})
  • 方法二:使用第三方路由中间件,这个下面会讲

路由的使用

Koa官方并没有给我们提供路由的库,我们可以选择第三方库:koa-router

  1. 首先先创建一个名为router的文件夹,在里面创建对应的路由文件
  2. 使用new Router创建路由时可以传递参数来指定路由对应的前缀
// router/user.js
// 导入koa-router这个库,但导入的是一个类
const Router = require('koa-router')

// 这里创建路由的时候需要传递一个对象进去,prefix属性表示该路由对应的前缀路径
const userRouter = new Router({ prefix: '/user' })

// router是可以使用对应的方法来注册中间件的,而且也可以连续注册中间件
userRouter.get('/', (ctx, next) => {
  ctx.response.body = 'user list'
})

userRouter.post('/', (ctx, next) => {
  ctx.response.body = 'create new user'
})

// 在路由文件的末尾导出创建好的路由
module.exports = userRouter

路由实例中有一个routes方法,其返回值是一个函数,可以让该函数充当中间件来完成路由的注册

// index.js
const koa = require('koa')

const userRouter = require('./router/user')

const app = new koa()

// 利用路由对象上的routers方法充当中间件函数来注册中间件
app.use(userRouter.routes())
// 利用路由对象上的allowedMethods方法来自动返回没有设置的请求方法
app.use(userRouter.allowedMethods())

app.listen(3000, () => {
  console.log('Koa初体验服务器启动成功~');
})

注意: allowedMethods用于判断某一个method是否支持

  • 如果我们请求get,那么是正常的请求,因为我们有实现get
  • 如果我们请求putdeletepatch,那么就会自动报错:Method Not Allowed,状态码:405
  • 如果我们请求linkcopylock,那么也会自动报错:Not Implemented,状态码:501

参数解析:params - query

使用路由的话很容易根据获取到传递过来的paramsquery,获取方式和express很像,通过ctx.request.paramsctx.request.query即可获取到客户端传递过来的参数

const Koa = require('koa')
const Router = require('koa-router')

const app = new Koa()

const userRouter = new Router({ prefix: '/user' })

userRouter.get('/:id', (ctx, next) => {
  console.log(ctx.request.params); // { id: '121345' }
  console.log(ctx.request.query); // { name: 'xiaoming', age: '18' }
})

app.use(userRouter.routes())

app.listen(3000, () => {
  console.log('Koa初体验服务器启动成功~');
})

下面是我们在postman上测试数据的结果:

参数解析:json - urlencoded

如果客户端传递过来的是json数据或者是x-www-form-urlencoded中的数据,我们可以借助一个第三方库koa-bodyparser来进行解析,引入的函数执行之后有一个返回值,其可以作为app.use的中间件函数注册,这样后面的中间件再被执行的时候就可以在ctx.request.body中获取到客户端传递过来的数据了

const Koa = require('koa')
const bodyParser = require('koa-bodyparser')

const app = new Koa()

app.use(bodyParser())

app.use((ctx, next) => {
  console.log(ctx.request.body); // { name: 'aaa', age: 'bbb' }
})

app.listen(3000, () => {
  console.log('Koa初体验服务器启动成功~');
})

下面是在postman上测试的示例:

参数解析:form-data

使用Koa来解析表单提交的数据时和express一样都要依赖于第三方库,只不过koa依赖的库叫做koa-multer,而express依赖的叫做multer而已,他们的用法其实很类似:都是要在引入multer之后再执行,最后得到一个upload对象,注册一个upload.any中间件

注意:koa-multer这个库将解析好的form-data的数据都是放到了原生的ctx.req上面,所以在这里我们需要通过ctx.req.body去获取解析好的数据,而不是koa自己实现的ctx.request.body上

const Koa = require('koa')
const multer = require('koa-multer')

const app = new Koa()

const upload = multer()

app.use(upload.any())

app.use((ctx, next) => {
  console.log(ctx.req.body); // { name: 'asd ', age: '20' }
})

app.listen(3000, () => {
  console.log('Koa初体验服务器启动成功~');
})

下图是测试客户端使用form-data传数据的结果,但一般来说我们只用form-data来传递文件,所以这种场景见到的不多

Multer上传文件

koa-multerexpress中的multer基本使用方法是一样的,只不过koa-multer会将我们解析好的数据都放置到ctx.req对象中,所以我们如果想获取到由express-multer处理过的文件信息的话,需要去到ctx.req.file中查找而不是ctx.request.file

const path = require('path')

const Koa = require('koa')
const multer = require('koa-multer')
const Router = require('koa-router')

const app = new Koa()

// storage在和express中使用multer创建的方法一样
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, './uploads/')
  },
  filename: (req, file, cb) => {
    cb(null, Date.now() + path.extname(file.originalname))
  }
})

// upload在和express中使用multer创建的方法一样
const upload = multer({
  storage
})

// 创建路由并指定路由接收的前缀
const fileRouter = new Router({ prefix: '/upload' })

fileRouter.post('/', upload.single('avatar'), (ctx, next) => {
  // 获取上传的文件信息
  console.log(ctx.req.file);
  ctx.response.body = '上传文件成功!'
})

// 将我们创建的路由注册成中间件
app.use(fileRouter.routes())

app.listen(3000, () => {
  console.log('Koa初体验服务器启动成功~');
})

数据的响应

状态码

状态码的设置可以通过ctx.response.status = 状态码来实现,一般设置状态码的操作会放在我们设置响应数据的前面

ctx.response.status = 200
ctx.response.body = 'Hello World'

响应结果

输出结果:body将响应主体设置为以下之一:

  • string:字符串数据
  • Buffer:Buffer数据
  • Stream:流数据
  • Object ``Array:对象或者数组,客户端收到的是一个json数据
  • null:不输出任何内容
  • 如果response.status尚未设置,Koa会自动将状态设置为200204(服务器端成功处理了请求,但不需要返回任何实体内容。返回结果为null时状态码就会自动变为204)
ctx.response.body = {
  name: 'asd',
  age: 18
}

但我们返回数据不仅仅可以通过ctx.response.body,也可以直接给上下文的body对象赋值即ctx.body,一样可以达到响应结果的目的,并且和上面的效果是一样的

ctx.body = {
  name: 'asd',
  age: 18
}

但实际上,ctx.body响应数据的本质还是用到了ctx.response.body,我们可以在源码中寻找到相关的逻辑,而且koa利用delegate函数在取值的时候帮助我们做了一个代理:

当我们访问ctx.query的时候相当于访问的是ctx.request.query,所以我们想要获取query数据的时候可以直接使用ctx.query

当我们需要使用ctx.status和ctx.body返回数据的时候,其实还是通过代理把值传递给了ctx.response.statusctx.response.body,所以我们在返回响应状态和响应信息的时候,直接使用ctx.status和ctx.body更加方便

// 为了更好理解,这里删除了部分源码
delegate(proto, 'response')
  .access('status')
  .access('message')
  .access('body')
 
delegate(proto, 'request')
  .method('get')
  .access('search')
  .access('method')
  .access('query')
  .access('path')
  .access('url')

静态服务器

koa并没有内置部署相关的功能,但是我们可以使用第三方库:koa-static;部署的过程类似于express

const Koa = require('koa')
const staticAssets = require('koa-static')

const app = new Koa()

// 指定我们的静态资源文件夹
app.use(staticAssets('./dist'))

app.listen(3000, () => {
  console.log('Koa静态资源部署~');
})

错误处理方式

可以通过监听事件和触发事件的方式统一处理错误。因为我们一般都是在路由中处理逻辑,并不方便获取到全局的app,但ctx上下文中是具有app对象的,其和new Koa()得到的app是同一个变量,可以利用app的emit方法触发指定名称的事件以及传入对应的错误信息和ctx,这样我们就可以在监听错误的事件中根据错误信息统一处理错误并利用传入的ctx返回响应对应的结果

const Koa = require('koa')

const app = new Koa()

app.use((ctx, next) => {
  // 假装用户登录失败
  const isLogin = false
  if (!isLogin) {
    // 利用ctx上的app对象发送事件
    ctx.app.emit('error', new Error('您还没有登录哦!'), ctx)
  }
})

app.on('error', (err, ctx) => {
  const code = 401
  // 利用传入的ctx返回状态码和响应结果
  ctx.status = code
  ctx.body = {
    code,
    message: err.message
  }
})

app.listen(3000, () => {
  console.log('Koa初体验服务器启动成功~');
})

Koa源码分析

调用new Koa()到底创建的是什么?

我们从koa中导入的其实是个Application类,所以在创建app对象的时候要通过new关键字来创建,该实例上初始化了很多的属性,比如说middlewarecontext等,其原型上面也增添了很多方法,比如说listenhandleRequest

  1. 我们会发现koa的源码下的lib文件夹下面没有index.js文件,为了知道koa的入口文件是哪个?我们必须要先去package.json文件夹中去查找main属性(其对应了包的入口文件),于是发现lib/application.js才是koa的入口文件

  1. 既然知道了入口文件,那么我们就知道通过require('koa')导入的到底是什么了?

express最大的区别就是,express导入的是一个函数,而koa导入的却是一个Application类,该类还继承了Emitter类,所以app对象才可以通过emit方法发送事件

// lib/application.js
// 为了更好的阅读,这里删掉了部分源码
module.exports = class Application extends Emitter {
  constructor (options) {
    super()
    this.middleware = []
    this.context = Object.create(context)
    this.request = Object.create(request)
    this.response = Object.create(response)
  }
  
  listen (...args) {
    const server = http.createServer(this.callback())
    return server.listen(...args)
  }
}
  1. 所以当我们执行const app = new Koa()代码的时候,实际上赋值给app的是一个创建好的实例对象,该对象上初始化了很多属性,比如说middlewarecontext等,其原型上面还添加了很多的方法,比如说listen等等

app.listen()是如何启动服务器的?

无论是express还是koa,本质上都是基于http模块的封装;所以app.listen的具体实现过程还是依赖于http.createServerserver.listen这两个函数来实现的,和express在实现方案上面没有区别

  1. 先利用http.createServer方法搭建服务器,只不过这里传入的是app.callback函数(具体app.callback执行了哪些操作我们后面再进行研究)
  2. 然后再通过server.listen开启服务器,并将我们传递给app.listen的参数全部传递给server.listen
// lib/application.js
// 这段代码截取自Application类
module.exports = class Application extends Emitter {
  listen (...args) {
    const server = http.createServer(this.callback())
    return server.listen(...args)
  }
}

app.use(中间件)内部到底发生了什么?

其内部的操作很简单,仅仅先判断了一下参数类型,没有错误就直接将用户传入的函数放置到app.middleware这个数组当中去

  1. 首先找到了app.use的函数之后,其首先会判断一下我们传入的参数是不是函数,因为在koaapp.use方法不允许指定路径,所以如果传入的不是函数就会抛出报错
  2. 其次,我们还记得在创建app实例的时候,constructor函数里面会给app初始化一个middleware属性,其初始值为一个空数组,app.use会将传入的中间件函数就是放入这个middleware数组中去,这一点和express很像,注册中间件的方式都是将中间件函数放置到一个数组里面
// lib/application.js
// 这段代码截取自Application类
module.exports = class Application extends Emitter {
  use (fn) {
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!')
    this.middleware.push(fn)
    return this
  }
}

用户发送了网络请求,中间件是如何被回调的?

  1. 用户发送了网络请求之后,最先执行的就是传入http.createServer的回调函数,但仔细观察之后会发现执行的回调其实是app.callback函数的返回值
// lib/application.js
// 这段代码截取自Application类
module.exports = class Application extends Emitter {
  listen (...args) {
    const server = http.createServer(this.callback())
    return server.listen(...args)
  }
  
  callback () {
    const fn = compose(this.middleware)
    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res)
      return this.handleRequest(ctx, fn)
    }
    return handleRequest
  }
}
  1. 来到callback函数之后,发现其返回的是内部定义的handleRequest函数,也就是说在用户发送了网络请求之后,执行的是callback函数内部返回的handleRequest函数,并把node传递过来的reqres都传递了进去
  2. 但如果想真正搞懂中间件是怎么被执行的,还需要一步步阅读callback中的代码,首先从componse函数开始,执行该函数时将对应的中间件数组middleware作为参数传递了进去,并将返回值赋值给了变量fn;但我们找compose函数的时候发现,compose又是依赖于koa-compose这个库来实现的
// lib/application.js
const compose = require('koa-compose')
  1. 来到componse函数之后,发现其一开始做的是一些参数校验的工作,确保传递进去的是一个数组且每一个元素都是一个函数。它的返回值也是一个函数,对应着callback函数中的fn变量,但是我们还不知道什么时候调用它,所以先搁置下它里面的具体逻辑继续往下看
// koa-componse/index.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!')
  }

  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 {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}
  1. 现在我们来具体研究handleRequest函数,app.createContext执行之后将返回值赋值给了ctx,ctx又作为参数传递给了app.handleRequest函数,而且还通过闭包接收了刚刚所研究过的fn函数;但现在有两个问题:app.createContext做了什么?app.handleRequest又干了什么?
// lib/application.js
// 这段代码截取自Application类
const handleRequest = (req, res) => {
  const ctx = this.createContext(req, res)
  return this.handleRequest(ctx, fn)
}
  1. createContext:很明显的看到了其把node传递过来的reqres对象先分别赋值给了ctx.requestctx.response,紧接着其又将ctx.requestctx.response分别赋值给了ctx.reqctx.res。其实现在我们应该能想象到我们在中间件中为什么可以使用ctx.req和ctx.res来简化书写了,因为koa内部帮助我们做了代理。同理,context上面被添加了app属性,其值就等于我们在全局下所创建的app对象,这也解释了为什么我们能够通过context.app访问到全局的app了,就是因为这里有一步赋值操作
// lib/application.js
// 这段代码截取自Application类
createContext (req, res) {
  const context = Object.create(this.context)
  const request = context.request = Object.create(this.request)
  const response = context.response = Object.create(this.response)
  context.app = request.app = response.app = this
  context.req = request.req = response.req = req
  context.res = request.res = response.res = res
  return context
}
  1. handleRequest:通过观察该函数的结构,发现其是先执行了fnMiddleware函数,并将上下文对象ctx作为了参数传递进去。而fnMiddleware函数恰好就是koa-componse解析middleware数组返回的函数fn,所以我们还是需要回过头去研究fn函数

再加上观察函数的执行过程,发现fnMiddleware函数返回的值是一个promise,因为它后面有个then函数。那就说明在其对应的promise状态变为fulfilled之后才会去执行handleResponse函数返回结果(handleResponse函数的具体操作后面有讲),如果发生了错误就由onerror函数来进行处理

// lib/application.js
// 这段代码截取自Application类
handleRequest (ctx, fnMiddleware) {
  const res = ctx.res
  res.statusCode = 404
  // onerror是有关错误处理的逻辑代码
  const onerror = err => ctx.onerror(err)
  // 为客户端响应结果的逻辑代码
  const handleResponse = () => respond(ctx)
  onFinished(res, onerror)
  return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}
  1. 我们这次细致来研究一下fnMiddleware函数,也就是compose函数的返回值fn,首先执行的是dispatch(0),传入的数字代表了在dispatch函数内部要去执行middleware中哪个索引对应的中间件函数,dispatch函数的返回结果很特别——由Promise.resolve包裹,所以其返回值是一个promise对象:Promise.resolve(fn(context, dispatch.bind(null, i + 1))),但从这句代码中我们可以发现fn是会被执行的,而且在执行的时候被传入了ctx和一个指定了参数的dispatch函数,这其实就是我们在中间件中使用的ctx和next函数

这里的Promise.resolve非常重要,首先我们要知道如果resolve函数里面又是一个promise对象,那么外层promise的状态就会由内层promise的状态来决定,所以如果第一个中间件是异步操作的,比如是一个async函数(async函数返回结果是一个promise对象,其状态会等到函数代码执行完才改变)。所以当then回调被触发时,第一个异步中间件函数是已经确保执行完毕了的

如果每个中间件函数都是同步任务的话,那么then回调一定会在所有匹配到的中间件函数执行完毕之后才回去执行

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
      // 取出来的fn就是我们用app.use注册的中间件函数了
      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)
      }
    }
  }

调用next为什么会执行下一个中间件?

其实我们应该已经发现了,传入给中间件函数的next实际上就是dispatch函数,但其已经指定了下一个要执行的中间件函数索引,所以当我们调用next函数时,实际上就是去middleware中寻找下一个中间件函数来执行

koa中是怎么返回响应结果的?

这就涉及到了刚刚提到过的handleResponse函数,因为该函数是被放到then函数里面执行的,所以当需要返回结果的时候一定已经执行完了所有匹配的中间件,然后才通过res.end返回了ctx.body中的数据,这也解释了为什么我们在多个中间件中给ctx.body赋值,最后返回的响应结果是最后一次给ctx.body赋的值

  1. handleResponse函数其实内部调用的是一个respond函数,并且利用闭包将ctx传递了进去
const handleResponse = () => respond(ctx)
  1. 可以看到使用ctx.body返回数据的本质其实用的还是res.end,而且我们现在应该也能解释为什么koa可以直接返回任意类型(对象、数组、字符串等等)的数据给客户端,因为其在真正响应数据之前会通过数据类型对要返回的数据先做一个处理
// 下面的代码是经过简化删除的
function respond (ctx) {
  const res = ctx.res
  let body = ctx.body
  const code = ctx.status

  // responses
  if (Buffer.isBuffer(body)) return res.end(body)
  if (typeof body === 'string') return res.end(body)
  if (body instanceof Stream) return body.pipe(res)

  // body: json
  body = JSON.stringify(body)
  res.end(body)
}

Koa和express对比

从架构设计上来说:

  • express是完整和强大的,其中帮助我们内置了非常多好用的功能

  • Koa是简洁和自由的,它只包含最核心的功能,并不会对我们使用其它中间件进行任何的限制

    • 甚至在app中连最基本的getpost都没有给我们提供
    • 我们需要通过自己或者路由来判断请求方式或者其他功能
  • 因为express和Koa框架他们的核心其实都是中间件

    • 但是他们的中间件事实上执行机制是不同的,特别是针对某个中间件包含异步操作时
    • 所以,接下来,我们再来研究一下express和koa中间件的执行顺序问题

案例对比

express实现-同步数据

因为next函数是同步执行的,而每个中间件中的req其实是同一个对象,那么等到第一个中间件要执行res.end函数的时候。req.message已经实现了我们的累加效果,想要了解具体的同学可以去看我下这篇文章,里面有对next函数做一个源码分析:juejin.cn/post/710380…

const express = require('express')

const app = express()

const middleware1 = (req, res, next) => {
  req.message = 'aaa'
  next()
  res.end(req.message)
}

const middleware2 = (req, res, next) => {
  req.message += 'bbb'
  next()
}

const middleware3 = (req, res, next) => {
  req.message += 'ccc'
}

app.use(middleware1, middleware2, middleware3)

app.listen(3000, () => {
  console.log('express框架初体验');
}) 

最终客户端得到的结果确实是aaabbbccc

express实现-异步数据

如果我想等到第三个中间件请求到数据之后,将请求到的数据拼接到req.message再在第一个中间件中返回,还可以用类似刚刚的代码实现吗?其实就不行了!!!因为next函数并不会去等待里面的异步操作执行完,所以在middleware3中即使还没有接受到响应结果,代码依然会回到middleware1中去执行res.end函数

const express = require('express')
const axios = require('axios')

const app = express()

const middleware1 = (req, res, next) => {
  req.message = 'aaa'
  next()
  res.end(req.message)
}

const middleware2 = (req, res, next) => {
  req.message += 'bbb'
  next()
}

const middleware3 = (req, res, next) => {
  axios.get('http://localhost:8000').then(res => {
    req.message += res.data
  })
}

// 用async和await结果也是一样的,因为async函数执行时如果前面没有await,则不会阻塞代码继续向下执行
// const middleware3 = async (req, res, next) => {
//   await axios.get('http://localhost:8000').then(res => {
//     req.message += res.data
//   })
// }

app.use(middleware1, middleware2, middleware3)

app.listen(3000, () => {
  console.log('express框架初体验');
}) 

客户端拿到的结果是aaabbb,并没有将我们使用axios请求到的结果拼接上去。准确来说,是在拼接上去之前就已经返回响应结果了

Koa实现-同步数据

Koa来处理同步数据的时候效果和express一样,只是用来返回数据的操作不一样而已

const Koa = require('koa')

const app = new Koa()

app.use((ctx, next) => {
  ctx.message = 'aaa'
  next()
  ctx.body = ctx.message 
})

app.use((ctx, next) => {
  ctx.message += 'bbb'
  next()
})

app.use((ctx, next) => {
  ctx.message += 'ccc'
})

app.listen(8000, () => {
  console.log('Koa初体验服务器启动成功~');
})

最终客户端接受到的结果也是aaabbbccc

Koa实现-异步数据

Koa在执行第一个中间件函数时,本质上是调用了dispatch(0)dispatch函数的返回值是一个Promise对象,结合上面所看到的handleRequest源码,我们发现只有等到dispatch(0)返回的Promise状态改变时才会调用then回调去使用req.end返回响应结果

首先,我们必须得知道如果Promise.resolve函数中又是一个Promise对象,那么外层promise的状态将由内层的promise的状态所决定,那么既然外层promise状态改变了之后才会返回数据,我们刚好可以利用Promise.resolve的特点,给其传入一个Promise对象,让我们传入的Promise去控制外层的promise状态

很容易想到async函数,因为其返回值就是一个promise对象,而且它的状态是在其内部所有代码包括await语句后面的代码执行完毕后状态才发生改变的。这样就可以保证,只要我们让第一个中间件等到其它中间件执行完毕(包括异步请求)后才返回值改变promise的状态,就可以实现触发then回调返回数据前,我们已经执行完了所有中间件的同步异步操作,自然异步请求到的数据也会添加到对应的响应信息中

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 {
      // 在这里返回值时使用Promise.resolve做了一层包裹
      return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
    } catch (err) {
      return Promise.reject(err)
    }
  }
}

简单来说,next函数的返回值是一个通过Promise.resolve返回的promise对象,我们恰好可以让fn的返回值也是一个promise对象,最简单的方法就是给中间件函数前面加上一个async关键字,这样就可以利用中间件函数来控制next函数对应的promise状态了

当我们搞懂了上面的逻辑之后,就可以解释为什么我们的代码可以实现对应的效果了。指定第一个中间件函数为async,是为了控制最外层dispatch(0)返回值的promise状态,因为它决定了我们何时去调用then回调返回数据;第一个中间件里面的await next()语句是为了等待dispatch(1)返回值的promise状态改变;第二个中间件里面的await next()语句是为了等待dispatch(2)返回值的promise状态改变;当中间件三获取到了对应的请求结果并执行完毕之后,dispatch(2)对应的promise状态改变,导致中间件2执行完毕,dispatch(1)的promise状态发生改变,于是来到了第一个中间件中ctx.body = ctx.message这行代码,执行完毕后dispatch(0)的promise状态也发生了改变,此时then回调会被执行并给客户端返回响应结果

这就解释了为什么给每个中间件函数使用了asyncawait之后,我们就可以在第一层中间件等待第三层中间件的异步结果执行完后再返回响应结果

const Koa = require('koa')
const axios = require('axios')

const app = new Koa()

app.use(async (ctx, next) => {
  ctx.message = 'aaa'
  // 
  await next()
  ctx.body = ctx.message
})

app.use(async (ctx, next) => {
  ctx.message += 'bbb'
  // 等待第三个中间件状态改变,也就是代码全部执行完毕,因为next函数的返回值是一个promise对象
  await next()
})

app.use(async (ctx, next) => {
  // 等待异步请求完成
  const { data } = await axios.get('http://localhost:3000')
  ctx.message += data
})

app.listen(8000, () => {
  console.log('Koa初体验服务器启动成功~');
})

这种方案客户端接受到的数据就是aaabbbccc

结论

Koa来处理中间件的异步逻辑更加的灵活,因为其next函数的返回值是一个Promise对象,我们可以人为的去控制其状态何时改变,从而控制返回响应结果的时机;而express中next函数的返回值是由它自己来控制的,所以我们无法通过next函数去控制每个中间件的异步函数