首页
/ Koa Response API 深度解析:从状态码、Body 序列化到头控与重定向

Koa Response API 深度解析:从状态码、Body 序列化到头控与重定向

2026-09-05 20:16:52作者:仰钰奇

Koa 的 Response 对象是对 Node.js 原生 res 对象的封装层,它让“写 HTTP 响应”这件日常操作从手动拼头、手动序列化变成了声明式的属性赋值。本文基于 Koa 仓库(当前版本 3.2.1,engines 要求 Node >= 18)中的 Response API 文档 展开,逐条讲清每个 API 的语义与默认行为,并结合 lib/response.jslib/application.js 的源码,剖析状态码自动协商、ctx.body 类型分发、重定向安全处理等底层机制,帮助你在写中间件和代理逻辑时做出准确的响应控制决策。

Response 对象在哪里、ctx 上的快捷方式从哪来

官方文档的开篇定义:Response 是对 Node 原生 response 对象的抽象,为日常 HTTP 服务开发提供额外能力。在 Koa 中,每个请求上下文 ctx 同时挂有 ctx.requestctx.response,而你在代码里写的 ctx.statusctx.bodyctx.set(...) 等,其实都是对这两个对象的委托(delegation)

从源码结构看,lib/context.js 通过 delegates 包在 context 原型上批量注册了这些快捷方式:

delegate(proto, 'response')
  .method('attachment')
  .method('redirect')
  .method('remove')
  .method('vary')
  .method('has')
  .method('set')
  .method('append')
  .method('flushHeaders')
  .method('back')
  .access('status')
  .access('message')
  .access('body')
  .access('length')
  .access('type')
  .access('lastModified')
  .access('etag')
  .getter('headerSent')
  .getter('writable')

这意味着 ctx.redirect('/login')ctx.response.redirect('/login') 完全等价;ctx.body 的 getter/setter 则直接转发到 ctx.response._body。本文后续以 response 为叙述主体,实际使用中写在 ctx 上即可。

response.status 与 response.message:为什么默认是 404

response.status 读取 this.res.statusCodelib/response.js)。文档特别强调了一个与 Node 的差异:Node 的 res.statusCode 默认是 200,而 Koa 默认是 404

这个 404 的默认值来自 lib/application.jshandleRequest

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

每个请求进入中间件链之前,状态码先被强制置为 404——即“若没有任何中间件明确设置状态或 body,客户端最终收到 404”。这正是 Koa 的设计哲学:响应必须由应用主动声明。

设置状态码时,setter 有严格校验(lib/response.js):

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.message[code]
  if (this.body && statuses.empty[code]) this.body = null
}

几个值得注意的细节:

  • 状态码必须是 100–999 的整数,否则直接抛错。statuses 包维护了完整的状态码/消息映射表,文档列出的 1xx–5xx 全量清单(从 100 "continue"511 "network authentication required")正是来自它;
  • _explicitStatus 是内部标志,记录“状态码是用户显式设置的”——它决定了后文 ctx.body 赋值时是否要自动改状态码;
  • 若设置的状态属于 statuses.empty(如 204、304)而当前已有 body,Koa 会把 body 强制置为 null,因为空响应不允许携带内容;
  • HTTP/1.x 请求下会顺带把 statusMessage 设为标准文案。

response.message 默认随 response.status 联动:getter 返回 this.res.statusMessage || statuses.message[this.status]lib/response.js),你也可以通过 response.message = 'msg' 显式覆盖。

因此,若想在不发送 body 的情况下返回非 404 状态,必须显式赋值:

ctx.response.status = 200;
// 或任何其它状态
ctx.response.status = 204;

response.body:Koa 响应体系的核心

response.body 是 Koa 自动序列化机制的入口。文档给出的支持类型是:

  • string:直接写入
  • Buffer:直接写入
  • Stream:管道写入
  • Object / Array:JSON 序列化
  • null / undefined:无内容响应

