出层半程从三个赋值开始:ctx.status、ctx.set、ctx.body。它们语法简单到不值得讲,但组合语义与生效时机有一堆暗规则——赋值顺序影响类型推断、头有最后修改窗口、204 不许带 body。本节把这些暗规则摊开,让你写出的每个响应都出自明确意图而不是碰运气。
ctx.body 是出层机制的核心枢纽:给什么类型,内核就按什么方式序列化。规则要背下来:
ctx.body = { id: 1 }; // 对象或数组 → JSON 序列化,content-type: application/json ctx.body = '<h1>ok</h1>'; // 字符串 → 原样输出,content-type: text/html(含标签时) ctx.body = Buffer.from([1, 2, 3]); // Buffer → 二进制流,application/octet-stream ctx.body = fs.createReadStream(p); // 可读流 → 管道接管(4.4 节的下载写法) ctx.body = null; // 置空 + 204/304 场景,配合 status 使用
两条联动规则藏在里面。其一,给 body 赋值会把 status 从 404 拉回 200——第 1.3 节讲过"无人写 body 则 404",反过来写了 body 就不再是 404,除非你显式再赋 status。其二,显式 status 要写在 body 之后或独立声明,否则可能被联动覆盖。最稳的顺序是先想清语义,两个都显式写:
ctx.status = 201; ctx.set('Location', `/orders/${id}`); ctx.body = order; // 显式声明的 201 不会被默认联动拉回 200
不用背全部状态码,接口开发真正高频的是这几组,按"谁的问题"分:2xx 表示成功且分形态——200 常规成功带 body、201 已创建(配合 Location)、204 成功无内容(删除、无需返回体的更新)。4xx 表示客户端的问题——400 参数不合法、401 未认证(凭证缺失或失效)、403 已认证但无权、404 资源不存在、405 方法不支持、409 状态冲突、413 请求体过大、422 语义校验不过、429 触发限流。5xx 表示服务端的问题——500 未捕获异常、502/504 网关与上游超时。
一个常被问的争议:登录失败该回 401 还是 403?我的口径:没登录(或凭证失效)401,登录了但没权限 403——401 的语义里含"请重新提供凭证",与登录场景天然吻合;把"密码错误"也归 401 没问题,但把"没权限看这个资源"归 401 会误导客户端反复重登。

响应头与 body 一样走"声明制",真正的发送发生在内核出层阶段。这意味着出层路径上的每一层都能改头——这正是第 3.2 节把压缩层、安全头层放外侧的原因。反过来,一旦内核开始写 socket,一切修改都太晚了:
// 能改:出层阶段统一补追踪头 app.use(async (ctx, next) => { await next(); ctx.set('X-Request-Id', ctx.state.reqId); ctx.set('X-Response-Time', `${Date.now() - ctx.state.start}ms`); }); // 不能改:响应已经发出后(如 body 是流、且流已开始写入)再 set,直接抛错
实用推论:想在响应里带任何动态信息(追踪号、限流余量、灰度标记),统一起 X- 前缀在出层补,别指望处理器里记得每个接口都 set 一遍——约定胜过自觉。
把联调中最常撞见的状态码误用摆成对照表,code review 时可以直接引用:
| 误用写法 | 问题 | 正确口径 |
|---|---|---|
| 参数错误回 500 | 客户端的锅记到服务端头上,告警噪音暴增 | 400 或 422 |
| 未登录回 403 | 客户端误判为"无权限",触发不该有的重登引导 | 401 |
| 资源不存在回 400 | 探测者借此区分"格式错"与"没这个资源" | 404 |
| 一切失败回 200 加 code 字段 | HTTP 层监控、网关重试、浏览器缓存全部失灵 | 语义状态码,body 只做补充 |
| 删除成功回 200 带整个对象 | 传输浪费,语义含糊 | 204 无内容 |
其中第四行值得多说一句:把所有响应都包成 200 的"伪 REST"风格在早期移动端流行过,理由是"方便统一解析"。代价是 HTTP 协议层的一切机制——网关按状态码熔断、监控系统按状态码算错误率、CDN 按状态码决定缓存——全部需要额外改造才能识别你的业务语义。协议提供了语义,就用协议的语义;body 里的 code 字段负责业务细分,两层各司其职。
两类响应自带固定套路。重定向用 ctx.redirect,默认 302;语义明确"永久搬家"时显式给 308(保留方法与体,比老的 301 更适合 API):
router.get('/v1/users/:id', async (ctx) => { ctx.status = 308; // 永久迁移,保留方法 ctx.redirect(`/v2/users/${ctx.params.id}`); });
附件下载在 4.4 节已完整实现,这里补一个内容协商之外的便利方法——ctx.attachment(filename) 等价于手工设置 Content-Disposition,还会处理 UTF-8 文件名的兼容编码,中文文件名场景直接用它。
⚠️ 常见坑:给 204 或 304 响应赋 body。规范上这两个状态码不允许携带主体,Koa 会忽略甚至因头部已发送而抛错。需要"成功但无内容"时,
ctx.status = 204即可,不要画蛇添足。
状态码与头的骨架定了,下一节填充血肉:同一资源怎么按客户端口味输出不同格式,以及错误响应的统一形态怎么设计。