手把手带你用 128 行代码实现一个简易版 Koa 框架

Koa 是由 Express 背后的团队设计的一个新的 Web 框架,不过比 Express 更精简,更现代。具体来说:

一、中间件机制

Koa 中通过异步函数,实现中间件控制权传递到"下游"后,依然能够流回"上游",而非 Express 那样仅仅实现了控制权传递,无法追踪,实现了真正意义上的中间件级联调用。

二、上下文对象 context

中间件调用时接收的是上下文对象 context,而非像 Express 中间件那样接收原生 reqres 对象;另外,context 对象上提供了一些快捷属性和方法,帮助开发者完成相关操作,使用更便捷。

下面,我们先来学习一下 Koa 的常用使用。

基本使用

先来看一下 Koa 的"hello world"程序。

javascript 复制代码
const Koa = require('koa')
const app = new Koa()

app.use(async ctx => {
  ctx.body = 'Hello World'
});

app.listen(3000)

跟 Express 相比,写起来更加简单了。与 Express 有 2 点不一样。

  1. Koa 应用是采用类实例方式创建的(new Koa()),而非 Express 的工厂函数方式创建的(express()
  2. 中间件依然是采用 app.use() 方法收集,不过中间件接收的第 1 个参数变成了上下文对象 ctx,而非原生 reqres 对象,原生 reqres 对象可以通过 ctx.reqctx.res 获取

Koa 禁止通过直接修改 ctx.res 的方式返回响应,包括:

  • res.statusCode
  • res.writeHead()
  • res.write()
  • res.end()

而必须使用 Koa 自己包装的 Response/Request 对象(通过 ctx.responsectx.request 获取)或是借助 ctx.bodyctx.statusctx.messagectx.typectx.set() 等这些快捷方式设置,快捷方式本质上是对 ctx.response, ctx.request 的高层抽象,通常会使用快捷方式进行设置context 上的快捷方式分布可以查看这里(Request Alias)这里(Response Alias)

还有,跟 Express 一样,app.listen(3000) 实际上是 http.createServer(app.callback()).listen(3000) 的语法糖。

javascript 复制代码
const Koa = require('koa')
const app = new Koa()

app.listen(3000);
// 等同于
// http.createServer(app.callback()).listen(3000);

当你还需要同时启动 https 服务时,要这样写。

javascript 复制代码
const http = require('http')
const https = require('https')
const Koa = require('koa')
const app = new Koa()
http.createServer(app.callback()).listen(3000)
https.createServer(app.callback()).listen(3001)

当然,你可以同时注册多个中间件。

javascript 复制代码
const Koa = require('koa')
const app = new Koa()

// logger

app.use(async (ctx, next) => {
  await next()
  const rt = ctx.response.get('X-Response-Time')
  console.log(`${ctx.method} ${ctx.url} - ${rt}`)
});

// x-response-time

app.use(async (ctx, next) => {
  const start = Date.now()
  await next()
  const ms = Date.now() - start
  ctx.set('X-Response-Time', `${ms}ms`)
});

// response

app.use(async ctx => {
  ctx.body = 'Hello World'
});

app.listen(3000);

与 Express 类似,中间件系统也是通过 next() 参数连接的。不过不同的是,next() 函数调用后会始终返回一个 Promise 对象,你可以通过 async 函数 + await next() 的方式,在后续中间件执行完成后,做一些操作,这是 Express 中间件系统无法实现的。

拿上面的案例来说:

首先,我们在第 2 个中间件通过 await next() 方式等待响应体设置后(ctx.body = 'Hello World'),通过 ctx.set() 设置了响应头 X-Response-Time

同时,我们在第 1 个中间件中通过 await next() 方式监听第 2 个中间件完成执行,获取并打印响应头 X-Response-Time 的值。

app 上还提供了一个 .context 属性,它是传递给中间件 ctx 参数的原型对象。你可以通过编辑 app.contextctx 添加其他属性,并对所有中间件可见。

javascript 复制代码
app.context.db = db();

app.use(async ctx => {
  console.log(ctx.db)
});

最后,Koa 没有专门的错误中间件。它会通过 app 上的 error 事件,捕获错误。

javascript 复制代码
app.on('error', err => {
  log.error('server error', err)
});

要说明但是,Koa 实例本身也是 EventEmitter 对象,可以触发(.emit(eventName))和监听事件(.on(eventName))。

以上我们就介绍完了 Koa 的基本使用。下面来看如何实现它。

代码实现

依据 Koa v2.15.0 版本代码

查看 Koa 仓库源码,一共 4 个文件。

application.js 存放 Koa 和核心实现逻辑(包括 app.use()app.callback(), app.listen()),context.js 中存储上下文对象中的一些方法和属性,request.jsresponse.js 则分别是 Koa 基于 reqres 封装的对象。

我们的实现不会存放在不同的文件中,而是放在一个文件 koa.js 里面,也不会引入任何外部依赖。

Koa 类及实例属性

首先,Koa 是一个类,并且继承了 EventEmitter

javascript 复制代码
const EventEmitter = require('events')

module.exports = class Application extends EventEmitter {
  // TODO
}

初始化 app 实例上,有 3 个属性,contextrequestresponse

javascript 复制代码
const context = {}
const response = {}
const request = {}

module.exports = class Application extends EventEmitter {
  constructor() {
    super()

    this.context = Object.create(context)
    this.request = Object.create(request)
    this.response = Object.create(response)
  }
}

.context.request.response 属性分别基于 3 个原型对象创建,内容会随着我们的实现慢慢填充。

app.use()

Koa 我们先来实现 app.use() 方法,它是用来收集存储中间件的。

javascript 复制代码
module.exports = class Application extends EventEmitter {
  constructor() {
    super()

    this.middleware = []
    // ...
  }

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

实现比较简单,就是把传入进来中间件存储在内部的 middleware 数组属性上,为了实现链接调用,返回了 this

app.listen()

再来实现 app.listen() 方法。

javascript 复制代码
module.exports = class Application extends EventEmitter {
  // ...

  listen(...args) {
    return require('node:http').createServer(/* ? */).listen(...args)
  }
}

通过"基本使用"一节的介绍,我们知道 /* ? */ 中的 ? 对应的是 this.callback() 方法。

app.callback()

.callback() 包含 Koa 业务处理的核心逻辑,处理中间件数组的链式调用,并能正常处理返回。

javascript 复制代码
module.exports = class Application extends EventEmitter {
  // ...

  listen(...args) {
    return require('node:http').createServer(this.callback()).listen(...args)
  }

  callback() {
    return (req、res) => {
      // TODO
    }
  }
}

那该如何来实现呢?首先,我们会写一个 compose 函数来正确处理中间件的前端调用顺序。

compose() 函数

compose 函数类似 compose(middleware)(ctx),也就是说实际执行中间件逻辑的时候会传入上文对象 ctx

javascript 复制代码
function compose(middleware) {
  return function (context) {
    // TODO
  }
}

另外,内部 dispatch 函数专门用于执行中间件,你只要传入对应中间件的索引值就行。

javascript 复制代码
function compose(middleware) {
  return function (context) {
    function dispatch(index) {
      const fn = middleware[index]
      // TODO
    }
    return dispatch(0)
  }
}

在调用当前中间件时,除了会传递上下文对象,还会传递下一个中间件的触发函数 dispatch(index + 1) 作为 next() 参数,用于将控制权传递给下一个中间件。

diff 复制代码
function compose(middleware) {
  return function (context) {
    function dispatch(index) {
      const fn = middleware[index]
+     return fn(context, dispatch.bind(null, index + 1))
    }
    return dispatch(0)
  }
}

如果不调用 next(),那么执行流程会在当前中间件结束后就返回了。

同时,为了确保每个中间件是异步函数,返回一个 Promise,还要额外加一个 Promise.resolve() 包装。

diff 复制代码
function compose(middleware) {
  return function (context) {
    function dispatch(index) {
      const fn = middleware[index]
-     return fn(context, dispatch.bind(null, index + 1))
+     return Promise.resolve(fn(context, dispatch.bind(null, index + 1)))
    }
    return dispatch(0)
  }
}

如果 fn() 调用抛错,还要能获取异常。

diff 复制代码
function compose(middleware) {
  return function (context) {
    function dispatch(index) {
      const fn = middleware[index]
+     try {
        return Promise.resolve(fn(context, dispatch.bind(null, index + 1)))
+     } catch (err) {
+       return Promise.reject(err)
+     }
    }
    return dispatch(0)
  }
}

当然,中间件都执行完了的时候,fnundefined,也要处理。

diff 复制代码
function compose(middleware) {
  return function (context) {
    function dispatch(index) {
      const fn = middleware[index]
+     if (!fn) {
+       return Promise.resolve()
+     }
      
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, index + 1)))
      } catch (err) {
        return Promise.reject(err)
      }
    }
    return dispatch(0)
  }
}

