【koa快速入门】之深究原理

319 阅读5分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第8天,点击查看活动详情

koa系列

本文是koa快速入门系列的第二篇

前言

koa是Express团队打造的新一代web框架,特点是更小,更舒服的开发体验。

前两节我们已经介绍了koa的基本使用和koa项目的最佳实践,今天我们来深究下koa2的原理。

《koa2教程》思维导图

初看源码

查看koa2的源码,可以发现其实现代码非常简单,只有四个js文件。

下面先从这四个js文件介绍源码的大概结构:

application.js

是koa2的入口文件,在当中有Koa实例的构造函数,该构造函数继承events,来实现对(错误)事件的触发和监听。

listen函数,是对http.createServer的封装。

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

use函数,收集中间件。

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
}

callback函数,是用于处理中间件,安排中间件的执行顺序,并返回http.createServer可以处理的回调函数。

  /**
   * Return a request handler callback
   * for node's native http server.
   *
   * @return {Function}
   * @api public
   */  

  callback () {
    const fn = compose(this.middleware)

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

    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res)
      return this.handleRequest(ctx, fn)
    }

    return handleRequest
  }

  /**
   * Handle request in callback.
   *
   * @api private
   */

  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)
  }

context.js

就是中间件参数中的ctx,上下文,最主要的功能是基于delegates模块实现的。

delegates 基本用法就是将内部对象的变量或者函数绑定在暴露在外层的变量上。

通过delegates把ctx.repsponse.status等等repsponse和request上的属性,暴露在ctx上,类似ctx.status。

const proto = module.exports = {
  //...
}

delegate(proto, 'response')
  .method('attachment')
  .access('status')
//...

request.js和response.js

这两个类是对原生req和res的封装(这个原生req和res是http.createServer的回调函数返回的),用get和set对外暴露了很多方便使用的属性和方法,我们ctx访问的repsponse和request上的属性,其实是这些get和set方法。

  /**
   * Get response status code.
   *
   * @return {Number}
   * @api public
   */

  get status () {
    return this.res.statusCode
  },

  /**
   * Set response status code.
   *
   * @param {Number} code
   * @api public
   */

  set status (code) {
    if (this.headerSent) return

    assert(Number.isInteger(code), 'status code must be a number')
    assert(code >= 100 && code <= 999, `invalid status code: ${code}`)
    this._explicitStatus = true
    this.res.statusCode = code
    if (this.req.httpVersionMajor < 2) this.res.statusMessage = statuses[code]
    if (this.body && statuses.empty[code]) this.body = null
  },

深究原理

中间件和洋葱模型

下面重点介绍中间件洋葱模型执行顺序实现。

首先我们要了解中间件的执行顺序,先看下面这段代码。

let Koa = require('koa');

let app = new Koa();

app.use(async (ctx, next) => {
    console.log(1);
    await next();
    console.log(6);
});

app.use(async (ctx, next) => {
    console.log(2);
    await next();
    console.log(5);
});

app.use(async (ctx, next) => {
    console.log(3);
    ctx.body = "hello world";
    console.log(4);
});

app.listen(3000, () => {
    console.log('listenning on 3000');
});

输出的顺序是123456,koa的中间件是按洋葱模型的顺序执行的。

中间件之间通过 next 函数联系,当一个中间件调用 next() 后,会将控制权交给下一个中间件,直到下一个中间件不再执行 next() 时沿路返回,依次将控制权交给上一个中间件。

vqrjv4796k

那么,怎么实现这种执行顺序呢?

上面初看代码的时候,我们已经知道,在use中通过this.middleware.push(fn)完成了中间件的搜集,然后在callback中处理中间件的执行顺序。

我们先回顾一下koa的application.js。

 /**
   * Return a request handler callback
   * for node's native http server.
   *
   * @return {Function}
   * @api public
   */  

  callback () {
    const fn = compose(this.middleware)

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

    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res)
      return this.handleRequest(ctx, fn)
    }

    return handleRequest
  }

  /**
   * Handle request in callback.
   *
   * @api private
   */

  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)
  }

再看一下koa-compose中compose函数的实现。

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)
      }
    }
  }
}

我们先删掉不重要的部分,整理下代码,只保留实现洋葱模型执行顺序的代码。

function compose (middleware) {
  return function (context, next) {
    return dispatch(0)
    function dispatch (i) {
      let fn = middleware[i]
      if (i === middleware.length) fn = next  // 最后一个中间件也处理完,fn指向next
      if (!fn) {
        return Promise.resolve() // fn为null直接resolve
      } else {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
      }
    }
  }
}

重点在fn(context, dispatch.bind(null, i + 1)),在resolve中,调用了fn,在fn中又调用了dispatch(i+1)

我们可以先理解fn实现了下面这样的接口。

function fn(ctx, next){
	return next()
}

用前面打印顺序的例子,我们来盘一下执行顺序。

  1. dispatch(0),fn指向第一个中间件,在resolve中执行,然后就 console.log(1);
  2. 然后遇到了next(),于是调用到dispatch(1),fn指向第二个中间件,执行fn,console.log(2)
  3. 然后遇到了next(),于是调用到dispatch(2),fn指向第三个中间件,执行fn,console.log(3),继续console.log(4)
  4. 然后,第二个中间件中的next执行完返回了,就继续执行第二个中间件next后面的console.log(5)。(函数调用栈的原理)
  5. 然后,第一个中间件中的next执行完返回了,就继续执行第一个中间件next后面的console.log(6)

OK了,顺序这就搞清楚了!

总结

了解了koa2的源码,最直观的一个感受就是,koa2的实现方式很先进,而且很简洁。

大量使用了es6的新特性,和一些功能强大又小巧的第三方模块,最终的koa2的产品,也遵从这种简洁的设计理念,只做好一个中间件框架,不附带一点点其他更多的功能。

参考文献

koa官网

KOA2框架原理解析和实现

往期好文

koa入门系列

【koa快速入门】之基础使用

【koa快速入门】之最佳实践

【koa快速入门】之深究原理

“告别烂代码”

2022代码规范最佳实践(附web和小程序最优配置示例)

【前端探索】告别烂代码!用责任链模式封装网络请求

【前端探索】告别烂代码第二期!用策略模式封装分享组件

代码人生

【三年前端开发的思考】如何有效地阅读需求?

前端踩坑必看指南

【前端探索】图片加载优化的最佳实践

【前端探索】移动端H5生成截图海报的探索

【前端探索】H5获取用户定位?看这一篇就够了

【前端探索】微信小程序跳转的探索——开放标签为什么存在?