逐步解析 koa2 核心实现原理及代码实践
引言
作为一个前端,工作大部分时间都是在和页面打交道,但如果有一天你有一个很好的产品 idea,前后端都需要,就尴尬了,一般这个时候有 3 条路走:
- 放弃,心里想着这产品哪怕做出来可能也没人用,还要消耗自己大部分时间和人力,白费力气。
- 找一个认识的后端,说动他(她)并一起实践这个伟大的想法。
- 前后端都自己做,不过要花大量时间去涉猎服务端相关知识,并且一个人做两个人的活儿,累死累活最终实现这个伟大的想法。
我的建议还是首先尝试一下第三条路(如果你觉得这个想法再不快点实现就亏了一个亿,你也可以首先选第二条路),先别想着放弃,一方面哪怕最后不成功没人用,但是从技术角度讲,这不是刚好扩展了自己的知识面吗?而且万一咱的产品🔥了呢?
想搞服务端,前端最好的选择无非就是 NodeJS 了,语法和我们写前端时的 JavaScript 是一样的,只需要了解相关的 node 模块即可上手编写服务端代码。而我们最先应该了解的就是 http
模块,它能让我们快速启动一个服务。但是因为历史包袱以及考虑到广泛的适用性,原生的模块多少是需要二次封装一下才能很好地服务于我们开发者。
Koa2 原理实现
Koa2
就是这么个封装了原始 http
模块,拥有更好的心智模型的框架,接下来我会从原理实现入手,讲清楚如何实现一个基本的 Koa2
,再到后面介绍如何基于 Koa2
开始我们的服务端开发!一个快乐的 SQL Boy!🎉
koa2 与 http 简单对比
我们先使用 node 原生模块 http
起一个本地服务:
// server-http.js
const http = require('http')
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end('<h1>Hello World</h1>')
})
server.listen(3000, () => {
console.log('server is running on http://localhost:3000')
})
执行一下 node server-http.js
,在浏览器打开 http://localhost:3000
,即可看到效果。
现在使用对 http
进行了封装的 Koa2
起一个类似的本地服务,当然了,这需要我们先安装一下这个包,控制台执行下 npm i koa
,然后在新建的文件中写入以下代码:
// server-koa.js
const Koa = require('koa')
const app = new Koa()
app.use(async ctx => {
ctx.response.res.writeHead(200, { 'Content-Type': 'text/html' })
ctx.body = '<h1>Hello World</h1>'
})
app.listen(3001, () => {
console.log('server is running on http://localhost:3001')
})
执行一下 node server-koa.js
,在浏览器打开 http://localhost:3001
,即可看到一样的效果。
我们对比下两者书写上的区别,可以很直观地发现:
- 从
koa
中导出的是一个类class
,没有暴露创建服务的方法http.createServer
,可以猜想到是在app.listen
中执行了此方法。 app.use
的第一个参数是一个回调函数,与http.createServer
类似,不过,回调参数req
和res
被封装到了一个参数ctx
中。- 在原生
http
中将内容响应到客户端是使用res.end
,在koa2
中却是ctx.body
,咋回事呢?
现在我们对两者先有直观上的区别感受,接下来大家逐步跟着我的思路阅读,会对这种区别产生的原因理解地透透的,大家记住一句话,本质上 koa2
就是对 http
的扩展,使其有更多的常用功能和更好用而已,我们学习的就是 koa2
的封装思路。
koa2 源码文件的结构
koa2
的源码文件就只有 4 个,很简洁明了。
|- lib
|-- application.js
|-- context.js
|-- request.js
|-- response.js
各个文件的名字很直接展示了其主要作用:
application.js
为导出Koa
类的主入口文件,内部实现将其它模块串联起来的逻辑。context.js
主要作用是代理request.js
和response.js
中的方法。request.js
封装了http
的请求,扩展了一些功能。response.js
封装了http
的响应,扩展了一些功能。
根据 koa2
的简单 demo 和上面的文件结构,我们就可以顺着思路一步步实现基本的 koa2
了,这里的实现不是完全照搬 koa2
的源码,而是利用其实现思路,写一个相对来说更容易理解的版本。
封装 http 服务
新建 application.js
,直接创建 Application
类,并实现对 node 中 http
的封装:
const http = require('http')
class Application {
constructor() {
this.fn = null
}
use(fn) {
this.fn = fn
}
handleRequestCallback() {
return (req, res) => {
this.fn(req, res)
}
}
listen(...args) {
const server = http.createServer(this.handleRequestCallback())
server.listen(...args)
}
}
module.exports = Application
上面的代码就是对 http
的一个简单封装,利用 app.use
注册回调函数,通过 app.listen
监听 server
并传入参数。
值得注意的是 handleRequestCallback
返回的是一个箭头函数,这里是为了让 this
指向的是实例,毕竟 fn
就是挂在实例上的。如果这里不这样写,而是直接执行 this.fn(req, res)
,其中 this
将会指向我们创建的 server
,显然是不正确的。
此时在同目录新建一个 test.js
,写入以下代码:
const Koa = require('./koa')
const app = new Koa()
app.use((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end('<h1>Hello World</h1>')
})
app.listen(8888, () => {
console.log('server is running on http://localhost:8888')
})
控制台使用 node 命令执行该文件,随后打开 http://localhost:8888
,会发现 Hello World 被正确地返回。
但是我们使用 koa2
时,app.use
中回调函数的第一个参数是 ctx
,而不是现在的 node 原生的 request
和 response
对象,所以我们要将其封装成如下这样:
app.use(ctx => {
ctx.response.res.writeHead(200, { 'Content-Type': 'text/html' })
ctx.body = '<h1>Hello World</h1>'
})
如果你将代码替换成上面这种写法,数据是不会被正确返回的,我们后续会再继续完善!
接下来就需要我们编写 context.js
、request.js
和 response.js
中的内容了,并将它们在 application.js
中串联起来。
创建上下文 context
使用 koa
时,在上下文中能访问到封装的请求对象 ctx.request
,原生模块的请求对象 ctx.req
,封装的响应对象 ctx.response
,原生模块的响应对象 ctx.res
,以及原生的请求、响应对象都被附加到了封装的请求、响应对象中,即 ctx.request.req
和 ctx.response.res
。
先在各个文件写入最简单的代码,先实现这些对象存储结构。
context.js
:
const context = {}
module.exports = context
request.js
:
const request = {}
module.exports = request
response.js
:
const response = {}
module.exports = response
然后在 application.js
中导入这些模块:
const context = require('./context')
const request = require('./request')
const response = require('./response')
接下来我们思考以下几个问题:
- 如何做到避免用户直接操作我们的
context
、request
和response
对象? - 每新建一个应用,即
new Koa
,如何保持各个应用中对于这 3 个模块的独立性? - 每次通过
app.use
注册回调函数时,这些回调函数内的上下文都是独立的?
koa
通过在构造函数内分别创建三个对应的对象,并将原型分别指向 context
、request
和 response
,解决前两个问题:
class Application {
constructor() {
this.context = Object.create(context)
this.request = Object.create(request)
this.response = Object.create(response)
this.fn = null
}
}
因为 http
请求无状态,使用 app.use
注册回调函数时,其上下文也要保持独立,所以需要再进行一次类似上面的原型操作:
class Application {
createContext(req, res) {
const context = Object.create(this.context)
const request = Object.create(this.request)
const response = Object.create(this.response)
return context
}
handleRequestCallback() {
return (req, res) => {
const ctx = this.createContext(req, res)
this.fn(ctx)
}
}
再然后就是将各个原生对象挂在我们自己封装的对象,以下是这一步骤 application.js
完整代码:
const http = require('http')
const context = require('./context')
const request = require('./request')
const response = require('./response')
class Application {
constructor() {
this.context = Object.create(context)
this.request = Object.create(request)
this.response = Object.create(response)
this.fn = null
}
use(fn) {
this.fn = fn
}
createContext(req, res) {
const context = Object.create(this.context)
const request = Object.create(this.request)
const response = Object.create(this.response)
context.req = req // 原生的
context.request = request // 自己封装的
context.request.req = req // 原生的
context.res = res // 原生的
context.response = response // 自己封装的
context.response.res = res // 原生的
return context
}
handleRequestCallback() {
return (req, res) => {
const ctx = this.createContext(req, res)
this.fn(ctx)
}
}
listen(...args) {
const server = http.createServer(this.handleRequestCallback())
server.listen(...args)
}
}
module.exports = Application
自定义 request 和 response 扩展
我们上面说到过,ctx
上挂载的 request
和 response
是自定义的请求和响应对象的扩展,接下来举个例子说明这两个自定义对象的目的。
第一种情况,代理原生请求或响应对象本来就有的能力:
const request = {
get url() {
return this.req.url
},
set url(val) {
this.req.url = val
},
}
module.exports = request
通过 getter/setter
函数对原生的 url
进行代理,可方便进行赋值和取值操作。
我们在 test.js
中通过以下写法来获取 url
,这样写:
app.use(ctx => {
ctx.response.res.writeHead(200, { 'Content-Type': 'text/html' })
console.log(ctx.request.url)
ctx.body = '<h1>Hello World</h1>'
})
控制台使用 node 命令重新执行该文件,随后打开 http://localhost:8888/a/b
,在控制台会发现打印了 /a/b
,说明我们自定义的 request
扩展对象代理 url
成功。
现在考虑下,为什么在执行 this.req.url
时能够正确访问原生 req
对象?因为在 context
中我们将原生 req
对象挂载到了自定义扩展的 request
对象上了,即以下这行代码:
context.request.req = req
于是访问 this.req.url
时,实际上这里的 this
就是 ctx.request
。
第二种情况,新增原生对象上没有的能力:
const response = {
_body: undefined,
get body() {
return this._body
},
set body(val) {
this._body = val
this.res.statusCode = 200
}
}
module.exports = response
到目前为止,我们的服务并没有返回任何东西,如果你开着浏览器访问,会一直转圈圈,因为我们实际上并没有返回任何东西,原生的 http
服务通过 res.end('xxx')
来返回数据并关闭连接。而现在我们是通过ctx.body
来模拟这个操作。
有了 response
模块这部分代码,当我们执行 ctx.body = '<h1>Hello World</h1>'
时,相当于给 _body
赋值存了起来。
诶,不对,给 ctx.body
赋值,关我 response
什么事?还记得一开始我们说过,context.js
主要作用是代理 request.js
和 response.js
中的方法。
比如 ctx.body
其实就相当于 ctx.response.body
,回到 context
模块,以下代码就是实现这种代理的方式:
const context = {}
function defineGetter(target, key) {
context.__defineGetter__(key, function() {
return this[target][key]
})
}
function defineSetter(target, key) {
context.__defineSetter__(key, function(value) {
return this[target][key] = value
})
}
defineGetter('request', 'url')
defineSetter('request', 'url')
defineGetter('response', 'body')
defineSetter('response', 'body')
module.exports = context
实际上 __defineGetter__
和 __defineSetter
一直都是非标准方法,但是其兼容性却特别好。koa2
中使用的 delegates
模块也一直是用的这两个非标准方法。或许大家可以考虑下用 Object.defineProperty
来实现。
回到 application.js
,在 handleRequestCallback
函数中添加以下代码:
class Application {
handleRequestCallback() {
return (req, res) => {
const ctx = this.createContext(req, res)
res.statusCode = 404
this.fn(ctx)
const content = ctx.body
if (content) {
res.end(content)
} else {
res.end('Not Found')
}
}
}
}
module.exports = Application
重启服务,再看看浏览器,即可看到正确返回了 Hello World
。
中间件机制 - 洋葱模型
目前在我们的测试文件 test.js
中只使用了一次 app.use
,注册了一个回调函数,然而在实际应用时,我们必然会使用多次的。
现在我们使用 koa2
做个测试,新建一个 test-koa.js
写入以下代码:
// test-koa.js
const Koa = require('koa')
const app = new Koa()
app.use((ctx, next) => {
console.log(1)
next()
console.log(2)
})
app.use((ctx, next) => {
console.log(3)
next()
console.log(4)
})
app.use((ctx, next) => {
console.log(5)
next()
console.log(6)
})
app.listen(7777, () => {
console.log('server is running on http://localhost:7777')
})
控制台执行 node test-koa.js
后,打开 http://localhost:7777
,再回到控制台会看到打印的顺序如下:
1 3 5 6 4 2
在 koa2
中注册函数的第一个参数 ctx
大家很熟悉了,第二个参数 next
代表下一个要被执行的注册函数。其实上面的代码可以这样来理解:
app.use((ctx, next) => {
console.log(1)
(ctx, next) => {
console.log(3)
(ctx, next) => {
console.log(5)
// empty
console.log(6)
}()
console.log(4)
}()
console.log(2)
})
每一次执行 next()
函数就相当于将下一个注册函数执行,也就是说执行下一个中间件函数,这就是所谓的洋葱模型,用一张图来解释:

上面的代码中全是同步逻辑,如果我们的中间件函数里有异步逻辑,也就是我们使用 koa2
时经常用到的 async/await
,我们考虑下面一段代码会在浏览器显示什么,以及控制台的打印顺序:
const Koa = require('koa')
const app = new Koa()
const sleep = (time) => {
return new Promise((resolve) => {
setTimeout(() => {
console.log('sleeping')
resolve()
}, time)
})
}
app.use((ctx, next) => {
console.log(1)
ctx.body = '1'
next()
console.log(2)
ctx.body = '2'
})
app.use(async (ctx, next) => {
console.log(3)
ctx.body = '3'
await sleep(2000)
next()
console.log(4)
ctx.body = '4'
})
app.use((ctx, next) => {
console.log(5)
ctx.body = '5'
next()
console.log(6)
ctx.body = '6'
})
app.listen(7777, () => {
console.log('server is running on http://localhost:7777')
})
结果是浏览器访问 http://localhost:7777
显示的是 2
,控制台打印的是顺序是:
1 3 2 (延迟 2000 ms 后) sleeping 5 6 4
在 koa2
中代表所有中间件函数执行完毕的标志是最外圈的“洋葱皮”被“刀”都切到了,也就是说最外层的代码都被执行完毕了,知道这个我们再来分析上面代码的输出结果。
在第一个(也就是最外层)中间件函数中,执行到的 next
中有异步逻辑 但我们没有等待 next
执行完毕,只是进入了第二个中间件函数中开始执行代码,所以直接打印出了 1 3 2
,至此其实已经判定为中间件函数执行完毕了,开始响应逻辑,所以在浏览器页面上看到的是 2
。
但后续的代码还在执行,在第二个中间件函数中使用了 await
,于是 2000 ms
后继续走后续逻辑,所以在控制台的打印顺序如上。
所以咱们在 koa2
的使用中,一定要在 next()
前加上 await
,不然大概率结果会不如预期,大多数中间件都是有异步逻辑的。
实现中间件机制
在 koa2
中是如何实现上述的中间件机制的呢?通过上述代码和结果演示,能够看出每一个中间件函数的执行顺序是和 app.use
的使用顺序一致的,这不难想到也许 koa2
中使用了一个数组来保存每一个中间件函数,并依次执行。
回到我们的 application.js
模块,接下来就是重头戏了,也是 koa2
中最核心最精华的部分,我将演示如何一步步实现中间件机制。
初始化一个 middlewares 数组
在构造函数 constructor
中把我们原来定义的 this.fn
删掉,定义一个数组 this.middlewares = []
,其目的是保存所有 app.use
注册的中间件函数。
class Application {
constructor() {
this.context = Object.create(context)
this.request = Object.create(request)
this.response = Object.create(response)
this.middlewares = []
}
}
添加中间件函数
在 use
函数内部,删除 this.fn = fn
,而是将 fn
添加至 this.middlewares
数组中。
class Application {
use(fn) {
this.middlewares.push(fn)
}
}
新建组合函数 compose
之前只注册一个函数时,我们直接执行 this.fn(ctx)
,但现在我们需要一个新的组合函数 compose
来执行所有注册的有异步逻辑的中间件函数,并且返回一个 Promise
。
class Application {
compose(ctx) {
// 执行所有中间件函数并返回一个 Promise
}
handleRequestCallback() {
return (req, res) => {
const ctx = this.createContext(req, res)
res.statusCode = 404
this.compose(ctx)
.then(() => {
const content = ctx.body
if (content) {
res.end(content)
} else {
res.end('Not Found')
}
})
}
}
}
⚠️ 在 koa2
源码中使用了 koa-compose
模块,该模块导出的 compose
为一个函数,该函数执行后返回一个新的内联函数,我们上述的实现相当于直接把这个内联函数抽出来执行了,相信大家看源码的时候会理解的。
实现 compose 逻辑
我们在 compose
内部的代码主要实现以下逻辑:
- 没有注册中间件函数时,直接返回
Promise.resolve()
。 - 从第一个中间件函数执行开始,遇到执行
next
就意味着要执行第二个中间件函数,相当于递归调用。 - 所有返回结果都要包装成
Promise
。 - 一个中间件函数不能被调用两次,否则抛错。
于是我们根据上面思路,就可以写出以下代码:
class Application {
compose(ctx) {
let index = -1
const dispatch = (i) => {
// 一个中间件函数不能被调用两次,否则抛错
if (i <= index) {
return Promise.reject('[Error] next() called multiples times')
}
index = i
// 没有注册中间件函数时,直接返回 Promise.resolve()
if (this.middlewares.length === i) {
return Promise.resolve()
}
const fn = this.middlewares[i]
try {
// 遇到执行 next 就意味着要执行第二个中间件函数,相当于递归调用
// 这里 () => dispatch(i + 1) 就是 next
return Promise.resolve(fn(ctx, () => dispatch(i + 1)))
} catch (err) {
return Promise.reject(err)
}
}
// 从第一个中间件函数执行开始
return dispatch(0)
}
}
大家思考一下,为什么我在一个中间件函数里执行两次或以上 next
,就会导致报错?原因在于我们执行无论多少次,i + 1
的 i
都是同一个值,但是第一次执行 dispatch(i + 1)
时,index
已经赋值为 i + 1
了,这样第二次执行时,i + 1 <= index
就会成立,反之就代表只执行了一次。
就是那么简单的几行代码就实现了 koa2
的核心功能,不过我们只是阅读别人的代码时候觉得简单,真的要自己想出来估计也是需要不少脑细胞的。
接下来你可以拿刚才写好的 test-koa
来测试这段逻辑了,别忘了引入的是我们自己的 koa
哦~
错误捕获与处理
一个优秀的框架或 SDK,良好的错误或异常捕获是很有必要的,不至于因为代码执行错误导致后续逻辑中断,这可以给开发者更多的信息,也能有更多选择,比如降级逻辑。
在 koa2
中某个中间件函数发生错误时,可以通过 app.on('error', () => {})
拿到错误信息,这需要 node 的原生模块 events 默认导出的 EventEmitter
支持,如果有用过 Vue 的同学,对这个一定很熟悉了。不熟悉的同学可以搜索下发布订阅模式~
回到 application.js
,我们引入并继承这个类,构造函数中要加上 super()
:
const EventEmitter = require('events')
class Application extends EventEmitter {
constructor() {
super()
this.context = Object.create(context)
this.request = Object.create(request)
this.response = Object.create(response)
this.middlewares = []
}
}
在执行 compose
方法的地方,我们已经写了 then
,把 catch
也补上:
class Application {
handleRequestCallback() {
return (req, res) => {
// ...
this.on('error', this.onerror)
const onerror = err => ctx.onerror(err)
this.compose(ctx)
.then(() => {
// ...
})
.catch(onerror)
}
}
onerror(err) {
const msg = err.stack || err.toString()
console.error('[Inner Error]', `\n${msg.replace(/^/gm, ' ')}\n`)
}
}
在上面代码我们总共做了两件事:
- 添加
this.on('error', this.onerror)
并创建了一个方法onerror
,该方法用于处理捕获到的错误,并处理后在控制台输出(我这里为了演示简便,只是很简单处理)。 - 添加
const onerror = err => ctx.onerror(err)
,并在catch
中将err
传递给onerror
。
上面的两个 onerror
作用是完全不一样的,第一个是用于 koa2
内部错误打印,如果用了社区的 koa-logger
还能用于收集错误日志。第二个是用于返回给用户的原始错误。
但是给用户的错误是通过 ctx.onerror
去做的,所以我们要来到 context
模块,为其添加一个 onerror
方法:
const context = {
onerror(err) {
if (null == err) return
this.app.emit('error', err, this)
},
}
同样地,我为了演示,在该方法只是将错误抛出去,可以看到是通过 this.app.emit
进行抛出的,那么问题来了,context
上面怎么会有一个 app
属性,并且它上面还有继承了 EventEmitter
才有的方法 emit
,不难想到,其实我们只需要在 application
模块中 createContext
时,将 this
赋值给 ctx.app
就可以了:
class Application {
createContext(req, res) {
const context = Object.create(this.context)
const request = Object.create(this.request)
const response = Object.create(this.response)
context.app = this
// ...
return context
}
}
接下来新建一个 test-koa-error.js
文件,输入以下故意有错误的代码,执行后看看控制台是不是正确打印了错误:
const Koa = require('./koa')
const app = new Koa()
app.use((ctx) => {
ctx.response.res.writeHead(200, { 'Content-Type': 'text/html' })
str += '<h1>Hello World</h1>' // 变量未声明,应该报错
ctx.body = str
});
app.on('error', (err, ctx) => {
console.error('[Outer Error]', err)
});
app.listen(8888, () => {
console.log('server is running on http://localhost:8888')
})
启动服务后,打开 http://localhost:8888
再回到控制台,出现[Outer Error]
和 [Inner Error]
,说明我们的错误捕获成功了!

当然,这两个提示只是我为了区分才故意这样写的哦~
参考代码
以上就是 koa2
框架实现的基本原理,因为是文章的展现形式,可能做不到每行代码都解释清清楚楚,也不可能每一行代码都演示如何去写,我已经尽量将整段代码切割成一块一块,如果大家还是有不理解的地方,可以参考下本文的所有演示代码:
结语
写这篇文章的目的一方面在于强制驱动自己去学习了解 koa2
的源码,以便于在使用 koa2
进行开发时遇到问题能快速定位问题,也能学习到其封装思路,之后自己写代码时能借鉴其思想;另一方面希望能帮助和我有一样想法的同学建立起源码阅读思路。总而言之,我认为源码的阅读不是为了读而读,而是阅读理解它之后或许能让我们在实践时有指导思路。
另外,如果对大家有所帮助,给我的 blog 赏个 star🌟 哦~
转载自:https://juejin.cn/post/7186442425513017401