写完 compose,让我们重新回到 callback(),继续写逻辑。

重回 app.callback()

app.callback() 中,我们首先使用 compose() 处理中间件数组,让这些中间件实现串行调用。

javascript 复制代码
module.exports = class Application extends EventEmitter {
  // ...

  callback() {
    return (req、res) => {
      const fnMiddleware = compose(this.middleware)
      const ctx = this.createContext(req、res)
      return fnMiddleware(ctx).then(/* ? */).catch(/* ? */)
    }
  }
}

接下来调用 fnMiddleware 传入上下文对象,传入的 ctx 需要我们基于 reqres 创建,创建的逻辑封装在 app.createContext 中,我们来看一下。

app.createContext()

app.createContext() 逻辑如下所示。

javascript 复制代码
module.exports = class Application extends EventEmitter {
  // ...

  createContext(req、res) {
    const context = Object.create(this.context)
    const request = Object.create(this.request)
    const response = Object.create(this.response)

    context.request = request
    context.response = response

    context.req = request.req = response.req = req
    context.res = request.res = response.res = res
    context.app = request.app = response.app = this

    return context
  }
}

每次请求,我们都会重新创建包括 requestresponsecontext 在内的 3 个对象,同时:

  • context 会部署 requestresopnse 属性,用于设置快捷属性。另外
  • contextrequestresponse 会部署 resreqapp 属性,方便对原生对象进行代理访问,并能与应用实例进行交互

