Express 5 中间件与路由基础:原理、next()机制与执行顺序

换个角度看,理解 Express 的核心,就是理解中间件(Middleware)路由(Routing)是怎么配合工作的。Express 5(也就是 2024 年 10 月 16 日刚发布的那个 5.0.0 正式版)依然延续了经典的“中间件层叠”模型。你可以把整个请求处理过程想象成一个流水线或者洋葱模型,请求进来,经过一层层的中间件处理,最后给出响应。

中间件到底是什么?

在 Express 里,中间件本质上就是一个函数。这个函数能拿到三个核心对象:

如果你不调用 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 方法路径

⚡ 效率提示:中间件的摆放位置

千万别把处理耗时逻辑的通用中间件放在路由后面。因为 Express 是顺序执行的,如果路由先匹配上了并结束了响应,后面的中间件根本没机会跑。通常的建议是:

---

从零构建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.paramsreq.query

在 Express 5 里,获取方式依然是 req.params.idreq.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 的时候,除了享受这些便利,也要注意几个“坑”。根据最新的社区讨论和官方文档:

💡 经验总结:即便是 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;

常见漏洞防护

💡 经验总结:在部署前,用 nmap 或者在线安全扫描工具扫一下你的接口。另外,Express 5 虽然移除了一些旧的不安全的 API(比如 res.sendfile 变成了 res.sendFile),但默认配置依然很“裸”。一定要手动加上 helmetcors 配置,这是生产环境的底线。

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 的支持。

核心变化与破坏性变更:

迁移代码示例:

假设你有个 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.sendfileres.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 的发布让这个老框架又续了一命,特别是强化了异步处理,让代码写起来没那么“回调地狱”了。但如果你是新项目,且技术栈允许,不妨试试 Fastify,那开发体验确实比 Express 更现代一些。