而当前仓库的实现(lib/response.js)实际上还支持 ReadableStreamBlobResponse 三种 WhatWG 类型,这是文档未逐一列出但源码已确认的能力。setter 的完整逻辑如下(节选关键分支):

set body (val) {
  const original = this._body
  this._body = val

  // no content
  if (val == null) {
    if (!statuses.empty[this.status]) {
      if (this.type === 'application/json') {
        this._body = 'null'   // JSON 上下文的 null 会序列化为字符串 "null"
        return
      }
      this.status = 204
    }
    if (val === null) this._explicitNullBody = true
    this.remove('Content-Type')
    this.remove('Content-Length')
    this.remove('Transfer-Encoding')
    cleanupPreviousStream()
    return
  }

  // set the status
  if (!this._explicitStatus) this.status = 200

  // 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) ? 'html' : 'text'
    this.length = Buffer.byteLength(val)
    return
  }

  // buffer
  if (Buffer.isBuffer(val)) {
    if (setType) this.type = 'bin'
    this.length = val.length
    return
  }

  // stream
  if (isStream(val)) {
    onFinish(this.res, destroy.bind(null, val))
    // ...
    if (setType) this.type = 'bin'
    return
  }

  // ReadableStream / Blob / Response 分支从略
  // json
  this.remove('Content-Length')
  if (!this.type || !/\bjson\b/i.test(this.type)) this.type = 'json'
}

对照文档,各类型的具体行为及源码级补充如下。

String

文档说明:Content-Type 默认为 text/htmltext/plain(charset 均为 utf-8),并设置 Content-Length。源码揭示了一个文档未细说的规则:以空白字符后紧跟 < 开头(即 ^\s*<)的字符串被识别为 HTMLtype 设为 'html',否则为 'text'lib/response.js)。测试 tests/response/body.test.js 中有对应的 when an html string is givenwhen an xml string is given 用例印证了这一点。

Buffer

文档说明:Content-Type 默认为 application/octet-stream,并设置 Content-Length。源码中 setType 时执行 this.type = 'bin'lib/response.js)。

Stream

文档说明:Content-Type 默认为 application/octet-stream,且每当 Stream 被设为 body 时,.onerror 会自动监听 error 事件;请求关闭(包括提前关闭)时 Stream 会被 destroy。源码实现为 onFinish(this.res, destroy.bind(null, val))lib/response.js)。

若你不想要自动 error 转发与自动 destroy(例如代理场景下直接透传上游 HTTP 流时,destroy 会切断底层连接),文档给出的方案是:不要把上游流直接作为 body,而是手动接管 error 后再 pipe:

const PassThrough = require('stream').PassThrough;

app.use(async ctx => {
  ctx.body = someHTTPStream.on('error', (err) => ctx.onerror(err)).pipe(PassThrough());
});

此外源码还处理了流替换的边角情况:当 body 从一个 stream 换成非 stream 值时会 destroy(original),换成新 stream 时则不销毁旧流(以支持 koa-compress 这类包装流中间件),这些行为在 tests/response/body.test.js 中都有“should NOT cleanup original stream when replaced by new stream”等专门用例覆盖。

Object / Array

文档说明:Content-Type 默认为 application/json,涵盖普通对象 { foo: 'bar' } 与数组 ['foo', 'bar']。最终序列化发生在响应发送阶段(见文末 respond 小节)。

null / undefined 与 204 的自动协商

文档中这段说明是理解 ctx.body 的关键:

response.status 未被设置,Koa 会根据 response.body 自动把状态设为 200204。具体而言,如果 body 未设置或为 null/undefined,Koa 自动把状态设为 204。若你真要用其它状态发送无内容响应,需按下面的方式覆盖 204。

// 必须先于 status 设置,因为 null | undefined body 会自动把状态置为 204
ctx.body = null;
// 现在用期望的状态覆盖 204
ctx.status = 200;

