Express 5 中间件与路由基础:原理、next()机制与执行顺序
换个角度看,理解 Express 的核心,就是理解中间件(Middleware)和路由(Routing)是怎么配合工作的。Express 5(也就是 2024 年 10 月 16 日刚发布的那个 5.0.0 正式版)依然延续了经典的“中间件层叠”模型。你可以把整个请求处理过程想象成一个流水线或者洋葱模型,请求进来,经过一层层的中间件处理,最后给出响应。
中间件到底是什么?
在 Express 里,中间件本质上就是一个函数。这个函数能拿到三个核心对象:
req (Request):请求对象,里面装着 URL 参数、POST 提交的数据、Header 头等等。
res (Response):响应对象,用来给客户端回数据,比如 res.json() 或者 res.send()。
next:这是一个函数。这是整个中间件机制的核心,调用 next() 的意思就是:“哥们儿,我这边处理完了,把接力棒传给下一个中间件吧”。
如果你不调用 next(),也不调用 res.send() 之类的结束响应方法,那这个请求就会卡在这儿,客户端就会一直转圈圈,直到超时。核心要点:这是新手最容易踩的坑之一,忘记写 next()。
执行顺序就是一切
Express 的执行顺序非常“耿直”,就是代码书写的顺序。谁写在前面,谁先执行。
来看个最基础的例子,感受一下这个“流水线”:
const express = require('express');
const app = express();
// 中间件 1:记录请求时间
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] 收到请求:${req.method} ${req.url}`);
next(); // 放行,交给下一个
});
// 中间件 2:检查是否有自定义 Header
app.use((req, res, next) => {
if (req.headers['x-custom-header']) {
console.log('检测到自定义 Header');
}
next();
});
// 路由处理
app.get('/', (req, res) => {
res.send('Hello Express 5!');
// 这里不需要 next 了,因为响应已经结束了
});
// 启动服务
app.listen(3000, () => {
console.log('服务运行在 http://localhost:3000');
});
运行这段代码,当你访问首页时,控制台会先打印时间日志,然后检查 Header,最后才返回 'Hello Express 5!'。如果你把第一个中间件的 next() 删掉,你会发现页面永远加载不出来,因为请求没传到路由那边去。
路由也是一种特殊的中间件
很多新手分不清路由和中间件的区别。其实,路由(app.get, app.post 等)在底层也是一种中间件,只不过它多了两个限制条件:HTTP 方法和路径。
app.use() 是通用的,只要请求进来,不管什么路径什么方法,只要匹配到路径前缀(如果不写路径,默认是 /),都会执行。
app.get() 或者 app.post() 是特定的,必须路径匹配,且 HTTP 方法(GET/POST)也要对得上才执行。
⚡ 效率提示:中间件的摆放位置
千万别把处理耗时逻辑的通用中间件放在路由后面。因为 Express 是顺序执行的,如果路由先匹配上了并结束了响应,后面的中间件根本没机会跑。通常的建议是:
- 先放解析请求体的中间件(如
express.json())。
- 再放日志、鉴权等通用中间件。
- 最后放具体的业务路由。
- 最底下放 404 捕获和全局错误处理。
---
从零构建RESTful API:路由定义、HTTP方法与路径参数实战
现在我们来点实战的。既然 Express 是做后端接口的一把好手,那我们就用 Express 5 来撸一个简易的“图书管理” RESTful API。RESTful 风格换个角度看,用 URL 定位资源,用 HTTP 动词(GET, POST, PUT, DELETE)描述操作。
定义路由与处理 HTTP 方法
Express 5 的路由定义和 4.x 基本一样,非常直观。我们直接上代码,创建一个能增删改查图书的接口:
const express = require('express');
const app = express();
// 必须加这个中间件,否则 Express 5 也读不到 req.body 里的 JSON 数据
app.use(express.json());
// 模拟一个内存数据库
let books = [
{ id: 1, title: 'JavaScript高级程序设计', author: 'Nicholas C. Zakas' },
{ id: 2, title: 'Node.js实战', author: 'Mike Cantelon' }
];
// 1. 获取所有图书 (GET)
app.get('/api/books', (req, res) => {
res.json(books);
});
// 2. 获取单本图书 (GET + 路径参数)
// 这里的 :id 就是路径参数
app.get('/api/books/:id', (req, res) => {
// Express 5 依然支持 req.params,这里要注意 id 是字符串,要转成数字
const bookId = parseInt(req.params.id);
const book = books.find(b => b.id === bookId);
if (!book) {
return res.status(404).json({ message: '图书没找到' });
}
res.json(book);
});
// 3. 添加新图书 (POST)
app.post('/api/books', (req, res) => {
const newBook = {
id: books.length > 0 ? Math.max(...books.map(b => b.id)) + 1 : 1,
title: req.body.title,
author: req.body.author
};
books.push(newBook);
// 201 状态码表示创建成功
res.status(201).json(newBook);
});
// 4. 更新图书 (PUT)
app.put('/api/books/:id', (req, res) => {
const bookId = parseInt(req.params.id);
const bookIndex = books.findIndex(b => b.id === bookId);
if (bookIndex === -1) {
return res.status(404).json({ message: '要更新的图书不存在' });
}
// 更新数据
books[bookIndex] = { id: bookId, ...req.body };
res.json(books[bookIndex]);
});
// 5. 删除图书 (DELETE)
app.delete('/api/books/:id', (req, res) => {
const bookId = parseInt(req.params.id);
const initialLength = books.length;
books = books.filter(b => b.id !== bookId);
if (books.length === initialLength) {
return res.status(404).json({ message: '要删除的图书不存在' });
}
res.status(204).send(); // 204 No Content 通常代表删除成功且无返回体
});
app.listen(3000, () => {
console.log('RESTful API 服务启动在 3000 端口');
});
路径参数与查询参数的区别
很多新手搞混 req.params 和 req.query。
- 路径参数 (Params):通常是必填的,用来定位具体资源。比如
/api/books/1,那个 1 就是 Params。
- 查询参数 (Query):通常是可选的,用来做筛选、分页。比如
/api/books?page=1&limit=10,那个 ? 后面的就是 Query。
在 Express 5 里,获取方式依然是 req.params.id 和 req.query.page。
📖 学习建议:路由模块化
如果你把所有路由都写在 app.js 里,那代码很快就会变成一团浆糊。Express 提供了 Router 类来帮我们拆分路由。
建议:按业务模块拆分文件。比如创建 routes/books.js:
// routes/books.js
const express = require('express');
const router = express.Router();
// 这里的路径是相对路径,不需要写 /api/books 了
router.get('/', (req, res) => {
res.send('这是书籍列表');
});
module.exports = router;
// 然后在主文件中引入
// const bookRouter = require('./routes/books');
// app.use('/api/books', bookRouter);
这样维护起来就清爽多了,这也是目前社区里最主流的写法。
---
Express 5 新特性实战:异步中间件与Promise错误原生捕获
Express 5 最大的亮点之一就是对异步中间件的支持更加友好了。如果你是从 Express 4 升上来的,或者之前被 async/await 里的错误坑过,这一节一定要看仔细。
Express 4 的痛点
在 Express 4.x 里,如果你在中间件里用了 async 函数,但是忘了 try...catch,或者 Promise 抛错了,Express 是捕获不到这个错误的。它不会自动把你抛出的错误传给那个 (err, req, res, next) => {} 的错误处理中间件。
通常你不得不写一个像 wrap 或者 asyncHandler 这样的工具函数来包装一下,否则服务器直接崩给你看。
// Express 4 时代痛苦的写法
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
Express 5 的救赎
Express 5.0.0 正式版(2024年10月发布)终于把这个痛点解决了。现在,如果你的中间件是 async 函数,或者返回了一个 Promise,一旦这个 Promise 被 reject 了,Express 5 会自动捕获这个错误并传给错误处理中间件。
来看个对比示例:
const express = require('express');
const app = express();
// 模拟一个异步操作,比如读数据库
function fetchUserData(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (userId === '123') {
resolve({ id: '123', name: '张三' });
} else {
// 模拟报错
reject(new Error('数据库查询失败:用户不存在'));
}
}, 100);
});
}
// 路由:使用 async/await
app.get('/users/:id', async (req, res, next) => {
// 在 Express 5 中,这里如果 reject 了,不需要 try/catch
// Express 5 会自动把这个 reject 传递给下面的错误处理中间件
const user = await fetchUserData(req.params.id);
res.json(user);
});
// 路由:返回 Promise(不写 async 也可以)
app.get('/users-promise/:id', (req, res, next) => {
return fetchUserData(req.params.id) // 注意这里 return 了 Promise
.then(user => res.json(user));
// 如果 fetchUserData 挂了,Express 5 也能抓到
});
// 全局错误处理中间件
// 必须有 4 个参数,哪怕不用 next
app.use((err, req, res, next) => {
console.error('捕获到错误:', err.message);
res.status(500).json({
error: true,
message: err.message
});
});
app.listen(3000, () => {
console.log('Express 5 异步错误捕获测试启动');
});
你试着访问 /users/456(一个不存在的用户),你会发现 Express 5 自动触发了底下的错误处理中间件,返回了 500 错误,而不是让进程崩溃。换个角度看,Express 5 内部帮你做了类似 asyncHandler 的事情。
破坏性变更提醒
升级到 Express 5 的时候,除了享受这些便利,也要注意几个“坑”。根据最新的社区讨论和官方文档:
res.sendfile 被移除了:现在只能用 res.sendFile(注意 F 是大写)。
- 路径匹配规则变了:Express 5 使用了最新的
path-to-regexp 库,之前的某些模糊匹配写法可能不生效了,建议检查路由正则。
- 对
req.body 的解析:虽然 express.json() 还在,但如果你用的第三方 body-parser 太老,可能不兼容。
💡 经验总结:即便是 Express 5,也要显式处理错误
虽然 Express 5 能自动捕获异步错误,但我不建议完全依赖它。
如果你的异步逻辑里有特定的业务逻辑错误(比如用户输入参数不合法),最好的做法还是在 async 函数里主动 throw new Error('参数错误') 或者 return next(new Error('参数错误'))。
建议:保留全局错误处理中间件作为最后的防线(兜底),但在业务逻辑层尽量把错误分类处理清楚,这样代码可读性更高,也更符合“谁污染谁治理”的原则。
4. TypeScript下的类型安全实践:自定义Request与中间件类型增强
换个角度看,用 Express 写业务最头疼的不是逻辑写不出来,而是写着写着 req.user 就飘红了,或者你自己都忘了某个中间件给 req 挂了啥属性。Express 5.0.0 虽然已经正式发布(2024年10月16日),但它本身还是 JavaScript 框架,想要在 2024-2026 年这个技术周期里活得滋润,跟 TypeScript 深度结合是跑不掉的必修课。
很多新手一上来就直接 npm install @types/express,然后就开始写。结果到了需要自定义属性的时候,比如用户登录后把 user 对象挂到 req 上,就开始抓瞎了。你直接写 req.user,TS 编译器会直接给你报错,告诉你 Property 'user' does not exist on type 'Request'。
这时候千万别去用那种粗暴的 @ts-ignore,那是掩耳盗铃。咱们的做法是声明合并(Declaration Merging)。
扩展 Express Request 类型
咱们来个实战场景:假设用户登录后,JWT 解析中间件会把用户信息挂到 req.user 上。
首先,你得有一个 types/express.d.ts 文件(或者放在 src/types 下,确保 tsconfig.json 能包含到它)。
// src/types/express.d.ts
import { Express } from 'express';
// 定义你自己的用户类型
export interface AuthUser {
id: number;
username: string;
role: 'admin' | 'user';
}
// 使用模块扩充(Module Augmentation)
declare global {
namespace Express {
// 这里就是地方,扩展 Request 接口
export interface Request {
user?: AuthUser; // 可选,因为可能没登录
requestId?: string; // 再挂个请求ID,方便日志追踪
}
}
}
搞定这个定义后,你在中间件里给 req.user 赋值,TS 就不会瞎叫唤了。
中间件类型增强与异步处理
Express 5 的一大亮点就是原生支持 Promise 拒绝处理。在 4.x 时代,如果你在异步中间件里忘了 try-catch,或者 Promise.reject 了但没 next(err),进程可能就崩了。Express 5 会自动捕获这些 rejection 并传给错误中间件。
结合 TypeScript,咱们写一个带类型的认证中间件:
// src/middlewares/auth.middleware.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken'; // 假设你用 jwt
// 咱们自定义一个类型,明确这个中间件的输入输出
export type AuthMiddleware = (req: Request, res: Response, next: NextFunction) => Promise<void> | void;
export const authMiddleware: AuthMiddleware = async (req, res, next) => {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
// 这里直接抛错,Express 5 会自动接住传给错误中间件
return res.status(401).json({ message: '未提供Token' });
}
// 假设 jwt.verify 返回了 user 信息
const decoded = jwt.verify(token, 'your-secret-key') as Express.AuthUser;
// 这里因为咱们扩展了 Request,所以 req.user 是存在的,且有类型提示
req.user = {
id: decoded.id,
username: decoded.username,
role: decoded.role
};
next(); // 正常放行
} catch (error) {
// 在 Express 5 里,直接 next(error) 或者抛出异常都行
// 但显式调用 next(error) 是最稳妥的
next(error);
}
};
💡 经验总结:别在业务代码里到处写 req as any。花十分钟把全局类型定义好,后面写业务逻辑的时候,IDE 的自动补全能让你爽到飞起,而且能避免很多因为属性名拼写错误导致的低级 Bug。特别是做 BFF(Backend for Frontend)层开发时,类型就是前后端沟通的契约。
5. 生产级优化与安全:中间件执行顺序、CORS配置与常见漏洞防护
很多小伙伴写完功能就觉得万事大吉了,一上线就被打穿。可以这么理解,Express 这种轻量框架,安全得靠你自己“堆”。这里面的门道,除了代码逻辑,最容易被忽视的就是中间件的执行顺序和默认安全配置。
中间件顺序是个玄学
Express 的中间件是洋葱模型,一层包一层。顺序错了,轻则逻辑不生效,重则安全漏洞。
注意,日志和静态文件一般放最前面,安全中间件紧随其后,然后是解析请求体的中间件,最后是业务路由和错误处理。
看看这个标准的生产级 app.ts 配置:
// src/app.ts
import express, { Request, Response, NextFunction } from 'express';
import helmet from 'helmet'; // 安全头部中间件
import cors from 'cors'; // 跨域处理
import rateLimit from 'express-rate-limit'; // 限流
import cookieParser from 'cookie-parser';
import { authMiddleware } from './middlewares/auth.middleware';
const app = express();
// 1. 信任代理(如果你在 Nginx 或云负载均衡后面)
app.set('trust proxy', 1);
// 2. 安全头部:给响应头加一堆安全锁,防 XSS、点击劫持等
// Express 5 时代,Helmet 已经是标配了
app.use(helmet());
// 3. CORS 配置:千万别在生产环境用 * !
const corsOptions = {
origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
const whitelist = ['https://your-frontend.com', 'https://admin.your-frontend.com'];
if (!origin || whitelist.indexOf(origin) !== -1) {
callback(null, true);
} else {
callback(new Error('CORS策略不允许该来源访问'));
}
},
credentials: true, // 允许携带 Cookie
optionsSuccessStatus: 200
};
app.use(cors(corsOptions));
// 4. 限流:防暴力破解和 DDoS
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
max: 100, // 每个IP限制100次
standardHeaders: true,
legacyHeaders: false,
});
app.use(limiter);
// 5. 解析请求体:注意顺序!必须在路由之前
// Express 5 内置了更强大的解析能力,但还是要显式使用
app.use(express.json({ limit: '10kb' })); // 限制 body 大小,防内存溢出
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
app.use(cookieParser());
// 6. 自定义请求日志中间件(示例)
app.use((req: Request, res: Response, next: NextFunction) => {
req.requestId = Math.random().toString(36).substring(7);
console.log(`[${req.requestId}] ${req.method} ${req.originalUrl}`);
next();
});
// 7. 业务路由
app.get('/api/public', (req, res) => res.json({ msg: '公开数据' }));
// 需要认证的路由,把中间件放在路由前面
app.get('/api/profile', authMiddleware, (req, res) => {
// 这里 req.user 肯定有值,因为中间件过了
res.json({ user: req.user });
});
// 8. 错误统一处理中间件(必须放在最后,且参数必须是4个)
// Express 5 对这里做了强化,能捕获异步错误
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(`[${req.requestId}] Error:`, err.message);
res.status(500).json({
message: '服务器内部错误',
// 生产环境千万别把 stack 暴露出去
error: process.env.NODE_ENV === 'development' ? err.stack : undefined
});
});
export default app;
常见漏洞防护
- XSS(跨站脚本):除了用
helmet 设置 Content-Security-Policy,还要对用户输入做转义。如果你用 EJS 或 Pug 渲染页面,确保默认开启转义。
- SQL 注入:这跟 Express 关系不大,主要是 ORM 或数据库驱动的问题。但你要确保路由参数(如
req.params.id)在使用前做了类型校验,别直接拼 SQL 字符串。
- CORS 滥用:上面代码里写了,不要用
origin: '*'。特别是涉及 Cookie 和认证信息的接口,必须白名单校验。
💡 经验总结:在部署前,用 nmap 或者在线安全扫描工具扫一下你的接口。另外,Express 5 虽然移除了一些旧的不安全的 API(比如 res.sendfile 变成了 res.sendFile),但默认配置依然很“裸”。一定要手动加上 helmet 和 cors 配置,这是生产环境的底线。
6. 迁移与选型:Express 4.x升级5.0指南及与Fastify/Hono对比
最近社区里聊得最火的,除了 AI,就是 Express 5.0.0 正式版发布了(2024年10月16日)。很多守着 4.21.x 版本的同学就在纠结:升不升?还有人问,现在都 2024 年了,还学 Express?是不是该直接上 Fastify 或者 Hono?
Express 4.x 升级 5.0
简单来说,Express 5 不是重写,而是一次现代化的“大扫除”。它移除了很多废弃的 API,强化了对 Promise 的支持。
核心变化与破坏性变更:
- 路径匹配规则变了:Express 5 使用了最新的
path-to-regexp 版本。以前 /* 这种写法可能不灵了,或者捕获参数的行为变了。升级后一定要测路由。
res.sendfile 彻底移除:以前是 deprecated,现在直接没了。必须改成 res.sendFile(注意大写 F)。
res.json 等不再接受 undefined:在 4.x 里你可能 res.json(undefined) 返回空,在 5 里这会报错。
- 异步错误捕获:这是最大的利好。以前你写
async (req, res, next) => { throw new Error('boom'); },如果不包 try-catch 可能会导致进程退出。Express 5 会自动捕获这个 Promise rejection 并传给错误处理中间件。
迁移代码示例:
假设你有个 4.x 的项目,里面有个文件上传或者发送文件的接口:
// 旧版 4.x 代码 (千万别这么写了)
// app.get('/old-download', (req, res) => {
// const filePath = '/path/to/file';
// // 注意这里是小写 f,且如果路径不对,可能行为诡异
// res.sendfile(filePath);
// });
// 升级到 5.0 后的代码
import path from 'path';
import express from 'express';
const app = express();
app.get('/new-download', async (req, res, next) => {
try {
const filePath = path.resolve(__dirname, 'files/report.pdf');
// 1. 必须用 sendFile (大写F)
// 2. 支持 async/await,出错直接 throw,Express 5 会接住
if (!filePath.includes('report')) {
throw new Error('文件不存在或无权访问');
}
// Express 5 对路径解析更严格,建议用绝对路径
res.sendFile(filePath, (err) => {
if (err) {
// 这里的回调依然保留,处理文件流结束后的错误
console.error('文件发送失败', err);
// 注意:如果 headers 已经发送,这里不能再 next(err)
if (!res.headersSent) {
next(err);
}
}
});
} catch (error) {
// 在 Express 5 中,这里不 catch,直接 throw 也行,但为了逻辑清晰,建议保留
next(error);
}
});
🔧 实战技巧:别一次性全升。先在小项目或者新分支里试水。重点检查所有用到 res.sendfile、res.json(undefined) 以及正则表达式路由的地方。
选型对比:Express vs Fastify vs Hono
现在技术选型确实多了,咱们不吹不黑,直接上干货对比:
| 特性 | Express (5.0) | Fastify | Hono |
| :--- | :--- | :--- | :--- |
| 定位 | 经典、生态无敌、中间件多 | 高性能、Schema 驱动、自带 JSON Schema 校验 | 超轻量、跨运行时(Cloudflare Workers, Deno, Bun) |
| 学习曲线 | 低,文档多,避雷经验经验多 | 中,需要理解 Schema 和插件系统 | 低,API 设计很像 Express,但更现代 |
| 性能 | 中等(够用,但不是最快) | 极高(基于 fast-json-stringify) | 极高(特别是边缘计算场景) |
| TypeScript | 需要自己配类型(如上文所述) | 原生支持极好,类型推断很强 | 原生支持极好,且非常轻量 |
| 适用场景 | 传统 REST API、BFF、老项目维护 | 对性能有极致要求的 API 服务、微服务 | 边缘函数、Serverless、Cloudflare Workers |
我的看法:
- 如果你是做传统的中后台管理系统,或者团队里新人多,Express 5 依然是稳妥的选择。它的生态(Passport.js, Multer 等)虽然有些老了,但啥都能找到。
- 如果你在做高并发的纯 API 服务,且对性能有数字指标要求,Fastify 是现在的首选,它的插件体系和 Hook 设计比 Express 优雅很多。
- 如果你在玩边缘计算或者 Serverless,别犹豫,直接上 Hono。Express 在那儿跑起来太重了,Hono 才是为这些环境生的。
换个角度看,Express 5 的发布让这个老框架又续了一命,特别是强化了异步处理,让代码写起来没那么“回调地狱”了。但如果你是新项目,且技术栈允许,不妨试试 Fastify,那开发体验确实比 Express 更现代一些。