理解了 createContext(),我们再来看 callback()

javascript 复制代码
module.exports = class Application extends EventEmitter {
  // ...

  callback() {
    return (req、res) => {
      const fnMiddleware = compose(this.middleware)
      const ctx = this.createContext(req、res)
      return fnMiddleware(ctx).then(/* ? */).catch(/* ? */)
    }
  }
}

因为 Koa 禁止我们直接使用 ctx.res 对象设置响应信息,那么在所有中间件处理结束后,在 .then(/* ? */) 里就需要我们处理请求响应。

respond()

我们把响应逻辑抽象在 respond() 中,并传入 ctx

javascript 复制代码
return fnMiddleware(ctx).then(() => respond(ctx)).catch(/* ? */)

respond 方法中,为避免复杂,我们只处理字符串类型响应和 JSON 对象响应。

javascript 复制代码
function respond(ctx) {
  let body = ctx.body

  // body: string
  if (typeof body === 'string') {
    return ctx.res.end(body)
  }

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

如你所见,内部通过 ctx.body 获取请求体数据(等同于 ctx.reposnse.body,这块实现稍后说明),再对 body 值的类型进行检查,最后通过 ctx.res.end() 返回响应、终止请求。

ctx.onerror()

.catch(/* ? */) 里我们处理异常,这块逻辑(onerror)定义在原型对象 context 中。

javascript 复制代码
return fnMiddleware(ctx).then(() => respond(ctx)).catch((err) => ctx.onerror(err))
javascript 复制代码
const context = {
  onerror(err) {
    this.app.emit('error', err, this)
    let statusCode = err.status || err.statusCode || 500
    const msg = err.message ?? require('node:http').STATUS_CODES[statusCode]
    this.res.end(msg)
  }
}
const request = {}
const response = {}

module.exports = class Application extends EventEmitter {
  // ...
}

首先,在 app 实例上触发一个 error 事件,供外部监听。其次,使用异常消息作为响应数据返回。

接下来,再讲一讲 ctx.body 的实现。

ctx.body

ctx.body 其实是对 ctx.reposnse.body 属性的代理,我们写一个简单的实现。

javascript 复制代码
const context = {
  // ...
  set body(val) {
    this.response.body = val
  },
  get body() {
    return this.response.body
  }
}

response.body 属性实现如下。

javascript 复制代码
const response = {
  set body(val) {
    this._body = val

    // set the content-type only if not yet set
    const setType = !this.has('Content-Type');

    // string
    if (typeof val === 'string') {
      if (setType) this.type = /^\s*</.test(val) ? 'text/html' : 'text/plain'
      return
    }

    // json
    this.type = 'application/json'
  },
  get body() {
    return this._body
  },
  set(field, val) {
    this.res.setHeader(field, val)
  },
  remove(field) {
    this.res.removeHeader(field)
  },
  has(field) {
    return this.res.hasHeader(field)
  },
  set type(type) {
    if (type) {
      this.set('Content-Type', type)
    } else {
      this.remove('Content-Type')
    }
  }
}

观察可知:

  • 我们将设置给 response.body 的值存储在了内部的 ._body
  • 在设置 response.body 的时候,我们根据 response.body 值类型,调整了 Content-Type 响应头字段的值
  • 继续在 response 上抽象了一连串针对底层 res 对象的操作

以上,我们就差不多完成了所有 Koa 核心逻辑的编写。

整体代码

现在来回顾一下整体代码。

javascript 复制代码
const EventEmitter = require('events')

const context = {
  onerror(err) {
    this.app.emit('error', err, this)
    let statusCode = err.status || err.statusCode || 500
    const msg = err.message ?? require('node:http').STATUS_CODES[statusCode]
    this.res.end(msg)
  },
  set body(val) {
    this.response.body = val
  },
  get body() {
    return this.response.body
  }
}
const response = {
  set body(val) {
    this._body = val

    // set the content-type only if not yet set
    const setType = !this.has('Content-Type');

    // string
    if (typeof val === 'string') {
      if (setType) this.type = /^\s*</.test(val) ? 'text/html' : 'text/plain'
      return
    }

    // json
    this.type = 'application/json'
  },
  get body() {
    return this._body
  },
  set(field, val) {
    this.res.setHeader(field, val)
  },
  remove(field) {
    this.res.removeHeader(field)
  },
  has(field) {
    return this.res.hasHeader(field)
  },
  set type(type) {
    if (type) {
      this.set('Content-Type', type)
    } else {
      this.remove('Content-Type')
    }
  }
}
const request = {}

module.exports = class Application extends EventEmitter {
  constructor() {
    super()

    this.middleware = []

    this.context = Object.create(context)
    this.request = Object.create(request)
    this.response = Object.create(response)
  }

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

  listen(...args) {
    return require('node:http').createServer(this.callback()).listen(...args)
  }

  callback() {
     return (req、res) => {
      const fnMiddleware = compose(this.middleware)
      const ctx = this.createContext(req、res)
      return fnMiddleware(ctx).then(() => respond(ctx)).catch((err) => ctx.onerror(err))
    }
  }

  createContext(req、res) {
    const context = Object.create(this.context)
    const request = Object.create(this.request)
    const response = Object.create(this.response)

    context.request = request
    context.response = response

    context.req = request.req = response.req = req
    context.res = request.res = response.res = res
    context.app = request.app = response.app = this

    return context
  }
}

function compose(middleware) {
  return function (context) {
    function dispatch(index) {
      const fn = middleware[index]
      if (!fn) {
        return Promise.resolve()
      }
      
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, index + 1)))
      } catch (err) {
        return Promise.reject(err)
      }
    }
    return dispatch(0)
  }
}