源码印证了顺序敏感性:set body(null)this.status = 204 会触发 status setter 把 _explicitStatus 置为 true;随后再写 ctx.status = 200 才能最终生效。反过来,先写 ctx.status = 200 再写 ctx.body = null,由于此时 statuses.empty[200] 为假而状态已是 200,body 分支会走 this._body = 'null'(Content-Type 为 json 时)或直接维持 200——两种写法的产物不同,务必按文档给出的“先 body 后 status”顺序操作。

官方建议:用中间件断言 body 类型

文档明确指出:Koa 不会拦截一切可能被赋给 body 的值——函数没有有意义的序列化结果、布尔值在你的应用里可能合理、Error 对象虽然能工作但部分属性不可枚举而可能不符合预期。官方推荐的对策是添加一个开发期断言中间件:

app.use(async (ctx, next) => {
  await next()

  ctx.assert.equal('object', typeof ctx.body, 500, 'some dev did something wrong')
})

response.length:Content-Length 的读写

  • setterlib/response.js):设置 Content-Length,但若响应已带 Transfer-Encoding 头则跳过——两者互斥,这是 HTTP 语义的硬约束:
set length (n) {
  if (!this.has('Transfer-Encoding')) {
    this.set('Content-Length', n)
  }
}
  • getterlib/response.js):优先解析已存在的 Content-Length 头;否则从 ctx.body 推断——string 用 Buffer.byteLength、Buffer 直接取 length、其它对象用 Buffer.byteLength(JSON.stringify(body)) 估算;stream 或空 body 返回 undefined。文档描述为“返回 number、推断值或 undefined”,源码完全一致。

响应头操作:header / get / has / set / append / remove

response.header 与 response.headers

两者都返回响应头对象。实现上优先调用 res.getHeaders(),并保留了 Node < 7.7 的 res._headers 兼容路径(lib/response.js):

get header () {
  const { res } = this
  return typeof res.getHeaders === 'function'
    ? res.getHeaders()
    : res._headers || {} // Node < 7.7
}

headersheader 的别名。response.socket 则直接返回 this.res.socket(net.Socket 实例)。

response.get(field)

大小写不敏感地读取某个响应头:

const etag = ctx.response.get('ETag');

实现即 this.res.getHeader(field)lib/response.js)。

response.has(field)

判断某个响应头是否已设置,匹配大小写不敏感:

const rateLimited = ctx.response.has('X-RateLimit-Limit');

实现优先 res.hasHeader,并保留了旧 Node 的下落路径(lib/response.js)。set body 内部就靠 this.has('Content-Type') 决定要不要自动设置 Content-Type——用户已显式设置的 Content-Type 永远不会被 body 赋值覆盖,这一行为有 tests/response/body.test.js 中 “when Content-Type is set / should not override” 用例支撑。

response.set(field, value) 与 response.set(fields)

单头与批量两种形式:

ctx.set('Cache-Control', 'no-cache');
ctx.set({
  'Etag': '1234',
  'Last-Modified': date
});

实现(lib/response.js):

set (field, val) {
  if (this.headerSent || !field) return

  if (typeof field === 'string') {
    if (field.toLowerCase() === 'content-type') {
      assert(!Array.isArray(val), 'Assign multiple Content-Type for response header is not allowed')
    }
    this.res.setHeader(field, val)
  } else {
    Object.keys(field).forEach(header => this.res.setHeader(header, field[header]))
  }
}
  • 头已发送(headerSent)后调用是静默无效的,不会抛错;
  • 底层委托给 res.setHeader,即按 key 设置或更新,而不会重置整个头集合(文档原话:delegates to setHeader which sets or updates headers by specified keys and doesn't reset the entire header);
  • 额外约束:Content-Type 不允许被赋成数组(多头 Content-Type 无意义),违反会抛断言错误。

response.append(field, value)

追加一个头的值,与已有值合并为数组(lib/response.js):

ctx.append('Link', '<http://127.0.0.1/>');

典型用于 Set-CookieLinkWarning 等允许多值的头。

response.remove(field)

移除响应头,headerSent 后静默返回:

ctx.response.remove('ETag');

response.type 与 response.is:MIME 类型

response.type

返回剥离了 charset 等参数后的 Content-Type:

const ct = ctx.type;
// => "image/png"

实现为 type.split(';', 1)[0]lib/response.js)。

