前端学习笔记 · 第 119 天:响应状态码与响应头
- 2026-11-04 16:06:54
前端学习笔记 · 第 119 天:响应状态码与响应头——writeHead、setHeader 与发送顺序
Day 119 · 第五阶段 · Node.js 与后端开发
昨天把请求对象拆开看:方法、路径、头部、正文流。今天补上另一半:服务器怎么把结果写回去。响应由三部分组成——状态行、响应头、响应体,缺一处都会让调用方读不懂结果。
状态行的写法比请求行简单,只有状态码与原因短语;容易出错的是顺序:状态码与头部必须在正文之前确定,头部一旦发出就不能再改。
一、问题现场
四个典型现象:
所有响应都是 200,前端只能靠解析正文里的 code字段判断成败,拦截器与监控都失效。想补一个 x-trace头,日志里报ERR_HTTP_HEADERS_SENT,因为已经调用过writeHead或写过正文。删除接口返回 204,却带了一段 JSON 正文,客户端解析时按内容长度判断,读到的是空字符串。 用 res.write分段写了几次,响应头里没有content-length,客户端只能边收边处理。
四处都指向同一件事:响应是一次性的,状态与头先定,正文后写,顺序不能颠倒。
二、状态码与状态行
响应对象上的两个字段决定状态行:statusCode 是数字,statusMessage 是原因短语。默认状态码是 200,原因短语由内置表给出,例如 200 对应 OK。
常用状态码按用途分三组:
成功:200 表示请求已处理;201 表示新建了资源,通常配合 location头;204 表示成功但没有正文。客户端错误:400 表示请求内容不合法;404 表示路径不存在;405 表示路径存在但方法不支持,需要配合 allow头。服务端错误:500 表示处理过程中出错,不应把内部细节写进正文。
状态码可以只设字段,也可以随头部一起给出。实测一段同时用到三种写法的代码:
res.statusCode = 201;res.statusMessage = "Made";res.setHeader("x-a", "1");res.writeHead(201, { "x-b": "2" });res.end("body");客户端收到状态码 201、原因短语 Made,同时看到 x-a 与 x-b 两个头。writeHead 只携带自己的头,之前用 setHeader 设置的头不会被覆盖。
三、响应头
头部的增删查改由四个方法负责:setHeader 写入、getHeader 读取、getHeaderNames 列出、removeHeader 删除。实测先写入 x-a 再删除,getHeader 在删除后返回 undefined,getHeaderNames 只剩其余的头。
几条与请求头对称的规则:
头名大小写不敏感,写入 X-Trace与x-trace是同一个头。值是字符串,也可以是数组;数组形式的普通头在发送时会被合并成 a, b这样的一段字符串。有些头由 Node 自己补上: date、connection、keep-alive,以及按正文长度决定的content-length或transfer-encoding。
写头有两条路径:用 setHeader 逐个设置,再由 writeHead 一并发出;或直接把头对象交给 writeHead 的第二个参数。两者可以混用,先设置的头会与 writeHead 传入的头合并,实测两者都出现在最终响应里。
四、发送顺序
响应的发送分三个阶段,顺序决定了哪些操作还来得及。
准备阶段可以随意修改:设 statusCode、设statusMessage、调setHeader,此时headersSent为false。调用writeHead、首次write或end之后,头部被锁定,headersSent变为true。锁定之后再调setHeader会抛错,实测错误码为ERR_HTTP_HEADERS_SENT。
这一条解释了「日志里突然出现的异常」:代码里有一条分支先写了正文,后面的分支又想补一个头,必然失败。处理办法是把握手顺序写清楚——所有 setHeader 集中在发送之前,业务分支内部只决定状态码与正文。
正文阶段用 write 分段写、用 end 结束。end 之后 writableEnded 变为 true,再写正文不会生效。
五、响应体的三种写法
正文的传输方式由代码形态决定,三种情况实测结果不同:
单次 end("ok"):Node 直接给出content-length: 2,客户端一次读完。先 write再end:头部没有content-length,改用transfer-encoding: chunked,正文按块传输,实测三段写出后拼回abc。显式设置 content-length:按设置值发送,正文长度与之相符,实测为 5 字节的hello。
204 与 304 是例外:它们不携带正文。实测给 204 调用 end("ignored"),客户端收到的正文为空,响应头里也没有长度字段。需要「操作成功但没有内容返回」时用 204,不必写一个空对象。
六、两个类
理解这两个类比记字段更省事:
请求对象是 IncomingMessage的实例,是一个可读流,负责方法、路径、头部与正文。响应对象是 ServerResponse的实例,是一个可写流,负责状态码、响应头与正文。
API 文档把两者的字段分开列,方法、url、headers 属于前者;statusCode、setHeader、write、end 属于后者。排查时先看字段属于哪个类,再去查对应页面,比在代码里猜快得多。
七、实战示例:按路径返回状态码
把前面的规则收进一个服务器:/health 返回 200 与 JSON,/echo 只接受 POST,合法 JSON 返回 201 与 location,坏 JSON 返回 400,其他路径返回 404,方法不匹配返回 405 并带上 allow,/empty 用 DELETE 返回 204。
// server.mjs —— 按路径返回不同状态码与响应头import { createServer } from "node:http";const routes = { "/health": { GET(req, res) { res.writeHead(200, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ ok: true })); }, }, "/echo": { POST(req, res) { const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => { const text = Buffer.concat(chunks).toString("utf8"); let body; try { body = JSON.parse(text || "{}"); } catch { res.writeHead(400, { "content-type": "application/json; charset=utf-8" }); return res.end(JSON.stringify({ error: "invalid json" })); } res.writeHead(201, { "content-type": "application/json; charset=utf-8", location: "/echo/1" }); res.end(JSON.stringify({ received: body })); }); }, }, "/empty": { DELETE(req, res) { res.writeHead(204); res.end(); }, },};const server = createServer((req, res) => { const { pathname } = new URL(req.url, "http://localhost"); const route = routes[pathname]; if (!route) { res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); return res.end("not found"); } const handler = route[req.method]; if (!handler) { res.writeHead(405, { allow: Object.keys(route).join(", "), "content-type": "text/plain; charset=utf-8" }); return res.end("method not allowed"); } handler(req, res);});server.on("error", (error) => { console.error("服务器错误:", error.message); process.exitCode = 1;});server.listen(3019, () => console.log("监听", server.address().port));六条请求对应六种响应:
其中两条值得单独说明。/echo 成功时带上 location: /echo/1,如果要做完整的创建语义,这个头指向新资源;/empty 用 204 表示删除成功,正文留空。方法不匹配与路径不存在是两回事:404 表示没有这个资源,405 表示有资源但方法不对,后者必须带 allow,让调用方知道该用哪个方法。
常见问题
Q1:为什么所有响应都变成了 200?
代码里只调用了 res.end,没有设 statusCode,默认值就是 200。把状态码写在 writeHead 的第一个参数里,或提前设置 res.statusCode,让成功与失败走不同分支。
Q2:ERR_HTTP_HEADERS_SENT 是什么原因?
头部已经发出,又调用了 setHeader。常见于先 write 或 writeHead、后补头的代码路径。把所有头的设置集中在发送之前,分支内部只决定状态码与正文。
Q3:setHeader 与 writeHead 的头会不会互相覆盖?
不会互相清空。先 setHeader 的头会被保留,writeHead 传入的头会合并进来;同名头以 writeHead 的值为准。实测先设 x-a 再传 x-b,两者都出现在响应里。
Q4:204 为什么没有正文?
204 的语义是「成功且不需要返回内容」,规范规定不带响应体。即使代码里调用了 end("内容"),客户端收到的也是空正文,响应头里也没有长度字段。
Q5:响应头里没有 content-length,客户端怎么知道长度?
看 transfer-encoding。分段写出时使用 chunked,客户端按块读取,收到结束标志即为完整。需要确切长度时设置 content-length,或用一次 end 让 Node 自行计算。
Q6:404 与 405 该怎么选?
看资源是否存在。路径不存在用 404;路径存在但方法不支持用 405,并附上 allow 头列出可用方法。两者混淆会让调用方分不清「地址写错」还是「方法用错」。
知识点总结
状态行:
数字状态码在
statusCode,原因短语在statusMessage,默认 200 与内置短语常用状态码:
200 成功、201 新建、204 无正文、400 请求不合法、404 不存在、405 方法不匹配
响应头:
增删查改分别由
setHeader、getHeader、getHeaderNames、removeHeader负责头名规则:
大小写不敏感;数组值发送时合并成逗号分隔的字符串
两种写法:
先逐个
setHeader再由writeHead发出,或直接把头对象交给writeHead发送顺序:
先定状态与头,再写正文;头部发出后
headersSent变为true锁定之后:
再调用
setHeader抛ERR_HTTP_HEADERS_SENT正文传输:
单次
end带content-length,多次write改用分块传输两个类:
请求是
IncomingMessage,响应是ServerResponse,字段按类归属查文档
(本文为学习笔记整理)