Skip to content
概述
Express 会自动捕获路由处理函数中同步抛出的错误,这是框架的默认行为。但 async 函数内部抛出的异常如果未能正确传给 Express,请求就会一直挂起或超时,客户端收不到任何响应。以下从同步错误捕获的默认行为开始,说明错误处理中间件的四参数签名与注册位置,再讨论 next(err) 的控制流、异步路由的陷阱以及 asyncHandler 的实现,最后把 404 兜底和统一错误响应串成一条完整的处理链路。
文中的行为描述与示例均基于 Express 5.x,引用的文档也来自 Express 5.x 错误处理指南。这些行为在 Express 4.x 中基本一致,但在 4.x 下运行异步部分时,行为同样取决于是否手动传递错误,后文会详细说明。
同步错误的默认捕获
如果路由处理函数同步地抛出一个错误,Express 会直接捕获它,然后交给内置的默认错误处理器。
javascript
const express = require('express');
const app = express();
app.get('/sync-error', (req, res) => {
throw new Error('BROKEN');
});
app.listen(3000);访问 /sync-error 时,Express 在内部捕获这个错误,并向客户端返回状态码 500 的响应。在开发环境(NODE_ENV 未设置或为 development)下,响应体还会带上完整的错误堆栈。整个过程对开发者透明——不需要额外的 try/catch,错误也不会导致进程退出。
默认行为只覆盖同步代码中的异常。调用栈完全发生在请求‑响应循环内部,Express 可以在 try/catch 包裹的边界截获错误。一旦错误进入回调、Promise 或 setTimeout,这个边界就被打破了。
错误处理中间件
四参数签名
自定义错误处理逻辑通过错误处理中间件实现。它与普通中间件只有两点区别:第一,形参必须有四个;第二,注册位置要放在所有路由和其他中间件之后。
javascript
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).send('Something broke!');
});Express 执行时会检测中间件函数的形参个数。如果函数定义恰好接受四个参数,它就注册为错误处理中间件;三个或更少,就是普通中间件。即使把普通中间件写在最底部,也永远不会接收到错误。
注册位置
错误处理中间件只能捕获它之前的路由和中间件里产生的错误。通常的做法是在所有 app.use、app.get、app.post 等路由定义之后,再把错误处理中间件挂上去。如果把它写在路由前面,那些路由里的错误就收不到了。
next(err) 的错误传播
整个错误传播体系由 next() 驱动。调用 next() 时如果传入一个值,例如 next(err),这条信息就会被 Express 认定为“错误”。此时 Express 会跳过当前请求‑响应循环中剩余的所有非错误中间件和路由,直奔下一个错误处理中间件。
javascript
app.use((req, res, next) => {
console.log('1. 普通中间件');
next();
});
app.get('/trigger', (req, res, next) => {
console.log('2. 路由处理');
next(new Error('手动传递错误'));
});
app.use((req, res, next) => {
console.log('3. 这个普通中间件会被跳过');
next();
});
// 错误处理中间件
app.use((err, req, res, next) => {
console.log('4. 错误处理中间件收到:', err.message);
res.status(500).json({ error: err.message });
});请求 /trigger 时,控制台输出如下:
1. 普通中间件
2. 路由处理
4. 错误处理中间件收到: 手动传递错误标记 3 的中间件完全没有执行。即使后续还有其他普通中间件,也一并跳过。next(err) 不是抛出异常,而是直接修改了 Express 内部的路由分发队列,让错误处理中间件插队。
如果错误处理中间件内部没有调用 res.send() 等方法结束响应,Express 会继续把错误往后传。如果后面没有其他错误处理中间件,最终会落到内置的默认错误处理器。
异步路由的错误处理
异步错误被忽略的原因
异步操作一旦出现,前面“同步错误自动捕获”的便利就失效了。以下面这个路由为例:
javascript
app.get('/async-error', async (req, res) => {
throw new Error('ASYNC BROKEN');
});发出这个请求后,客户端大概率什么也收不到——连接会一直挂着直到超时。因为 async 函数返回一个 Promise,函数体里的 throw 被 JavaScript 自动转换成 Promise.reject()。Express 在调用这个路由处理函数时并没有对返回值执行 .catch(),于是 rejected promise 就成了未被处理的 promise rejection,Express 完全感知不到。
在较新的 Node.js 版本里,未被捕获的 promise rejection 会触发 unhandledRejection 事件,但这已经脱离了 Express 的请求‑响应循环,无法向客户端返回有意义的响应。服务器进程本身也可能因该事件而退出(取决于 Node.js 版本和配置)。
手动处理异步错误
要在路由内部显式 try/catch,然后手动 next(err):
javascript
app.get('/async-error', async (req, res, next) => {
try {
throw new Error('ASYNC BROKEN');
} catch (err) {
next(err);
}
});这种方式有效,但会给每个异步路由增加一层重复的 try/catch。另一种办法是直接在返回的 Promise 上挂 .catch(next),不过写在每个路由里同样繁琐。更方便的方法是使用通用的包装函数。
asyncHandler 包装函数
asyncHandler 是一个高阶函数,把异步路由处理函数包裹一层,让内部抛出的错误自动通过 next(err) 传递给 Express 错误处理中间件。它不改变路由逻辑,只负责接住 rejected promise 并转交出去。
asyncHandler 的实现
async 函数被调用时会返回 Promise。如果 Promise 状态变成 rejected,只需在它上面调用 .catch(next),就能让 Express 收到错误。基于这一点,asyncHandler 可以写成:
javascript
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}用 Promise.resolve() 包裹调用结果,是为了兼容那些可能不返回 Promise 的异步函数(使用 async 的函数必定返回 Promise,但也可能传入虽未声明 async 但实际返回 Promise 的函数)。不论 fn 返回什么,包装后的函数都会把值放到 Promise 链上,再用 .catch(next) 捕获任何 rejected 状态。
在 TypeScript 中可以补上类型约束,让参数和返回值保持清晰(运行时行为完全一样):
typescript
import { Request, Response, NextFunction } from 'express';
function asyncHandler(
fn: (req: Request, res: Response, next: NextFunction) => Promise<any>
) {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}asyncHandler 的使用
使用时,只需把路由处理函数用 asyncHandler 包一下,其余什么都不用改:
javascript
app.get('/safe-async', asyncHandler(async (req, res) => {
// 如果这里抛出异常,会被自动传递给 next(err)
const data = await someRiskOperation();
res.json(data);
}));asyncHandler 也可以和 Router 一起使用,不影响路由模块化:
javascript
const router = require('express').Router();
router.get('/item/:id', asyncHandler(async (req, res) => {
const item = await db.find(req.params.id);
if (!item) throw new Error('Not found');
res.json(item);
}));
app.use('/api', router);与每个路由里手写 try/catch 相比,asyncHandler 将错误捕获收归到一处,路由代码只关心业务逻辑,对后续增加统一的错误处理中间件也有意义。
404 兜底中间件
即使前面所有路由和中间件都正确运行,客户端仍可能请求一个不存在的路径。Express 默认不会为匹配不到的路由返回结构化 404,只会返回类似 Cannot GET /xxx 的简单响应。需要自定义兜底中间件来生成结构化错误。
兜底中间件应放在所有路由定义之后、错误处理中间件之前。它是一个普通的三参数中间件,作用是为“走到这里还没匹配上任何路由”的请求创建一个 404 错误,并传给错误处理中间件。
javascript
app.use((req, res, next) => {
const err = new Error(`Not Found: ${req.originalUrl}`);
err.status = 404;
next(err);
});这里给 Error 对象添加 status 属性,后续的统一错误处理中间件可以读取该属性来设置响应的状态码。如果没有专门设定,错误处理中间件通常会 fallback 到 500。
这个 404 兜底本身不发送响应,只负责生成错误并向后传,把构建和发送响应的责任完全交给错误处理中间件。
统一错误响应格式
自定义错误处理中间件应该有一个明确的职责:把所有传入的错误都转换成一致的 JSON 响应格式,并区分开发环境和实际使用来决定是否暴露堆栈信息。
javascript
app.use((err, req, res, next) => {
const status = err.status || 500;
const message = err.message || 'Internal Server Error';
const response = {
status,
message,
};
// 开发环境才暴露堆栈
if (process.env.NODE_ENV === 'development') {
response.stack = err.stack;
}
res.status(status).json(response);
});当错误到达这个中间件时,它会从 err 上读取 status(如果之前 404 兜底中间件或某个路由设置了就用,否则默认 500),然后一并返回 message。开发环境中,stack 字段会原样输出,方便追踪调用来源;在实际使用中,这个字段完全不出现。
这里用 process.env.NODE_ENV 来判断环境。Express 内置的默认错误处理器也用同样的方式控制堆栈暴露行为,因此自定义中间件可以与之保持一致。
整合示例
下面把前面各部分拼成一个完整但精简的 Express 应用,覆盖三种进入错误处理中间件的路径——同步异常、异步异常(经 asyncHandler)以及手动调用 next(err)。
javascript
const express = require('express');
const app = express();
// ---- asyncHandler 工具 ----
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
// ---- 路由 ----
// 1. 同步错误
app.get('/sync', (req, res) => {
throw new Error('同步错误');
});
// 2. 异步错误(通过 asyncHandler 接住的)
app.get('/async', asyncHandler(async (req, res) => {
await new Promise((_, reject) => setTimeout(() => reject(new Error('异步错误')), 100));
}));
// 3. 手动 next(err)
app.get('/manual', (req, res, next) => {
const err = new Error('手动传递的错误');
err.status = 400;
next(err);
});
// 正常路由
app.get('/ok', (req, res) => {
res.json({ ok: true });
});
// ---- 404 兜底 ----
app.use((req, res, next) => {
const err = new Error(`Not Found: ${req.originalUrl}`);
err.status = 404;
next(err);
});
// ---- 统一错误处理 ----
app.use((err, req, res, next) => {
const status = err.status || 500;
const message = err.message || 'Internal Server Error';
const payload = { status, message };
if (process.env.NODE_ENV === 'development') {
payload.stack = err.stack;
}
res.status(status).json(payload);
});
app.listen(3000);在开发环境中运行:
GET /sync返回 500,包含堆栈。GET /async返回 500,也包含堆栈。GET /manual返回 400,message是“手动传递的错误”,因为显式设置了err.status。GET /ok正常返回{"ok":true}。GET /nothing进入 404 兜底,返回 404 和相应的 message。
三种错误路径最终都由同一个错误处理中间件处理,不会分散到各处。
错误处理中间件的顺序与边界
注册顺序的约束可以概括为:无论想让错误处理中间件捕获哪些路由,它都必须写在那些路由的后面。实际项目里往往会把错误处理中间件单独抽成一个模块(如 errorHandler.js),然后在入口文件最后一行 app.use 引入,保证加载时机在所有路由挂载之后。
如果同一个应用里定义了多个错误处理中间件,Express 会按照它们的注册顺序依次执行,就像普通中间件一样——当前一个错误处理中间件调用了 next(err)(或者没有结束响应),错误就会继续往后传。最后的兜底永远是 Express 内置的默认错误处理器:如果不调用 res.send() 之类的方法结束请求,它就会接管,返回一个纯文本的错误信息。
还需要注意,错误处理中间件自身的代码也可能出错。比如在自定义错误处理器中又 throw 了一个错误,Express 内部会把这个异常也交给它之后的错误处理中间件(如果有的话),或者默认处理器。为了避免这种情况,错误处理中间件内部通常不写可能抛出同步异常的代码,或者在必要时自己在内部 try/catch,然后重新构造一个错误通过 next(err) 传出。