response.type=

通过 mime 字符串或文件扩展名设置 Content-Type:

ctx.type = 'text/plain; charset=utf-8';
ctx.type = 'image/png';
ctx.type = '.png';
ctx.type = 'png';

setter 内部走 mime-typescontentType(type) 做解析(lib/response.js):解析成功就 set('Content-Type', ...),失败则 remove('Content-Type')。文档提醒:合适的类型会自动附带 charset(例如 response.type = 'html' 会得到 utf-8),若需要精确覆盖 charset,应直接用 ctx.set('Content-Type', 'text/html') 绕过自动补全。

response.is(types...)

ctx.request.is() 类似,判断响应类型是否属于给定类型之一,底层是 type-is 包(lib/response.js)。文档给出的经典用例是“压缩所有非流的 HTML 响应”的中间件:

const minify = require('html-minifier');

app.use(async (ctx, next) => {
  await next();

  if (!ctx.response.is('html')) return;

  let body = ctx.body;
  if (!body || body.pipe) return;

  if (Buffer.isBuffer(body)) body = body.toString();
  ctx.body = minify(body);
});

response.redirect 与 response.back:重定向与安全处理

response.redirect(url)

执行 [302] 重定向:

ctx.redirect('/login');
ctx.redirect('http://google.com');

要改变默认 302,在调用前后赋值状态即可;要改变 body,在调用之后赋值:

ctx.status = 301;
ctx.redirect('/cart');
ctx.body = 'Redirecting to shopping cart';

源码(lib/response.js)做了两件文档没有明说的事:

redirect (url) {
  if (/^https?:\/\//i.test(url)) {
    // formatting url again avoid security escapes
    url = new URL(url).toString()
  }
  this.set('Location', encodeUrl(url))

  // status
  if (!statuses.redirect[this.status]) this.status = 302

  // html
  if (this.ctx.accepts('html')) {
    url = escape(url)
    this.type = 'text/html; charset=utf-8'
    this.body = `Redirecting to ${url}.`
    return
  }

  // text
  this.type = 'text/plain; charset=utf-8'
  this.body = `Redirecting to ${url}.`
}
  • URL 归一化与防注入:绝对 http(s) URL 会经 new URL(url).toString() 重新格式化,避免反斜杠等字符造成的 Location 头注入;相对/绝对 URL 最后都经 encodeUrl 编码(中文、emoji 等自动百分号编码);
  • 状态码协商:仅当当前状态码不属于重定向族(statuses.redirect,即 301/302/303/307/308)时才置 302——这就是“先 ctx.status = 301redirect 可以生效”的底层原因;
  • 响应体协商:客户端 Accept 含 HTML 时返回 HTML 文本并做 escape 防 XSS,否则返回纯文本。测试文件 tests/response/redirect.test.js 覆盖了 http://google.com\@apple.com 的归一化、emoji URL 编码、HTML 接受时 body 内容等场景。

response.back(url)

类似 redirect,但先检查 Referrer 头。v3 新增——v2 中 redirect('back', alt) 的特例被移除后,此方法成为推荐写法。源码逻辑(lib/response.js):

back (alt) {
  const referrer = this.ctx.get('Referrer')
  if (referrer) {
    // referrer is an absolute URL, check if it's the same origin
    const url = new URL(referrer, this.ctx.href)
    if (url.host === this.ctx.host) {
      this.redirect(referrer)
      return
    }
  }

  // no referrer, use alt or '/'
  this.redirect(alt || '/')
}

