Express框架的核心是中间件,而中间件的执行顺序决定了请求的整个生命周期。很多开发者遇到404、认证失败、响应头设置无效等问题,根源都在于中间件顺序排列错误。中间件本质上是按代码声明顺序依次执行的函数链,每个中间件通过调用next()将控制权交给下一个中间件。如果不调用next(),请求就会挂起;如果在响应发送后还调用next(),就会触发“Cannot set headers after they are sent”错误。理解这条执行链的机制,是构建稳定Express应用的第一课。
中间件执行顺序的核心机制Express中间件栈采用“先进先出”的线性执行模型。当你使用app.use()或app.METHOD()注册中间件时,它们被依次推入一个执行队列。请求到达时,Express从第一个中间件开始执行,每个中间件必须显式调用next()才能传递到下一个。这个机制看似简单,但实际项目中中间件数量一多,顺序问题就变得极其隐蔽。比如,某个中间件在异步操作完成前就调用了next(),导致后续中间件拿不到预期的数据;又或者某个中间件忘记调用next(),整个请求链直接中断,客户端一直等待超时。
全局中间件应该放在所有路由之前注册,这是最基本的原则。像express.json()、express.urlencoded()这类解析请求体的中间件,必须早于任何需要访问req.body的路由。cookie-parser必须早于任何需要读取cookie的逻辑。CORS中间件通常也要靠前放置,因为预检请求(OPTIONS)需要在其他中间件处理之前就得到响应。如果你把CORS放在路由之后,浏览器发起的预检请求根本走不到CORS中间件,跨域请求就会失败。
一个典型的正确顺序示例:
const express = require('express');
const app = express();
// 1. 请求日志 - 最外层,记录所有请求
app.use((req, res, next) => {
console.log(`${req.method} ${req.url}`);
next();
});
// 2. CORS - 处理跨域,必须在路由前
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
return res.sendStatus(200);
}
next();
});
// 3. 请求体解析 - 后续路由需要用到req.body
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 4. Cookie解析 - 后续认证需要
app.use(require('cookie-parser')());
// 5. 静态文件 - 匹配到文件就直接响应,不往后走
app.use(express.static('public'));
// 6. 认证中间件 - 保护特定路由
app.use('/api', (req, res, next) => {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: '未授权' });
}
// 验证token逻辑...
req.user = { id: 1, role: 'admin' };
next();
});
// 7. 业务路由
app.get('/api/users', (req, res) => {
res.json({ users: [] });
});
// 8. 404处理 - 所有路由都没匹配到
app.use((req, res) => {
res.status(404).json({ error: '接口不存在' });
});
// 9. 全局错误处理 - 必须放在最后,且有四个参数
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: '服务器内部错误' });
});
路由级中间件的顺序陷阱
很多开发者习惯把路由处理函数直接写在app.get()里,但实际项目往往需要为特定路由组添加中间件。Express允许在路由路径上挂载中间件链,这里的顺序同样关键。比如,一个用户模块可能需要先验证登录态,再检查权限,最后才执行控制器逻辑。如果你把权限检查放在控制器之后,那权限校验就形同虚设。
更隐蔽的问题是参数处理中间件。Express的app.param()用于处理路由参数,它会在匹配到参数时触发,但触发时机是在路由处理函数之前。如果你在app.param()里做了数据库查询并把结果挂在req对象上,那么后续中间件和控制器就能直接使用。但如果你在同一个路由上混用了多个app.param(),它们的执行顺序是按照参数在路由路径中出现的顺序,而不是你声明app.param()的顺序。这个细节很少有人注意,却可能导致依赖关系错乱。
异步中间件的错误传递Express 4.x默认不处理Promise的rejection。如果你在async函数中抛出异常,或者Promise被reject了但没有catch,Express不会自动捕获,请求会一直挂起直到超时。这是目前最常见的中间件错误处理缺陷。解决方法是在async中间件中显式使用try-catch,并将错误传递给next():
app.get('/api/data', async (req, res, next) => {
try {
const data = await fetchDataFromDB();
res.json(data);
} catch (err) {
next(err); // 关键:把错误传给Express错误处理中间件
}
});
更优雅的做法是封装一个高阶函数,自动捕获async中间件的错误。这个模式在Express社区已经非常流行:
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// 使用后代码简洁很多
app.get('/api/data', asyncHandler(async (req, res) => {
const data = await fetchDataFromDB();
res.json(data);
}));
这个asyncHandler包装函数会捕获所有被reject的Promise,并自动将错误传给next()。它解决了Express 4.x对async/await支持不完善的问题。值得注意的是,Express 5.x已经内置了对Promise rejection的自动捕获,但目前大多数生产环境仍在使用4.x版本,所以这个技巧依然非常实用。
错误处理中间件的四个参数约定Express通过参数个数来区分普通中间件和错误处理中间件。错误处理中间件必须有四个参数:(err, req, res, next)。即使你不用next参数,也必须声明它,否则Express会把它当作普通中间件处理。这个设计虽然有些怪异,但它是Express内部判断逻辑的基础。
错误处理中间件的放置位置有严格要求:必须放在所有路由和普通中间件之后。因为Express沿着中间件链查找,只有遇到四参数中间件才会进入错误处理流程。如果你把错误处理中间件放在路由前面,普通请求根本不会经过它;如果放在中间位置,它后面的中间件抛出的错误就捕获不到。
多个错误处理中间件可以串联,每个都可以选择处理错误或传递给下一个。这在需要分层处理不同类型错误时非常有用:
// 处理验证错误
app.use((err, req, res, next) => {
if (err.name === 'ValidationError') {
return res.status(400).json({ error: err.message });
}
next(err); // 不是验证错误,交给下一个错误处理器
});
// 处理数据库错误
app.use((err, req, res, next) => {
if (err.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: '数据已存在' });
}
next(err);
});
// 兜底错误处理
app.use((err, req, res, next) => {
console.error('未捕获的错误:', err);
res.status(500).json({ error: '服务器内部错误' });
});
响应发送后的错误处理
“Cannot set headers after they are sent”这个错误几乎每个Express开发者都遇到过。它发生在响应已经发送给客户端之后,代码又尝试修改响应头或再次发送响应。常见场景包括:在异步回调中多次调用res.send(),或者在已经res.json()之后又调用了next()进入下一个中间件,下一个中间件又尝试发送响应。
避免这个问题的核心原则是:在调用任何res.send()、res.json()、res.end()之后,必须用return语句终止函数执行,防止代码继续走到next()。注意,res.send()等方法本身不终止函数,JavaScript会继续执行后面的语句:
// 错误写法
app.get('/user/:id', async (req, res, next) => {
if (req.params.id === 'admin') {
res.json({ name: '管理员' }); // 响应已发送
}
// 但代码继续执行,又进入了数据库查询
const user = await db.findUser(req.params.id);
res.json(user); // 这里会抛出"Cannot set headers"错误
});
// 正确写法
app.get('/user/:id', async (req, res, next) => {
if (req.params.id === 'admin') {
return res.json({ name: '管理员' }); // return终止后续执行
}
const user = await db.findUser(req.params.id);
return res.json(user);
});
中间件条件执行的策略
不是所有中间件都需要对每个请求生效。Express提供了多种条件执行中间件的方式。最直接的是在app.use()中指定路径前缀,中间件只对匹配的路径生效。更灵活的方式是在中间件函数内部根据条件决定是否调用next():
// 只在开发环境启用请求日志
app.use((req, res, next) => {
if (process.env.NODE_ENV === 'development') {
console.log(`${req.method} ${req.url}`);
}
next(); // 无论是否打印日志,都要继续执行
});
// 根据请求头决定是否应用限流
app.use('/api', (req, res, next) => {
if (req.headers['x-skip-rate-limit']) {
return next(); // 跳过限流
}
rateLimiter(req, res, next);
});
还有一种高级用法是动态组合中间件链。你可以根据运行时条件,返回不同的中间件数组,然后用展开运算符传入路由:
const authMiddleware = [verifyToken, checkRole('admin')];
const publicMiddleware = [];
app.get('/api/admin/data', ...authMiddleware, (req, res) => {
res.json({ secret: '管理员数据' });
});
app.get('/api/public/data', ...publicMiddleware, (req, res) => {
res.json({ info: '公开数据' });
});
生产环境的中间件架构建议
经过大量项目实践验证,一个健壮的Express中间件架构应该遵循以下分层顺序:第一层是基础设施中间件,包括请求日志、压缩、安全头设置(如helmet)、CORS;第二层是请求解析中间件,包括body parser、cookie parser;第三层是静态资源和缓存中间件;第四层是认证和授权中间件;第五层是业务路由;第六层是404处理;最后一层是全局错误处理。
安全相关的中间件尤其需要注意顺序。比如,helmet会设置一系列安全相关的HTTP头,它应该在最外层,确保所有响应都带上这些头。速率限制中间件通常放在认证之前,因为恶意请求可能在认证之前就耗尽服务器资源。请求体大小限制应该放在body parser之前或作为其配置项,防止恶意大请求体攻击。
对于微服务架构中的Express应用,还需要考虑链路追踪中间件的放置。它应该尽可能靠前,以便记录完整的请求耗时;同时要在错误处理中间件之前,确保错误响应也能被追踪到。分布式追踪ID通常从请求头中提取或生成,挂在req对象上,后续所有日志和调用都带上这个ID,这是排查分布式系统问题的关键手段。
最终,中间件顺序没有一成不变的模板,但有一条黄金法则:从通用到专用,从全局到局部,从正常流程到异常处理。每次添加新中间件时,问自己三个问题:它需要处理哪些请求?它依赖哪些前置处理?它的输出被哪些后续中间件使用?回答清楚这三个问题,顺序自然就清晰了。