function respond(ctx) {
  let body = ctx.body

  // body: string
  if (typeof body === 'string') {
    return ctx.res.end(body)
  }

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

连上注释加空格,一个 128 行代码,不过移除原型对象部分的(context、request、response)代码,核心逻辑也只有 80 行代码不到而已。

总结

本来我们讲述了轻量级 Web 框架 Koa 的基本使用及实现过程。细心的你可能会发现 Koa 核心库并未内路由支持,这部分实现被放在了 koa-router 包中。

相比较于 Express 的中间件机制,Koa 利用异步函数实现了可回流的中间件调用机制,而不是简单的控制权传递;另一个优势的地方在于基于原生 reqres 对象进行了一层操作封装,也就是 Koa request、response 对象,让使用更加简单。

当然,本文只是对 Koa 框架核心功能的一个简单实现。其他像 options 支持、Koa Context/Request/Response 对象完整封装,大家有兴趣的话,可以参考源码进行学习。

本文就写到这里,感谢你的阅读,再见!

相关推荐
前端老宋Running2 分钟前
一次从“卡顿地狱”到“丝般顺滑”的 React 搜索优化实战
前端·react.js·掘金日报
隔壁的大叔3 分钟前
如何自己构建一个Markdown增量渲染器
前端·javascript
用户4445543654265 分钟前
Android的自定义View
前端
WILLF5 分钟前
HTML iframe 标签
前端·javascript
枫,为落叶23 分钟前
Axios使用教程(一)
前端
小章鱼学前端28 分钟前
2025 年最新 Fabric.js 实战:一个完整可上线的图片选区标注组件(含全部源码).
前端·vue.js
ohyeah29 分钟前
JavaScript 词法作用域、作用域链与闭包:从代码看机制
前端·javascript
流星稍逝31 分钟前
手搓一个简简单单进度条
前端
uup35 分钟前
JavaScript 中 this 指向问题
javascript
L***B56844 分钟前
如何安装linux版本的node.js
linux·运维·node.js