Express 的路由匹配有一个很容易被忽视的事实:它不是按路径的精确程度或长度来决定优先级的,而是完全按照代码书写的先后顺序执行。应用启动时,Express 会为每个路由处理方法创建一个 Layer 对象,并依次压入内部栈。当请求进入时,框架从栈顶开始向后扫描,只要找到第一个同时满足 HTTP 方法和路径条件的 Layer,就会停止继续匹配并执行对应的处理函数。换句话说,先写的路由总会先获得响应机会,这并不因为你后面定义了一个更具体的路径而改变。

这种设计带来简单直接的控制力,但也给混用静态路径、参数路径和通配符的场景埋下了隐患。要真正用好 Express 路由,必须搞清楚 path-to-regexp 是如何编译路径的,以及 app.use 前缀匹配和 app.get 完整匹配之间的本质区别。
路由匹配的内部执行流程
每当调用 app.get、app.post、app.put 等方法时,Express 会创建一个 route 对象,再包装成一个 Layer 放进 router 栈。Layer 中保存了路径字符串、编译后的正则表达式、HTTP 方法和处理函数。path-to-regexp 会把类似 /users/:id 这样的表达式转换成正则,其中 :id 表示一个命名参数,可以匹配任意非斜杠字符。也就是说,/users/:id 既能匹配 /users/123,也能匹配 /users/list,因为 list 同样是一个合法参数值。
当请求到达时,Express 会从栈顶开始遍历 Layer。对于路由 Layer,先检查请求方法是否匹配,比如 app.get 只处理 GET 请求,如果方法不符直接跳过。方法匹配后,再拿请求路径与 Layer 的正则做比对。一旦命中,处理函数立即执行并停止继续查找。这意味着如果先注册了 /users/:id,紧接着又注册 /users/list,那么在请求 /users/list 时,参数路由会先命中,后面的静态路由永远不会被执行。
下面这个示例可以直观看出顺序的影响:
const express = require('express');
const app = express();
app.get('/users/:id', (req, res) => {
res.send('用户详情:' + req.params.id);
});
app.get('/users/list', (req, res) => {
res.send('用户列表');
});
app.listen(3000, () => {
console.log('服务已启动');
});
运行后访问 /users/list,返回的内容是用户详情:list,而不是用户列表。因为第一个参数路由已经捕获了该路径。解决方式很简单,把静态路由 /users/list 移到参数路由 /users/:id 之前。这个例子说明了 Express 不做路径具体度计算,只严格按照书写顺序匹配。
中间件的前缀匹配与路由的完整匹配
app.use 注册的中间件和 app.get 这类路由方法在匹配策略上有明显区别。中间件 Layer 只匹配路径前缀,不关心 HTTP 方法。例如 app.use('/api', middleware) 会拦截所有以 /api 开头的请求,包括 GET、POST、PUT 以及更深层路径 /api/users/123。只有当中间件内部调用 next() 时,请求才会继续向后匹配其他 Layer。
这种前缀匹配特性非常适合做鉴权、日志、请求解析等全局或分组功能。比如把认证中间件挂在 /admin 路径前,可以统一保护后台所有接口。但如果中间件位置不当,或者忘记调用 next,就可能阻断整个后续路由的执行。下面是一个常见写法:
app.use('/api', function(req, res, next) {
const token = req.headers['x-auth-token'];
if (!token) {
return res.status(401).send('缺少令牌');
}
req.user = parseToken(token);
next();
});
app.get('/api/users', (req, res) => {
res.send('用户列表');
});
app.get('/api/orders', (req, res) => {
res.send('订单列表');
});
在这个结构中,/api/users 和 /api/orders 都会先经过前缀中间件。中间件通过 next() 放行后,请求会继续向下匹配到具体路由。与路由不同的是,中间件可以同时执行多个,只要路径前缀匹配且中间件调用了 next,不会因为某个中间件命中就完全停止。但路由方法一旦命中就会结束匹配流程,除非处理函数内部主动调用 next('route') 跳过当前路由。
参数路由、通配符与正则路由的优先级原则
虽然 Express 不按具体程度排序,但不同类型的路径在同一个前缀下仍然会遵循先注册先匹配的规则。参数路由 :id、通配符 * 和正则路由都可能匹配同一个请求路径。例如 /files/* 可以匹配 /files/2024/report.pdf,而 /files/:category 也可以匹配 /files/report,两者同时存在时,谁先注册谁先命中。
通配符 * 并不比参数路由优先级低,它同样是一个普通的路径表达式。path-to-regexp 会把 * 编译成匹配任意字符的正则片段,包括斜杠,这与 :id 只能匹配单个路径段不同。因此设计 API 时要特别注意通配符的放置位置。通常建议把最具体的静态路由放在最前面,参数路由放在中间,通配符或兜底路由放在最后。例如:
app.get('/files/avatar.png', (req, res) => {
res.send('默认头像');
});
app.get('/files/:name', (req, res) => {
res.send('文件:' + req.params.name);
});
app.get('/files/*', (req, res) => {
res.send('兜底文件路径');
});
这样静态文件 /files/avatar.png 不会被 :name 截获,而更复杂的多级路径由通配符处理。正则路由由于灵活性高,通常会写得比较宽泛,如果放在前面很容易误伤其他接口。最佳的维护方式是把正则路由集中到一个独立模块,并尽量限定其匹配范围,避免和常规 REST 风格路由混在一起。
使用 Router 拆分提升可维护性
当项目变大时,把所有路由都写在 app.js 里,顺序管理会非常痛苦。Express Router 是解决这个问题的核心工具。每个 Router 实例拥有独立的路由栈,可以被挂载到指定路径前缀下。Router 内部的匹配顺序仍然遵循书写顺序,但不同模块之间不会相互干扰,除非前缀重叠。
例如用户模块和订单模块分别建立 router 文件:
// users.js
const router = require('express').Router();
router.get('/list', (req, res) => {
res.send('用户列表');
});
router.get('/:id', (req, res) => {
res.send('用户详情');
});
module.exports = router;
// orders.js
const router = require('express').Router();
router.get('/list', (req, res) => {
res.send('订单列表');
});
router.get('/:id', (req, res) => {
res.send('订单详情');
});
module.exports = router;
然后在主应用中挂载:
const usersRouter = require('./users');
const ordersRouter = require('./orders');
app.use('/users', usersRouter);
app.use('/orders', ordersRouter);
这样 /users/list 会命中用户模块中的静态路由,/orders/list 命中订单模块的静态路由。每个模块内部的静态路由放在参数路由之前,就能保证 /list 不被 :id 捕获。即使以后新增模块,也只需要新增一个 Router 文件,并在主应用中挂载,而不必担心某个模块的路由顺序影响其他模块。
综合来看,Express 路由优先级始终围绕一个核心原则:先注册先匹配。静态路径、参数路径、通配符和正则都只是路径表达方式的不同,并不会自动获得更高或更低的优先级。理解这一点后,开发时应主动把最精确的路由放在前面,把宽泛的兜底路由放在最后;利用 Router 拆分路由栈,让每个模块内部的顺序清晰可控;同时注意 app.use 的前缀特性,避免中间件误拦截。做好这些约束,Express 的路由系统就能保持直观、高效且易于维护。