1. 项目概述:从“写个接口”到真正理解 Express 路由机制的分水岭
你是不是也经历过这样的时刻:刚学完
npm init
和
npm install express
,兴冲冲地敲下
app.get('/', (req, res) => res.send('Hello World'))
,页面真出来了——心里一热,觉得“Express 不过如此”。可等你真正要写一个用户注册、登录、资料修改、头像上传的完整小系统时,问题就来了:为什么 POST 表单提交后页面白屏?为什么用 Axios 调 PUT 接口老是报 404?为什么前端发了 DELETE 请求,后端
app.delete()
却根本没执行?更别提那些调试时满屏飘过的
502 Bad Gateway
、
418 I'm a teapot
(别笑,这真是 HTTP 官方状态码)、甚至
Error: Can't set headers after they are sent
这种让人抓狂的报错。这些不是玄学,而是你还没真正“看见”Express 路由这层薄薄的玻璃纸背后,HTTP 协议、Node.js 事件循环、中间件洋葱模型三者咬合运转的真实齿痕。
这篇内容,就是帮你把这张纸捅破。它不讲“Express 是什么”,因为那属于百科词条;它也不堆砌所有 40+ 个 HTTP 方法(RFC 7231 里定义的),只聚焦最核心、最高频、最容易出错的四个:
GET、POST、PUT、DELETE
。我会带你从一行
app.get()
的底层执行路径开始,拆解它如何被 Node.js 的
http.IncomingMessage
实例触发、如何匹配 URL 模式、如何流转进中间件栈、又如何最终抵达你的回调函数。你会明白,
app.post('/user', ...)
里的
/user
不是字符串,而是一个正则表达式编译后的匹配器;
req.body
为什么默认是
undefined
,而加了
express.json()
中间件后它才“活”过来;
PUT
和
POST
在语义上根本不是“差不多”,而是 RESTful 设计哲学里“创建”与“更新”的严格分野。如果你正在用 Express 写真实项目,或者准备技术面试中被问到“路由匹配原理”,又或者只是想摆脱“能跑就行”的初级状态——这篇文章就是为你写的。它不假设你懂 HTTP 报文结构,但会用
curl -v
命令现场抓包,让你亲眼看到请求头里
Content-Type: application/json
是如何决定后端解析逻辑的;它不回避
req.params
、
req.query
、
req.body
这三个容易混淆的数据源,而是用一张表格对比它们的来源、用途和典型场景。这不是教程,这是我在给团队新人做内部分享时反复打磨的实战笔记,里面每一个坑,都是我亲手踩过、截图存证、再花半小时写测试用例验证过的。
2. 核心设计思路:为什么是 GET/POST/PUT/DELETE,而不是其他?
2.1 HTTP 方法的本质:动词即契约,不是语法糖
很多初学者把
app.get()
和
app.post()
看作是“写两个不同名字的函数”,这是最大的认知偏差。HTTP 方法(HTTP Method)在 RFC 7231 中被明确定义为
客户端向服务器发起请求时所声明的意图(intended action)
。这个“意图”不是可有可无的装饰,而是整个 Web 架构的基石契约。
GET
意味着“我要安全地获取资源”,服务器必须保证执行多次
GET
不会产生副作用(比如扣款、发邮件);
POST
意味着“我要提交数据,可能创建新资源”,它天然具有非幂等性(重复提交可能创建多个订单);
PUT
意味着“我要用提供的完整数据,完全替换目标资源”,它是幂等的(提交一次或十次,结果都一样);
DELETE
意味着“我要移除指定资源”,同样要求幂等。Express 的路由方法,正是对这一层语义的直接映射。当你写下
app.put('/api/users/:id', handler)
,你不仅是在注册一个函数,更是在向所有调用者(前端、第三方服务、甚至未来的你自己)宣告:“这个端点只接受完整用户数据的覆盖式更新,且重复调用不会产生额外影响”。
提示:理解这一点,就能立刻避开一个经典陷阱——用
POST去实现“更新用户资料”。这看似能跑通,但违背了 REST 原则,导致前端无法利用浏览器的刷新重试机制(刷新 POST 页面会弹窗警告),也使得 API 文档语义模糊,让后续维护者困惑。真正的“更新”操作,应该用PUT或更精细的PATCH。
2.2 Express 路由匹配的三层过滤机制:URL 路径、HTTP 方法、中间件链
Express 的路由并非简单的“字符串匹配”,而是一个精密的三层过滤流水线。第一层是
URL 路径匹配(Path Matching)
。当你访问
http://localhost:3000/api/users/123?sort=name
,Express 会先提取出
/api/users/123
这段路径(
req.path
),然后依次比对所有已注册的路由路径模式。这里的关键是:
/api/users/:id
是一个路径参数模式,
:id
会被捕获为
req.params.id
;而
/api/users/*
是通配符,
*
匹配任意子路径;
/api/users/:id(\d+)
则加入了正则约束,只匹配数字 ID。第二层是
HTTP 方法匹配(Method Matching)
。只有路径匹配成功,且请求的
req.method
(如
'GET'
、
'POST'
)与路由注册的方法一致,该路由才会被选中。第三层是
中间件链执行(Middleware Chain Execution)
。即使前两层都通过,请求也不会直接进入你的最终处理函数。它会先流经所有在该路由之前注册的中间件(包括全局中间件和路由级中间件),每个中间件都可以修改
req
/
res
对象、终止响应(
res.send()
)、或调用
next()
传递控制权。这三层缺一不可,共同构成了 Express 的“请求生命周期”。
注意:
app.use()注册的是全局中间件,它不关心 HTTP 方法,对所有路径都生效;而app.get()、app.post()等是路由处理函数,必须同时满足路径和方法匹配。混淆这两者是新手最常见的错误之一,比如把日志中间件app.use(logger)错写成app.get(logger),结果日志永远不打印。
2.3 为什么只聚焦这四个方法?RESTful API 的最小完备集
网络热词列表里混杂着大量无关信息(如
dma/bridge subsystem for pci express product guide
、
sql server express安装包下载
),但它们恰恰反衬出一个事实:开发者在真实场景中,95% 以上的 CRUD(创建、读取、更新、删除)操作,都由这四个 HTTP 方法承载。
GET
对应
R(Read)
,用于获取资源列表或单个资源详情;
POST
对应
C(Create)
,用于创建新资源(如提交表单、上传文件);
PUT
对应
U(Update)
,用于根据唯一标识(ID)进行全量更新;
DELETE
对应
D(Delete)
,用于根据唯一标识移除资源。其他方法如
HEAD
(只获取响应头)、
OPTIONS
(查询支持的方法)、
PATCH
(部分更新)虽然存在,但在入门和多数业务场景中并非必需。将精力集中在这四个方法上,能让你用最少的认知成本,构建出语义清晰、易于维护、符合行业惯例的 API。这也是为什么所有主流 API 设计规范(如 OpenAPI Specification)都将这四个方法列为最核心的交互单元。
3. 核心细节解析:GET、POST、PUT、DELETE 的实操要点与数据流向
3.1 GET:安全、可缓存、数据在 URL 中
GET
是最简单也最容易被误解的方法。它的核心特征是:
所有请求数据都通过 URL 查询参数(Query Parameters)传递
。当你在浏览器地址栏输入
http://localhost:3000/search?q=nodejs&sort=stars
,
q=nodejs
和
sort=stars
就是查询参数,它们会自动被 Express 解析并挂载到
req.query
对象上。这意味着
req.query
是一个纯 JavaScript 对象,你可以直接
console.log(req.query.q)
得到
'nodejs'
。
GET
请求天生具备安全性(Safe)和可缓存性(Cacheable),所以浏览器可以放心地对它进行缓存、预加载,甚至在用户点击后退按钮时直接从内存中恢复页面,而无需重新发起网络请求。然而,这也带来了两个硬性限制:一是 URL 长度有限制(不同浏览器不同,通常 2000 字符左右),所以不能传递大量数据;二是所有数据都暴露在 URL 中,因此
绝对不能用于传输敏感信息(如密码、token)
。在 Express 中,处理
GET
的代码极其简洁:
app.get('/search', (req, res) => {
const { q, sort, page = '1' } = req.query; // 使用解构赋值,并设置默认值
console.log(`搜索关键词: ${q}, 排序方式: ${sort}, 页码: ${page}`);
// 这里调用数据库查询逻辑...
res.json({ results: [], total: 0 });
});
实操心得:我曾经在一个电商项目中,把商品筛选条件(价格区间、品牌、规格)全塞进
GET参数,结果当用户选择十几个品牌时,URL 超长,部分老旧安卓 WebView 直接报错。后来我们改用POST+application/x-www-form-urlencoded,并在前端用history.pushState保持 URL 可分享,既解决了长度问题,又保留了用户体验。记住:GET的哲学是“获取”,不是“提交”。
3.2 POST:非幂等、数据在请求体、需显式解析
如果说
GET
是“看”,那么
POST
就是“交”。它的核心特征是:
所有请求数据都封装在 HTTP 请求体(Request Body)中
。这使得
POST
可以传输任意大小、任意格式的数据(文本、JSON、二进制文件)。但这也带来了一个关键问题:Express 默认
不解析请求体
。
req.body
在默认情况下永远是
undefined
。你必须手动添加解析中间件。最常用的是
express.urlencoded()
和
express.json()
。前者用于解析
application/x-www-form-urlencoded
格式(传统 HTML 表单提交的默认格式),后者用于解析
application/json
格式(现代前后端分离应用的主流格式)。它们的顺序至关重要:必须在
app.use()
中
先于所有路由注册
,否则路由函数永远拿不到
req.body
。
// 正确:解析中间件必须放在路由注册之前
app.use(express.urlencoded({ extended: true })); // 处理表单数据
app.use(express.json()); // 处理 JSON 数据
// 错误:如果放在这里,下面的路由将无法访问 req.body
app.post('/login', (req, res) => {
// req.body 将是 undefined!
const { username, password } = req.body; // TypeError: Cannot destructure property 'username' of 'req.body' as it is undefined.
res.send('Login success');
});
extended: true
参数决定了
urlencoded
中间件使用哪个解析库:
true
用
qs
库(支持嵌套对象,如
user[name]=John&user[age]=30
),
false
用内置的
querystring
(只支持扁平键值对)。对于绝大多数项目,
true
是更安全的选择。
注意:
POST的非幂等性意味着,用户不小心连续点击两次“提交订单”按钮,可能会生成两个完全相同的订单。这是业务逻辑必须处理的问题,Express 本身不提供防重提交机制。常见的解决方案有:前端按钮点击后置灰、后端生成唯一请求 ID(X-Request-ID)并做幂等校验、或使用数据库的唯一索引约束。
3.3 PUT:幂等、全量更新、语义即契约
PUT
是 RESTful 设计中最具“契约精神”的方法。它的语义非常明确:
客户端发送的请求体,就是目标资源的完整、最新状态
。服务器的职责不是“修改某个字段”,而是“用这个新状态,完全替换掉旧状态”。例如,有一个用户资源
GET /api/users/123
返回:
{ "id": 123, "name": "Alice", "email": "alice@example.com", "status": "active" }
那么,一个
PUT /api/users/123
请求,其请求体应该是:
{ "name": "Alice Smith", "email": "alice.smith@example.com" }
注意,这里没有包含
id
和
status
字段。根据
PUT
的语义,服务器必须将
id=123
的用户记录,
完全更新为请求体中的数据
。这意味着,如果数据库中该用户的
status
原来是
"active"
,而新请求体中没有
status
字段,那么更新后
status
字段将被设为
NULL
或空字符串(取决于数据库 schema 和 ORM 配置)。这就是
PUT
的“全量更新”特性。它要求客户端必须先
GET
到完整资源,再修改后
PUT
回去,确保数据一致性。
在 Express 中,
PUT
的处理与
POST
几乎完全相同,都需要
express.json()
或
express.urlencoded()
来解析
req.body
,并且路径中同样可以使用
req.params
来获取资源 ID。
app.put('/api/users/:id', (req, res) => {
const userId = parseInt(req.params.id, 10); // 安全地转换为整数
const { name, email } = req.body; // 从请求体中解构
// 业务逻辑:查找用户,然后用 req.body 的所有字段进行全量更新
// 注意:这里必须确保更新操作是原子的,避免并发问题
updateUser(userId, { name, email })
.then(() => res.json({ message: 'User updated successfully' }))
.catch(err => res.status(500).json({ error: err.message }));
});
提示:
PUT的幂等性是其最大优势。想象一个支付回调场景,银行可能因网络原因重复发送同一个支付成功的通知。如果后端用POST处理,每次都会创建一笔新的交易记录;而用PUT,并以支付订单号作为资源 ID(PUT /api/orders/ORD123456),那么无论收到多少次,最终数据库里都只有一条ORD123456的记录,状态始终是“已支付”。这是架构设计上的优雅。
3.4 DELETE:幂等、无请求体、语义即行动
DELETE
方法的语义最为直白:
移除指定的资源
。它的核心特征是:
DELETE
请求通常不携带请求体(Request Body)
。所有必要的信息,都通过 URL 路径(
req.params
)或查询参数(
req.query
)来传递。例如,删除一个用户,标准做法是
DELETE /api/users/123
,其中
123
是路径参数;而删除一批用户,则可能是
DELETE /api/users?ids=123,456,789
,其中
ids
是查询参数。Express 本身对
DELETE
的处理没有任何特殊之处,它和
GET
一样,不需要解析请求体,所以
req.body
在
DELETE
路由中通常是
undefined
(除非你主动配置了
express.json()
并且前端强行发送了 JSON body,但这严重违背了 HTTP 规范)。
// 标准做法:通过路径参数指定要删除的资源
app.delete('/api/users/:id', (req, res) => {
const userId = parseInt(req.params.id, 10);
deleteUser(userId)
.then(() => res.status(204).send()) // 204 No Content 是 DELETE 成功的标准响应
.catch(err => res.status(404).json({ error: 'User not found' }));
});
// 批量删除:通过查询参数传递 ID 列表
app.delete('/api/users', (req, res) => {
const ids = req.query.ids ? req.query.ids.split(',') : [];
deleteUsers(ids)
.then(() => res.json({ message: `${ids.length} users deleted` }))
.catch(err => res.status(500).json({ error: err.message }));
});
DELETE
的幂等性体现在:第一次
DELETE /api/users/123
会成功删除用户;第二次再发同样的请求,服务器应该返回
404 Not Found
(因为资源已不存在),而不是
500 Internal Server Error
。这保证了客户端可以安全地重试失败的
DELETE
请求。
注意:
DELETE操作的“软删除”(Soft Delete)是常见需求,即不真正从数据库物理删除,而是将is_deleted字段设为true。这完全可行,但必须在业务逻辑中实现,Express 路由层只负责接收请求和返回响应。切记,DELETE的 HTTP 语义是“移除”,至于是物理移除还是逻辑标记,是业务层的决策。
4. 实操过程:从零搭建一个完整的用户管理 API 示例
4.1 初始化项目与基础路由骨架
让我们动手实践,把前面所有的理论变成可运行的代码。首先,创建一个新目录,初始化 npm,并安装 Express:
mkdir express-routing-demo
cd express-routing-demo
npm init -y
npm install express
然后,创建
index.js
文件,这是我们的主入口:
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
// 1. 全局中间件:解析请求体
app.use(express.json()); // 解析 application/json
app.use(express.urlencoded({ extended: true })); // 解析 application/x-www-form-urlencoded
// 2. 全局中间件:记录请求日志(开发环境)
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
next();
});
// 3. 根路由:欢迎信息
app.get('/', (req, res) => {
res.json({
message: 'Welcome to the Express Routing Demo!',
endpoints: {
GET: '/api/users (list all users), /api/users/:id (get one user)',
POST: '/api/users (create a new user)',
PUT: '/api/users/:id (update a user)',
DELETE: '/api/users/:id (delete a user)'
}
});
});
// 4. 启动服务器
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
这段代码已经包含了我们前面强调的所有关键点:
express.json()
和
express.urlencoded()
的正确位置、一个简单的
GET
根路由、以及一个通用的日志中间件。运行
node index.js
,然后在浏览器中访问
http://localhost:3000
,你应该能看到欢迎 JSON。
4.2 实现用户数据模型与内存存储
为了演示,我们不引入数据库,而是用一个简单的内存数组来模拟用户数据。在
index.js
的顶部,添加以下代码:
// 模拟内存数据库
let users = [
{ id: 1, name: 'John Doe', email: 'john@example.com', createdAt: new Date() },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', createdAt: new Date() }
];
// 生成下一个 ID 的辅助函数
let nextId = 3;
const generateId = () => nextId++;
现在,我们可以为
/api/users
路径编写具体的 CRUD 路由。我们将它们放在根路由之后、
app.listen()
之前。
4.3 编写 GET 路由:获取用户列表与单个用户
// GET /api/users - 获取所有用户
app.get('/api/users', (req, res) => {
// 可以添加简单的分页和过滤逻辑
const { page = '1', limit = '10' } = req.query;
const pageNum = parseInt(page, 10);
const limitNum = parseInt(limit, 10);
const startIndex = (pageNum - 1) * limitNum;
// 模拟分页
const paginatedUsers = users.slice(startIndex, startIndex + limitNum);
res.json({
data: paginatedUsers,
pagination: {
page: pageNum,
limit: limitNum,
total: users.length,
totalPages: Math.ceil(users.length / limitNum)
}
});
});
// GET /api/users/:id - 获取单个用户
app.get('/api/users/:id', (req, res) => {
const userId = parseInt(req.params.id, 10);
const user = users.find(u => u.id === userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});
这里我们展示了
GET
的两个典型用法:
/api/users
用于列表查询,并支持
page
和
limit
查询参数进行分页;
/api/users/:id
用于详情查询,通过
req.params.id
获取路径参数。注意
404
错误的处理,这是 RESTful API 的基本礼仪。
4.4 编写 POST 路由:创建新用户
// POST /api/users - 创建新用户
app.post('/api/users', (req, res) => {
const { name, email } = req.body;
// 简单的输入验证
if (!name || !email) {
return res.status(400).json({ error: 'Name and email are required' });
}
// 检查邮箱是否已存在(模拟数据库唯一约束)
const existingUser = users.find(u => u.email === email);
if (existingUser) {
return res.status(409).json({ error: 'Email already exists' });
}
// 创建新用户对象
const newUser = {
id: generateId(),
name,
email,
createdAt: new Date()
};
users.push(newUser);
res.status(201).json(newUser); // 201 Created 是 POST 成功的标准响应
});
POST
路由的关键在于:它接收
req.body
,进行业务逻辑处理(验证、查重、创建),然后返回
201 Created
状态码和新创建的资源。
201
状态码明确告诉客户端:“资源已成功创建,响应体中包含了新资源的表示”。
4.5 编写 PUT 路由:更新用户信息
// PUT /api/users/:id - 更新用户(全量更新)
app.put('/api/users/:id', (req, res) => {
const userId = parseInt(req.params.id, 10);
const { name, email } = req.body;
// 查找要更新的用户
const userIndex = users.findIndex(u => u.id === userId);
if (userIndex === -1) {
return res.status(404).json({ error: 'User not found' });
}
// 简单验证
if (!name || !email) {
return res.status(400).json({ error: 'Name and email are required' });
}
// 全量更新:用新数据完全替换旧数据
// 注意:这里我们只更新 name 和 email,其他字段(如 createdAt)保持不变
// 这是业务逻辑的权衡,严格遵循 PUT 语义的话,应该更新所有字段
users[userIndex] = {
...users[userIndex], // 保留原有字段
name,
email
};
res.json(users[userIndex]);
});
这个
PUT
路由体现了“全量更新”的思想。我们找到了用户,然后用
...users[userIndex]
展开原有对象,再用新的
name
和
email
覆盖,确保
id
和
createdAt
等字段不会丢失。这是一种在实践中平衡语义与便利性的常见做法。
4.6 编写 DELETE 路由:删除用户
// DELETE /api/users/:id - 删除用户
app.delete('/api/users/:id', (req, res) => {
const userId = parseInt(req.params.id, 10);
const userIndex = users.findIndex(u => u.id === userId);
if (userIndex === -1) {
return res.status(404).json({ error: 'User not found' });
}
// 从数组中移除用户
const deletedUser = users.splice(userIndex, 1)[0];
res.json({ message: 'User deleted successfully', user: deletedUser });
});
DELETE
路由非常直接:找到用户索引,用
splice
移除,然后返回成功信息。
splice
方法会返回一个包含被删除元素的数组,所以我们用
[0]
来获取那个被删除的用户对象。
4.7 测试所有路由:使用 curl 命令行工具
现在,让我们用
curl
来实际测试这些路由。打开一个新的终端窗口,确保你的服务器正在运行(
node index.js
)。
-
测试 GET 列表 :
curl -X GET http://localhost:3000/api/users -
测试 GET 单个用户 :
curl -X GET http://localhost:3000/api/users/1 -
测试 POST 创建 :
curl -X POST http://localhost:3000/api/users \ -H "Content-Type: application/json" \ -d '{"name":"Bob Johnson","email":"bob@example.com"}' -
测试 PUT 更新 :
curl -X PUT http://localhost:3000/api/users/1 \ -H "Content-Type: application/json" \ -d '{"name":"John Updated","email":"john.updated@example.com"}' -
测试 DELETE 删除 :
curl -X DELETE http://localhost:3000/api/users/2
每执行一条命令,观察终端中 Express 的日志输出,以及
curl
返回的 JSON 响应。你会发现,每一次交互都严格遵循了我们前面讨论的 HTTP 方法语义和 Express 的路由规则。这就是理论落地的力量。
5. 常见问题与排查技巧实录:那些年我们一起踩过的坑
5.1 问题速查表:高频报错与精准定位
| 错误现象 | 可能原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
Cannot GET /xxx
|
1. 路径拼写错误(大小写、斜杠)
2. 路由注册在
app.use()
解析中间件之后
3. 服务器未重启 |
1. 检查
app.get('/xxx')
和
curl http://localhost:3000/xxx
的路径是否完全一致
2. 在
app.get()
前加
console.log('Route registered')
3.
ps aux | grep node
确认进程已更新
|
1. 统一路径风格(推荐全小写、无多余斜杠)
2. 确保
app.use(express.json())
等在所有
app.get()
之前
3.
Ctrl+C
停止,再
node index.js
|
req.body is undefined
|
1. 忘记注册
express.json()
或
express.urlencoded()
2.
Content-Type
请求头不匹配(如发 JSON 却没带
application/json
)
3. 中间件顺序错误 |
1.
console.log(req.headers['content-type'])
2.
console.log(req.rawHeaders)
查看原始请求头
3.
curl -v
命令查看请求头
|
1. 添加
app.use(express.json())
2. 前端 Axios 设置
headers: {'Content-Type': 'application/json'}
3. 将
app.use()
放在所有路由之前
|
404 Not Found
for
PUT
/
DELETE
|
1. 前端框架(如 Vue Router)启用了
history
模式,导致
PUT
请求被重写为
GET
2. Nginx/Apache 反向代理未配置
PUT
/
DELETE
方法转发
|
1. 在浏览器 Network 标签页,检查发出的请求 Method 是否真的是
PUT
2.
curl -X PUT -v http://your-server/api/xxx
直连后端
|
1. 前端禁用
history
模式,或后端配置
connect-history-api-fallback
2. Nginx 配置
location /api/ { proxy_pass http://backend; }
,确保
proxy_pass
透传所有方法
|
502 Bad Gateway
|
1. 后端服务(Express)未启动或崩溃
2. 反向代理(Nginx)配置了错误的
proxy_pass
地址或端口
3. 后端服务启动慢,代理超时 |
1.
curl http://localhost:3000
直连后端
2.
netstat -tuln | grep :3000
检查端口监听
3. 查看 Nginx error log (
/var/log/nginx/error.log
)
|
1.
node index.js
启动服务
2. 检查
proxy_pass http://127.0.0.1:3000;
是否正确
3. Nginx 中增加
proxy_read_timeout 300;
|
5.2 “418 I'm a teapot”:一个关于 HTTP 状态码的冷知识
网络热词中出现了
http error 418
,这其实是一个著名的 HTTP 状态码彩蛋。它源于 1998 年的愚人节 RFC 2324《超文本咖啡壶控制协议》(HTCPCP),用来表示“我是一个茶壶,无法煮咖啡”。在 Express 中,你当然可以手动返回它:
app.get('/teapot', (req, res) => {
res.status(418).send("I'm a teapot");
});
虽然它没有实际业务价值,但它完美诠释了 HTTP 状态码的设计哲学:
状态码是服务器向客户端传达“发生了什么”的标准化语言
。
200 OK
表示成功,
400 Bad Request
表示客户端请求有误,
500 Internal Server Error
表示服务器内部出错。学会正确使用状态码,是写出专业 API 的第一步。不要总是用
200
加一个
{ success: false }
的 JSON,这会让前端开发者无所适从。
5.3 “Can't set headers after they are sent”:Node.js 事件循环的幽灵
这是 Express 开发者几乎必遇的报错。它的根源在于 Node.js 的异步非阻塞 I/O 模型。想象一下这个错误代码:
app.get('/data', (req, res) => {
someAsyncOperation()
.then(data => {
res.json(data); // 第一次发送响应
});
res.send('Done'); // 第二次尝试发送响应,此时 headers 已经被上面的 res.json() 发送了!
});
res.send()
和
res.json()
都是“终结性”操作,它们会向客户端发送 HTTP 响应头和响应体,并关闭连接。一旦调用,就不能再调用任何
res.xxx()
方法。这个错误往往隐藏得很深,比如在
if/else
分支中,一个分支写了
res.send()
,另一个分支忘了写,或者在
try/catch
中,
catch
块里没有
res.status(500).send()
。排查技巧是:
在每个路由函数的末尾,检查是否所有代码路径都以
res.send()
、
res.json()
、
res.status().send()
或
next()
结束
。使用 ESLint 插件
eslint-plugin-express
可以在编码阶段就发现这类问题。
5.4 关于 CORS:为什么前端 AJAX 会失败,而 Postman 却可以?
这是一个经典的跨域(Cross-Origin Resource Sharing)问题。当你在
http://localhost:8080
的 Vue 应用中,用
axios.get('http://localhost:3000/api/users')
发起请求时,浏览器会先发送一个
OPTIONS
预检请求(Preflight Request),询问服务器:“我接下来要发一个
GET
请求,带
Authorization
头,你允许吗?” 如果 Express 服务器没有正确响应这个
OPTIONS
请求,浏览器就会阻止后续的
GET
请求,并在控制台报错
CORS policy: No 'Access-Control-Allow-Origin' header is present
。而 Postman 不是浏览器,它不遵守同源策略,所以直接发
GET
就能成功。
解决方案是使用
cors
中间件:
npm install cors
const cors = require('cors');
// 在所有路由之前
app.use(cors()); // 允许所有源
// 或者更安全的配置
app.use(cors({
origin: ['http://localhost:8080', 'https://myapp.com'],
credentials: true // 如果需要携带 cookie
}));
cors
中间件会自动处理
OPTIONS
预检请求,并在所有响应头中添加
Access-Control-Allow-Origin
等必要字段。
5.5 最后一个经验:用
app.all()
做统一的路由前哨
在大型项目中,你可能需要为一组路由做统一的权限检查、日志记录或数据预加载。
app.all()
是一个强大的工具,它会匹配所有 HTTP 方法(
GET
,
POST
,
PUT
,
DELETE
...)的请求。你可以把它当作一个“路由前哨站”。
// 为所有 /api/users/* 路径的请求,进行用户认证
app.all('/api/users

914

被折叠的 条评论
为什么被折叠?



