第一次从 Express 切到 Koa 的时候,我卡在了一个很蠢的地方:中间件里写了 next(),后面的代码却在响应发出去之后才执行,日志里的耗时统计全是 0 毫秒。排查了一下午才反应过来,Koa 的 next() 返回的是 Promise,得 await 它,否则你拿到的只是「我把执行权交出去了」这一瞬间,而不是「下游全部跑完了」。
这篇把 Koa2 从起服务到中间件、错误处理、表单文件上传整条线过一遍。重点放在洋葱模型上,因为那是 Koa 和 Express 真正分道扬镳的地方,也是面试里最爱问的一块。每段代码前后都会说清楚它在解决什么问题,而不是只贴一个能跑的片段。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 用三行代码起一个 Koa 服务,以及脚手架
koa-generator怎么用 Context到底包了什么,ctx.request和ctx.response的分工- 返回类型怎么协商,网页模板怎么用流的方式吐出去
- 原生路由的写法,以及
koa-route/koa-static/ 重定向 - 洋葱模型的执行顺序,为什么
next()前面是「进」后面是「出」 - 异步中间件必须
async的原因,漏了await会发生什么 - 用最外层中间件做统一错误兜底,配合
app.on('error')收底 - Cookie、表单、文件上传这些 Web App 的常规能力
- 这些 2018 年的写法在今天该换成什么
# 一、三行代码起一个 HTTP 服务
Koa 是 Express 原班团队做的第二代 Web 框架,特点是核心极小。Express 把路由、静态资源、模板全塞进了框架本体,Koa 把这些全砍了,只留下一个中间件引擎和一个上下文对象,剩下的自己按需装。
起服务确实只要三行。
const Koa = require('koa');
const app = new Koa();
app.listen(3000);
跑起来之后打开浏览器访问 http://127.0.0.1:3000,页面上是 Not Found。这不是报错,是因为我们一个中间件都没注册,Koa 找不到任何人给这次请求写响应体,就按默认逻辑返回了 404。
这个默认行为其实挺说明问题的:Koa 本身什么都不做,所有能力都来自你注册进去的中间件。
真要开一个新项目,手写目录结构挺费劲,官方有脚手架。
npm install koa-generator -g
装完之后创建项目,koa2 -e hello-koa2,其中 -e 表示用 ejs 模板语法,不写这个参数默认是 jade(也就是后来改名的 pug)。

