大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
http 模块
这一课你将能做到
- 用零依赖的
node:http起一个 HTTP 服务器并处理请求、返回响应 - 手工解析路径、查询参数和 POST 的 JSON 请求体
- 解释 req/res 是两条流,说清状态码与 Content-Type 的作用
为什么要学
Express、Koa 这些框架最终都是对 http 模块的封装。先用原生模块写一遍服务器,你才能回答:路由到底是什么?中间件在哪一层工作?express.json() 到底替你干了什么?
人话:http.createServer 就是 Node 帮你把 TCP 里的字节流翻译成「一个请求对象 req、一个响应对象 res」,剩下的路由判断、参数解析、状态码返回都要自己动手。这课写的每个手工环节,都会在下一课被 Express 一一「收编」——先手工再框架,框架对你就是透明的。
先建立图像
浏览器: GET /hello?name=Java
│ TCP 连接(默认 127.0.0.1:3000)
▼
┌─ http.createServer((req, res) => { ... }) ─┐
│ req: method / url / headers / body(流) │
│ res: setStatus / setHeader / write / end │
└─────────────────────────────────────────────┘
│
▼
浏览器收到: 状态码 200 + 响应头 + 响应体
一次请求的生命周期 = 建连接 → 回调拿到 req/res → 你处理 → res.end() → 关闭
req 和 res 本质都是流:req 是可读流(请求体一点一点到),res 是可写流(响应体一点一点发)。这解释了两件事:为什么 POST body 要监听事件来收,为什么大响应不用一次凑齐内存。
跟着做
步骤 1:最小服务器
新建 node-http 目录,npm init -y,新建 server.js:
// server.js
const http = require('node:http');
const server = http.createServer((req, res) => {
console.log('收到请求:', req.method, req.url);
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end('hello from node http');
});
const PORT = 3000;
server.listen(PORT, () => {
console.log(`服务器已启动: http://localhost:${PORT}`);
});
node server.js
# 服务器已启动: http://localhost:3000
浏览器访问 http://localhost:3000,看到 hello from node http;终端会打印 收到请求: GET /。浏览器还会额外发一次 GET /favicon.ico,属正常现象。
按 Ctrl+C 停止。改了代码要手动重启(下一课的 nodemon / node --watch 解决这个)。
步骤 2:加路由与查询参数
升级 server.js,用 new URL() 解析请求:
// server.js
const http = require('node:http');
const server = http.createServer((req, res) => {
// 第二个参数是基准地址,用来把 req.url 解析成结构化对象
const url = new URL(req.url, `http://${req.headers.host}`);
const pathname = url.pathname; // /hello
const name = url.searchParams.get('name') ?? 'world';
if (pathname === '/hello') {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end(`hello, ${name}`);
return;
}
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end('Not Found');
});
server.listen(3000, () => console.log('http://localhost:3000'));
node server.js
测试(新开一个终端,或用浏览器地址栏):
curl "http://localhost:3000/hello?name=Java"
# hello, Java
curl "http://localhost:3000/other"
# Not Found
结构:每个分支负责一条路由,处理完 return,兜底返回 404。这就是所有框架路由的本质。
步骤 3:返回 JSON
API 服务的标配。在 server.js 里加一条路由:
if (pathname === '/api/time') {
const data = { now: new Date().toISOString() };
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(data));
return;
}
curl http://localhost:3000/api/time
# {"now":"2026-10-06T06:45:12.345Z"}
要点:JSON.stringify 负责把对象变字符串,Content-Type: application/json 告诉调用方按 JSON 解析。两件事缺一不可——只 stringify 不设头,前端 response.json() 可能拿不到预期行为。
步骤 4:接收 POST 的 JSON 请求体
req 的 body 不会自动给你,要像读文件一样把它「读」出来:
// server.js 增加
function readBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.setEncoding('utf8');
req.on('data', (chunk) => { body += chunk; }); // 一块一块到
req.on('end', () => resolve(body)); // 读完触发 end
req.on('error', reject);
});
}
// 在 createServer 回调里增加:
if (pathname === '/api/echo' && req.method === 'POST') {
const raw = await readBody(req);
let data;
try {
data = JSON.parse(raw);
} catch {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ error: '请求体不是合法 JSON' }));
return;
}
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ youSent: data }));
return;
}
记得把 createServer 的回调改成 async。用 curl 测:
curl -X POST http://localhost:3000/api/echo ^
-H "Content-Type: application/json" ^
-d "{\"msg\":\"hi\"}"
# {"youSent":{"msg":"hi"}}
curl -X POST http://localhost:3000/api/echo -d "not-json"
# {"error":"请求体不是合法 JSON"}
(PowerShell 里 curl 是 Invoke-WebRequest 的别名,带引号的 JSON 可改用 curl.exe 或用 Node 测试:node -e "fetch('http://localhost:3000/api/echo',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({msg:'hi'})}).then(r=>r.json()).then(console.log)"——Node 20 内置 fetch。)
步骤 5:Content-Type 与静态返回
再体会一次响应头的作用——返回 HTML:
if (pathname === '/page') {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end('<h1>标题</h1><p>这是 http 模块吐出的页面</p>');
return;
}
浏览器访问 http://localhost:3000/page,<h1> 被渲染成标题——因为浏览器看到 text/html 就按 HTML 解析。如果 Content-Type 写成 text/plain,同样的内容就显示为源码。Content-Type 决定接收方怎么解读字节。
讲清楚要点
createServer(callback)注册的是「每个请求执行一次」的函数:server.listen(PORT)才真正开始监听。回调里的代码没有if/else兜底就等于所有路由共用一段逻辑。- req 是可读流,res 是可写流:POST/PUT 的 body 分块到达,必须监听
data/end拼起来(或用框架的 body 解析中间件);res 调end()才算响应完成,end 后不能再写。 - 状态码 + Content-Type 是响应的两大元信息:200 成功、201 已创建、400 客户端参数错、401 未认证、404 找不到、500 服务器内部错。JSON 接口永远
application/json; charset=utf-8。 - 解析 URL 用
new URL(req.url, base):req.url只有路径和查询串(如/hello?name=Java),配 host 基准后,url.pathname、url.searchParams才可用。 - 原生 http 没有路由表、没有中间件、没有 body 解析:这些正是 Express 补齐的部分。手写过一遍,第 6 课每个概念你都有对照物。
你可能会懵的地方
-
为什么浏览器访问会「请求两次」? 第二次通常是
GET /favicon.ico——浏览器自动要网站图标。服务器没处理就返回 404,无害。加个判断直接回 204 可消除噪音。 -
req.url和完整网址的区别? 服务器端拿到的req.url永远只有/path?query部分,主机名在req.headers.host里。所以new URL(req.url, ...)需要第二个基准参数。 -
POST body 为什么不是
req.body直接可读? 因为数据是从网络一条一条到达的流,http 模块不替你缓存拼装(省内存、可边收边处理)。拼装这个体力活就是 Expressexpress.json()中间件的工作。 -
res.end()之后还能改状态码吗? 不能。响应头在第一次 write/end 时发出,之后再 setHeader/statusCode 会报ERR_HTTP_HEADERS_SENT。所以「先定状态码和头,再写体,最后 end」的顺序要固定。 -
这服务器能上生产吗? 能跑但裸:没有日志、没有 body 大小限制(恶意大 body 会吃内存)、没有 HTTPS、没有集群。练手和内网工具够用;对外服务用框架 + 反向代理(Nginx)。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
忘了 server.listen | 进程不退出但端口没开,浏览器拒绝连接 | createServer 后必须 listen(PORT, cb) |
先 res.end() 再 setHeader | ERR_HTTP_HEADERS_SENT | 状态码/头 → 写体 → end |
GET 路由里直接读 req.body | GET 没有 body,得到 undefined | 参数用 url.searchParams,body 只在 POST/PUT |
JSON 接口不设 Content-Type | 调用方按文本处理,response.json() 失败 | 设 application/json; charset=utf-8 |
不限长度的 body 直接 body += chunk | 恶意请求可撑爆内存 | 检查 content-length,超限回 413 并 req.destroy() |
| 改完代码不重启 | 新逻辑不生效,怀疑人生 | Ctrl+C 重启,或用 node --watch |
卡点急救
EADDRINUSE: address already in use:3000 端口被上一个没关干净的进程占着。关掉旧终端;或换端口;Windows 查占用:netstat -ano | findstr :3000后taskkill /PID <pid> /F。- 浏览器一直转圈不返回:某个分支没调用
res.end(),连接挂起。检查所有分支是否都有 end,兜底 404 分支必不可少。 - 中文乱码:响应头没带
charset=utf-8,或源文件本身不是 UTF-8 保存。两处都改。 - POST 收到的 body 是空字符串:curl 没带
-d或客户端没发 body;也常见于忘了等end事件就JSON.parse。 - 局域网手机访问不到:
listen(3000)默认监听所有网卡没问题,查 Windows 防火墙放行,或临时用listen(3000, '0.0.0.0')。
面试常问
-
用原生 http 模块创建服务器的流程?
http.createServer(handler)创建实例,handler 在每个请求到达时收到req(可读流)与res(可写流);server.listen(port)监听端口;handler 内完成路由判断、头部设置、写响应体、res.end()结束响应。 -
Node 如何处理 POST 请求体? req 是可读流:监听
data事件累积 chunk、end事件表示接收完毕,可在此期间做长度限制与编码转换;框架的 body parser 做的正是这件事(如 express.json 按 content-type 解析并挂到 req.body)。 -
常见的 HTTP 状态码有哪些?语义是什么? 2xx 成功(200 OK、201 Created、204 No Content);3xx 重定向(301 永久、302 临时、304 缓存);4xx 客户端错误(400 参数、401 未认证、403 禁止、404 不存在、429 太频繁);5xx 服务端错误(500 内部错误、502 网关错、503 不可用)。
-
为什么说 Node 适合 I/O 密集的 Web 服务? 请求处理多为等待数据库/网络返回,Node 的事件循环在等待期间可处理其他连接,少量线程即可承载高并发;相反 CPU 密集会阻塞循环,需要子进程或换技术栈。
自测三问
http.createServer的回调什么时候执行?listen之前会执行吗?- 写出「返回 JSON 数据」必不可少的三行代码(状态码、头、体)。
- 为什么读 POST body 必须监听
data和end事件?
小结一句话
原生 http 模块 = 「把 TCP 字节翻译成 req/res 两条流」:路由自己 if、body 自己收、头自己设——手工走一遍,Express 的每一层封装就都看得懂了。
延伸
- Node.js 官方文档 http
- MDN HTTP 状态码
- 下一课:Express 入门——用中间件和路由表把本课的手工活全部收编。