Express 用一种特殊的中间件处理错误:它接收四个参数 (err, req, res, next),并且注册在所有路由之后。路由中抛出的每个错误、你传给 next(err) 的每个错误,以及在 Express 5 中 async 处理函数里每个被拒绝的 Promise,Express 都会交给它。由这个处理器决定状态码并发送一个错误响应,其余代码只管抛出错误就行。
GET /users/99 返回 404 和 {"error":"No user with id 99"},控制台里会出现错误处理器打印的那一行。发送 GET /users/1,路由会正常响应;只有出问题时才会运行错误处理器。
Express 帮你捕获的错误
从路由进入错误处理器有三种方式,在 Express 5 中三者的效果完全一样:
GET /async 返回 500 和 {"error":"Rejected in an async route"};GET /sync 和 GET /next 也会进入同一个处理器,各自带着自己的消息。现在把错误处理器参数里的 next 删掉,再发送一次 GET /sync:只有三个参数时,Express 会把它当作普通中间件而跳过,转而返回默认的 HTML 错误页面。
异步错误:Express 5 与 Express 4
/async 路由能正常工作,是因为 Express 5 会捕获被拒绝的 Promise。Express 4 不会:这个错误会变成未处理的 rejection,请求一直挂起。
| Express 4 | Express 5 | |
|---|---|---|
同步处理函数中的 throw | 能捕获 | 能捕获 |
next(err) | 能捕获 | 能捕获 |
async 处理函数中被拒绝的 Promise | 不能捕获,请求挂起 | 能捕获 |
setTimeout 或事件回调中的错误 | 不能捕获,进程崩溃 | 不能捕获,进程崩溃 |
在 Express 4 中,要给每个异步处理函数包一层,或者安装 express-async-errors 包:
// Express 4 only
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await db.findUser(req.params.id);
res.json(user);
}));
最后一行在两个版本中都一样:Express 看不到在一个不由它调用的回调里抛出的错误。把回调式 API 换成 Promise 版本(Node 的大部分功能都有 Promise 版本,比如 fs/promises),然后用 await 调用。
404 处理器和统一的 JSON 错误
当每个错误的结构都一样时,API 会更好用。给错误加上 status,把没有匹配到的路由变成 404 错误,然后让一个处理器统一格式化所有错误:
GET /nope 没有匹配到任何路由,所以 404 处理器把它变成一个错误,响应是 {"error":"Cannot GET /nope","status":404}。发送 GET /users/99,路由抛出的错误也会得到同样的结构。发送 POST /users,请求体为 {"name": }:express.json() 解析失败,会带着一个 status 为 400 的错误调用 next(),所以响应是 {"error":"Unexpected token '}', \"{\"name\": }\" is not valid JSON","status":400}。
不要泄露堆栈信息
你自己的 HttpError 中的错误消息是写给客户端看的。其他来源(数据库驱动、一个抛出 TypeError 的拼写错误)的消息和堆栈可能会暴露文件路径、查询语句和库的版本。上面的处理器对每个 5xx 都只发送 Internal server error,而把真正的错误记录在服务器上。if (res.headersSent) return next(err); 处理的是响应已经发出一部分之后才发生的错误:这时唯一安全的做法就是让 Express 关闭连接。
Express 自带的默认处理器会参考 NODE_ENV:除非 NODE_ENV 是 production,否则它的 HTML 页面里会包含堆栈信息。在生产服务器上设置它(NODE_ENV=production node server.js),但不要依赖它:一个永远不发送 err.stack 的自定义处理器更安全。
常见错误
在路由之前注册错误处理器。 Express 只会把错误往后传,所以位于某个路由之前的处理器永远看不到这个路由的错误。把它放在最后,放在 404 处理器之后。
捕获了错误却什么都不做。 只记录日志的 try/catch 会让请求一直挂起。在 catch 中调用 next(err)、重新抛出,或者发送一个响应。
发送两次响应。 在 res.json() 之后再抛出错误,会让错误处理器尝试再发送一次。像示例中那样检查 res.headersSent。
想了解普通中间件和 next() 的工作方式,请看 Express 中间件。
常见问题
Express 中可以有多个错误处理器吗?
可以。它们按顺序运行,每个处理器要么发送响应,要么调用 next(err) 把错误继续往下传。常见的分工是一个处理器负责记录日志,另一个负责发送响应。
如果错误处理器本身抛出错误会怎样?
Express 会把这个新错误交给下一个错误处理器;如果没有,就交给它内置的处理器,返回一个 500 页面。让错误处理器保持简单,这样它自己就不会出错。
错误响应应该用哪个状态码?
客户端发来的内容有问题时用 4xx(400 输入有误,401 未登录,403 无权限,404 未找到,409 冲突),你的代码出错时用 500。永远不要返回 200 却在响应体里放一个错误。
如何同时返回多个字段的验证错误?
返回 400 和一个列表,例如 { "errors": [{ "field": "email", "message": "is required" }] }。如果用 zod,在错误处理器里把 result.error.issues 映射成这个结构,这样每个路由报告错误输入的方式都一致。