注意安全设计:只有 Referrer 与当前请求同 host 时才会跳回 Referrer,跨域 Referrer 一律落到 alt || '/',防止开放重定向。

response.attachment([filename], [options])

Content-Disposition 设为 attachment,提示客户端下载。filenameoptions 均可选,options 支持 content-disposition 包的配置项(如 typefallback 等)。源码(lib/response.js)还有一个贴心细节:

attachment (filename, options) {
  if (filename && !this.has('Content-Type')) {
    this.type = extname(filename)
  }
  this.set('Content-Disposition', contentDisposition(filename, options))
}

传了 filename 且尚未设置 Content-Type 时,会按文件扩展名自动推断 MIME(例如 report.xlsxapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet)。

lastModified 与 etag:缓存协商头

response.lastModified / response.lastModified=

getter 将 Last-Modified 头解析回 Date(不存在则 undefined);setter 接受 Date 或日期字符串,统一转成 UTC 字符串写头(lib/response.js):

ctx.response.lastModified = new Date();
ctx.response.lastModified = '2013-09-13';

response.etag=

设置带引号包裹的 ETag,自动补全缺失的引号(支持 W/ 弱校验前缀):

ctx.response.etag = crypto.createHash('md5').update(ctx.body).digest('hex');
// 最终写入: ETag: "d41d8cd98f00b204e9800998ecf8427e"
set etag (val) {
  if (!/^(W\/)?"/.test(val)) val = `"${val}"`
  this.set('ETag', val)
}

文档注明“没有对应的 response.etag getter”,但当前源码中实际已补充了 getter(返回 this.get('ETag')lib/response.js)——以仓库源码为准,现在可以双向读写了。

response.vary / response.headerSent / response.flushHeaders / response.writable

  • vary(field):给 Vary 头追加字段,底层用 vary 包做去重合并(lib/response.js);headerSent 后静默返回。
  • headerSent:透传 this.res.headersSent,判断响应头是否已经发出。文档提示其用途:“检查客户端在出错时是否可能还来得及收到通知”——因为头一旦发出,状态码就改不了了。前面 statussetremovevary 等 setter 全部都有 if (this.headerSent) return 保护,这个 getter 是整个头写入安全机制的判断依据。
  • flushHeaders()res.flushHeaders() 的代理,用于先把头刷给客户端再开始写 body(分块传输、SSE 等场景)。
  • writable:布尔值,指示响应是否仍可写(lib/response.js)。实现综合了 res.writableEndedres.finishedsocket.writable 三个信号——“已有 pending 响应时 socket 为 falsy 但依然可写”的情况被显式处理为返回 true

幕后机制:application.js 中 respond() 的完整流程

以上 API 只是“记账”,真正把响应写出去的是 lib/application.jsrespond(ctx)。理解这个函数能解释很多 body 相关的行为:

function respond (ctx) {
  // allow bypassing koa
  if (ctx.respond === false) return        // 允许中间件接管底层 res(如 WebSocket 升级)

  const res = ctx.res
  if (!ctx.writable) return res.end()

  let body = ctx.body
  const code = ctx.status

  // ignore body
  if (statuses.empty[code]) {              // 204/304 等:强制去 body
    ctx.body = null
    return res.end()
  }

  if (ctx.method === 'HEAD') {             // HEAD:补 Content-Length 但不发 body
    if (!res.headersSent && !ctx.response.has('Content-Length')) {
      const { length } = ctx.response
      if (Number.isInteger(length)) ctx.length = length
    }
    return res.end()
  }

  // status body
  if (body === null || body === undefined) {
    if (ctx.response._explicitNullBody) {  // 显式 null body:清空相关头后结束
      ctx.response.remove('Content-Type')
      ctx.response.remove('Transfer-Encoding')
      ctx.length = 0
      return res.end()
    }
    if (ctx.req.httpVersionMajor >= 2) {
      body = String(code)                  // HTTP/2 返回状态码数字
    } else {
      body = ctx.message || String(code)   // HTTP/1.x 返回标准状态消息
    }
    if (!res.headersSent) {
      ctx.type = 'text'
      ctx.length = Buffer.byteLength(body)
    }
    return res.end(body)
  }

  // responses
  if (Buffer.isBuffer(body)) return res.end(body)
  if (typeof body === 'string') return res.end(body)

  let stream = null
  if (body instanceof Blob) stream = Stream.Readable.from(body.stream())
  else if (body instanceof ReadableStream) stream = Stream.Readable.from(body)
  else if (body instanceof Response) stream = Stream.Readable.from(body?.body || '')
  else if (isStream(body)) stream = body

  if (stream) {
    return Stream.pipeline(stream, res, err => {
      if (err && ctx.app.listenerCount('error')) ctx.onerror(err)
    })
  }

  // body: json
  body = JSON.stringify(body)
  if (!res.headersSent) {
    ctx.length = Buffer.byteLength(body)
  }
  res.end(body)
}

结合文档可以读出几条重要规则:

  1. 空状态码(204、304 等)无条件去 body:即使你设置了 body,respond 也会先执行 ctx.body = nullres.end(),与 status setter 中“empty 状态强制清空 body”形成双保险;
  2. 未显式 null 的 undefined body 会退化为文本状态消息(HTTP/1.x 下 body 是 "Not Found" 之类的字符串)——这正是“默认 404”的最终呈现;而 ctx.body = null(显式)走 _explicitNullBody 分支,返回一个长度为 0 的空响应;
  3. stream 统一走 Stream.pipeline:相比裸 pipe,pipeline 保证错误传播与自动 destroy,这也是为什么 body setter 中 onFinish destroy 之后,respond 里不再重复挂 error 监听(见 tests/response/body.test.js 的 “should not add error handler to stream (handled by pipeline)”);
  4. ctx.respond = false 逃逸口:需要完全绕过 Koa 响应处理(如升级 WebSocket)时,置为 false 后 Koa 不再碰 res
  5. JSON 序列化在此发生,且只有头尚未发出时才补 Content-Length——所以 body 的 length 推算必须发生在 ctx.flushHeaders() 之前。

测试基线与延伸阅读

Response 行为的回归基线位于 __tests__/response/ 目录,与文档 API 一一对应:body.test.js(body 类型分发、Content-Type 覆盖、stream 替换)、redirect.test.js(URL 归一化、HTML 协商)、status.test.jsset.test.jshas.test.jsappend.test.jsremove.test.jstype.test.jsetag.test.jslast-modified.test.jsattachment.test.jsvary.test.jswritable.test.js 等。测试基于 Node 内置 node:test 与 supertest,可用 npm test(即 node --test)运行全部用例;测试用上下文辅助器见 test-helpers/context.js

与本文相关的其它仓库文档:Request APIctx.request.is()ctx.accepts 的对照)、Context APIctx.onerrorctx.assert 的实现说明)、Application APIctx.respond = false 与应用级错误处理)。

小结

Koa 的 Response 抽象可以浓缩为三层:

  • 声明层status / body / type / length 等属性赋值,配合 _explicitStatus_explicitNullBody 两个内部标志完成 200/204 自动协商与 404 默认值语义;
  • 头控层set / append / remove / has / getheaderSent 保护,所有写入在头发出后静默失效,保证不会破坏已提交的状态行;
  • 发送层respond() 统一处理空状态去 body、HEAD 补长、HTTP/2 状态消息、stream pipeline 与 JSON 序列化,让中间件只需关心“说什么”,而无需关心“怎么发”。

掌握这条链路后,代理透传、文件下载、缓存协商与自定义响应格式等场景的实现,都有了明确的源码依据可循。

登录后查看全文
热门项目推荐
相关项目推荐