生成出来的骨架里已经把 koa-router、koa-static、模板引擎、日志中间件都装好接上了,npm start 之后同样访问 http://127.0.0.1:3000 就能看到欢迎页。拿它当学习用的沙盒很合适,真做项目的话我一般还是自己从空目录搭,因为脚手架带的依赖版本往往落后好几个大版本。
# 二、Context 对象,一次对话的全部上下文
Koa 给每次请求都造一个 Context 对象,习惯上叫 ctx。它把这一次 HTTP 请求和这一次 HTTP 响应打包在一起,你改它,用户就能看到变化。
最常用的就是 ctx.response.body,它是发送给用户的内容。
const Koa = require("koa");
const app = new Koa();
app.use(ctx => { //处理请求的中间件
ctx.response.body = "hello world";
}).listen(3000);
app.use 用来注册中间件,传进去的这个函数就是中间件本体。这里它做的事只有一件,给 ctx.response.body 赋值。
ctx.response 代表 HTTP Response,ctx.request 代表 HTTP Request,这两个是 Koa 自己封装的对象,不是 Node 原生的那两个。原生的挂在 ctx.req 和 ctx.res 上,需要直接操作底层流的时候才用得着。这个命名区别很多人第一次都会搞混,短的是原生,长的是 Koa 封装的。
另外 Koa 在 ctx 上做了一堆代理,ctx.body 等价于 ctx.response.body,ctx.status 等价于 ctx.response.status,ctx.path 等价于 ctx.request.path。写业务的时候用短的,读别人代码时知道它们是一回事就行。
# 2.1 返回类型的协商
Koa 默认的返回类型是 text/plain。想返回别的,可以先用 ctx.request.accepts 看看客户端能接受什么(依据是请求头里的 Accept 字段),再用 ctx.response.type 指定。
const Koa = require("koa");
const app = new Koa();
app.use(ctx => {
if (ctx.request.accepts('xml')) {
ctx.response.type = 'xml';
ctx.response.body = '<data>Hello World</data>';
} else if (ctx.request.accepts('json')) {
ctx.response.type = 'json';
ctx.response.body = { data: 'Hello World' };
} else if (ctx.request.accepts('html')) {
ctx.response.type = 'html';
ctx.response.body = '<p>Hello World</p>';
} else {
ctx.response.type = 'text';
ctx.response.body = 'Hello World';
}
}).listen(3000);
这里有个坑要注意:accepts 的判断顺序是你写的 if 顺序,不是客户端偏好的权重顺序。浏览器发过来的 Accept 一般长这样 text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8,因为末尾有 */*,上面这段代码里第一个分支 xml 就会命中,浏览器直接收到一坨 XML。真要按权重挑,得写成 ctx.accepts('html', 'json', 'xml'),把候选一次性传进去让 Koa 自己算。
顺带说一句,如果 body 赋的是一个普通对象,Koa 会自动把 type 设成 application/json 并序列化,上面那个 ctx.response.type = 'json' 其实可以省。
# 2.2 网页模板用流的方式返回
实际项目里返回给用户的页面通常是模板文件。可以让 Koa 读文件再吐出去。
const Koa = require("koa");
const app = new Koa();
const fs = require('fs');
app.use(ctx => {
ctx.response.type = 'html';
ctx.response.body = fs.createReadStream('./demos/template.html');
}).listen(3000);
注意这里 body 赋的是一个可读流,不是字符串。Koa 支持给 body 赋字符串、Buffer、Stream、对象和 null 这几种类型,赋流的好处是不用把整个文件读进内存,大文件下载场景基本都这么写。
代价是流的错误处理要额外操心。文件不存在时,createReadStream 不会立刻抛错,错误是在流开始读的时候异步冒出来的,这时候响应头可能已经发出去了,你没法再改状态码。生产环境更稳的做法是先 fs.promises.access 判断一下,或者干脆交给 koa-static 这类成熟中间件。
# 三、路由怎么写
网站总不止一个页面。最土的办法是自己判断 ctx.request.path。
const Koa = require("koa");
const app = new Koa();
const fs = require('fs');
app.use(ctx => {
if (ctx.request.path !== '/') {
ctx.response.type = 'html';
ctx.response.body = '<a href="/">Index Page1</a>';
} else {
ctx.response.body = 'Hello World';
}
}).listen(3000);
这段能跑,但路由一多就变成一长串 if...else,而且没法处理 /user/:id 这种带参数的路径,也区分不了 GET 和 POST。
# 3.1 koa-route 模块
把路由这件事交给专门的模块。
const Koa = require("koa");
const app = new Koa();
const fs = require('fs');
const route = require('koa-route');
const main = route.get("/", ctx => {
ctx.response.type = 'html';
ctx.response.body = '<a href="/">Index Page1</a>';
})
const about = route.get("/about", ctx => {
ctx.response.body = 'Hello World';
})
app.use(main);
app.use(about);
app.listen(3000);
route.get(path, fn) 返回的还是一个普通中间件,路径不匹配时它就调 next() 放行,匹配时才执行你的处理函数。理解了这一点你就明白,Koa 里根本没有「路由系统」这个概念,路由只是一种特殊的中间件。
koa-route 现在基本没人维护了,实际项目里用的是 @koa/router,它支持路由前缀、嵌套路由、router.param 这些能力,写法是先 new Router() 再 app.use(router.routes())。功能多了不少,但底层原理和上面这段是一样的。
# 3.2 静态资源
图片、字体、样式表一个个写路由不现实,koa-static 把这部分包了。
// 访问 http://localhost:3000/test.json
const Koa = require("koa");
const app = new Koa();
const path = require('path');
const serve = require('koa-static');
const main = serve(path.join(__dirname, "../public/"));
app.use(main);
app.listen(3000);
它做的事是拿请求路径去指定目录下找文件,找到就返回并带上 Content-Type 和缓存头,找不到就 next() 交给后面的中间件。所以静态资源中间件通常放在整个中间件链的靠前位置,让静态文件请求尽早短路,不用往下走一堆业务逻辑。