Express 路由完全指南:从路由方法、路径匹配到模块化 Router 的实战解析
【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum
路由(Routing)是 Express 应用处理 HTTP 请求的核心机制:它决定"哪一个请求该交给哪一段代码"。本指南以本仓库 nodeJS/express/routing.md 课程为主体,系统讲解路由的解剖结构、HTTP 动词匹配、字符串路径与通配符路径、路由参数与查询参数,以及如何用Router将路由组织成独立模块;并结合仓库内相邻课程(introduction_to_express.md、controllers.md、forms_and_data_handling.md)的源码级示例,帮助你从"能跑通一个路由"进阶到"能设计一套结构清晰、可维护的路由体系"。
路由在 Express 中的位置:请求旅程中的关键一站
在进入细节之前,先明确路由在整个请求生命周期中的角色。本仓库 introduction_to_express.md 中描述了"一个请求的旅程":当浏览器向服务器发送请求时,Express 将请求存入 request 对象(req),然后把它送入一条由**中间件函数(middleware functions)**组成的链条,直到某个中间件调用res响应对象结束请求-响应周期。
路由正是这条链条的"入口分拣器"——它本质上只做一件事:将请求的 HTTP 动词(如 GET、POST)与 URL 路径匹配到对应的中间件函数集合(即控制器 controller)上。正如 routing.md 所说,控制器和中间件的细节会在下一课展开,本节聚焦"如何使用路由"本身。
关于中间件的基础:中间件函数通常接收三个参数
req、res、next,其中next用于把控制权交给链条中的下一个中间件。应用级中间件通过app.use或app.METHOD绑定到整个应用实例,路由级中间件则绑定到Router实例,仅在该路由匹配时执行。详见 controllers.md。
路由的解剖:HTTP 动词 + 路径 + 回调函数
让我们回到基础 Express 应用中的唯一一条路由:
app.get("/", (req, res) => res.send("Hello, world!"));这条语句由三部分组成:
app—— 整个服务器(应用级路由对象);.get—— 匹配的 HTTP 动词(method);"/"—— 匹配的路径(path);- 回调函数—— 匹配成功后执行的中间件函数,接收
req和res。
app.get("/", ...)的含义是:任何通过app路由器(也就是整个服务器)到达/路径的GET 请求都会被这条路由匹配。同理,如果写成:
app.post("/messages", (req, res) => res.send("This is where you can see any messages."));则只会匹配POST 请求且路径为/messages的情况。若向/messages发送 GET 请求,不会命中这条路由。每个 HTTP 动词都有对应的 Express 路由方法(app.get、app.post、app.put、app.delete等),此外还可以用app.all()让一条路由匹配所有动词。
HTTP 动词一览
HTTP 协议定义了多种动词,但课程现阶段主要使用两种:
| 动词 | 用途 | 典型场景 |
|---|---|---|
| GET | 仅从服务器获取数据 | 浏览页面、打开链接 |
| POST | 向服务器发送数据 | 提交表单 |
| PUT / DELETE | 更新 / 删除资源 | 后续学习 REST API 时常用 |
值得注意的是,浏览器地址栏只能发起 GET 请求,无法直接发送 POST 请求——这正是后续用 Postman 等工具测试路由的原因(详见下文"测试路由"一节)。
一条路由匹配多个回调
Express 的路由方法不仅接受单个回调,也接受一个回调数组,它们会按顺序作为中间件链执行。这在 forms_and_data_handling.md 中体现得淋漓尽致——表单校验逻辑作为中间件数组传给控制器:
exports.usersCreatePost = [ validateUser, // 校验与净化中间件 (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).render("createUser", { title: "Create user", errors: errors.array(), }); } const { firstName, lastName } = matchedData(req); usersStorage.addUser({ firstName, lastName }); res.redirect("/"); } ];这里usersCreatePost不是一个普通函数,而是一个中间件数组——Express 会依次执行数组中的每个函数,先跑校验,再进入真正处理逻辑。这就是"路由匹配到一组中间件函数"的直观体现。
路径匹配:字符串、可选字符与通配符
路由的第一个参数是要匹配的路径,可以是字符串,也可以是正则表达式。字符串路径做的是精确匹配:/messages精确匹配/messages,/messages/all只匹配/messages/all(不会匹配/messages,也不会匹配/messages/new)。
用{}让字符可选
在字符串路径中,可以用花括号{}把一段字符标记为可选:
// 同时匹配 /message 和 /messages "/message{s}" // 同时匹配 / 和 /messages "/{messages}" // 同时匹配 /foo/baz 和 /foo/bar/baz "/foo{/bar}/baz"第三种写法"/foo{/bar}/baz"比较微妙:{/bar}作为一个整体是可选的,因此它既匹配foo/baz也匹配foo/bar/baz。
用*通配符匹配任意内容
*(称为 splat 或 wildcard)可以匹配任意数量的任意字符。Express 路径中的 splat 必须紧跟一个名字,即写成{*splat}的形式。它的典型用途是作为"兜底路由"(catch-all),处理所有其他路由都没匹配到的路径,例如自定义 404 错误处理:
// 匹配 /、/odin,也匹配 /sdds8fjsdifhj98sdfh 这类任意路径 "/{*splat}"路由顺序至关重要!
路由在服务器中按定义顺序进行匹配,先定义的路由优先。看这个反例:
app.get("/{*splat}", (req, res) => { res.send("/{*splat} is a great way to catch all otherwise unmatched paths, e.g. for custom 404 error handling."); }); app.get("/messages", (req, res) => { res.send("This route will not be reached because the previous route's path matches first."); });由于/{*splat}能匹配任何路径(包括/messages),GET /messages请求会先命中第一条路由,第二条永远不会执行。要修复,必须把/messages路由定义在/{*splat}之前,让具体路由优先于兜底路由。
这一点与中间件顺序原则一脉相承:Express 按注册顺序执行中间件,改变请求对象的中间件必须放在最顶部,下游中间件才能看到其改动(见 controllers.md)。路由本质上也是中间件链的一部分,顺序即行为。
路由参数:从路径中提取动态值
如果希望/odin/messages、/thor/messages、/theodinproject79687378/messages都能被同一条路由处理,就需要路由参数(route parameters)。这与 React Router 中的动态段是同一思路,且一个路径中可以包含任意多个参数。
语法:以:开头,后跟参数名(只能由大小写敏感的字母数字字符或_组成)。Express 会自动把路径中对应位置的值填入req.params对象,参数名作为键。后续的任意中间件函数都能访问这个对象:
/** * GET /odin/messages 将打印 * { username: "odin" } * * GET /theodinproject79687378/messages 将打印 * { username: "theodinproject79687378" } */ app.get("/:username/messages", (req, res) => { console.log(req.params); res.end(); }); /** * GET /odin/messages/79687378 将打印 * { username: "odin", messageId: "79687378" } */ app.get("/:username/messages/:messageId", (req, res) => { console.log(req.params); res.end(); });注意:
req.params中的值都是字符串。在真实项目中需要数值时,要先做类型转换。本仓库 controllers.md 中的控制器示例就展示了这一实践:async function getAuthorById(req, res) { const { authorId } = req.params; const author = await db.getAuthorById(Number(authorId)); // 字符串转数字 ... }同时它也用到了解构赋值
const { authorId } = req.params,从参数对象中直接取出命名参数——这是读取路由参数最常见的写法。
路由参数在真实路由设计中的应用
在 forms_and_data_handling.md 中,路由参数被用于构建 CRUD 端点:
const { Router } = require("express"); const usersRouter = Router(); const usersController = require("../controllers/usersController"); // 用户更新路由::id 是路由参数 usersRouter.get("/:id/update", usersController.usersUpdateGet); usersRouter.post("/:id/update", usersController.usersUpdatePost); module.exports = usersRouter;对应的 EJS 表单通过模板变量把实际 ID 填入 action:
<form action="/users/<%= user.userId %>/update" method="POST"></form>这样/users/1/update、/users/2/update就由同一条/users/:id/update路由统一处理,控制器内通过req.params.id拿到具体 ID——路由参数正是"一套路由,无数资源"的关键。
查询参数:URL 中的可选键值对
查询参数(query parameters)是 URL 末尾一段独特且可选的部分:?表示查询参数开始,之后是key=value形式的键值对,多个参数用&分隔。它们不属于路径本身,更像是传给某个路径的"参数"。
例如/odin/messages?sort=date&direction=ascending仍然匹配/:username/messages这条路由,但我们可以通过中间件链访问sort=date和direction=ascending这两对键值。
Express 会自动解析请求中的查询参数,填充到req.query对象。如果同一个键重复出现,Express 会把该键的所有值放入一个数组:
/** * GET /odin/messages?sort=date&direction=ascending 将打印 * Params: { username: "odin" } * Query: { sort: "date", direction: "ascending" } * * GET /odin/messages?sort=date&sort=likes&direction=ascending 将打印 * Params: { username: "odin" } * Query: { sort: ["date", "likes"], direction: "ascending" } */ app.get("/:username/messages", (req, res) => { console.log("Params:", req.params); console.log("Query:", req.query); res.end(); });查询参数的现实案例
你可能已经在 YouTube 上见过这种模式:每个视频有一个编码,观看视频时访问https://www.youtube.com/watch,把视频编码作为v键的查询参数传入;还可以用t键指定视频从第几秒开始播放。因此?v=xm3YgoEiEDc&t=424s表示"请求/watch路径,播放xm3YgoEiEDc视频,从第 424 秒开始"。
GET 表单与req.query的配合是另一个典型场景。仓库 forms_and_data_handling.md 的作业中明确指出:通过 GET 方法提交的表单数据不会出现在req.body中,而要用req.query读取——这正是搜索类表单(如/search?name=xxx)的标准做法,也让搜索链接可以被收藏和分享。
路由参数 vs 查询参数
两者经常被放在一起讨论,适用场景不同:
| 维度 | 路由参数 | 查询参数 |
|---|---|---|
| 位置 | 路径内部,/users/:id | 路径末尾,?key=value |
| 可选性 | 路径必须包含该段才会匹配 | 天然可选,不影响路径匹配 |
| 语义 | 标识"哪个资源"(如用户 ID) | 描述"如何返回"(如排序、过滤、分页) |
| 访问方式 | req.params | req.query |
Router:把路由拆分成模块化文件
到目前为止,所有路由都挂在app(整个服务器)上。在真实应用中路由数量庞大,更好的做法是把路由按功能分组,每组抽取到独立文件,这样既能组织代码,也能方便地为某个文件内的路由单独添加中间件,而不影响其他路由。
以课程中的图书馆应用为例,服务器需要处理以下路由:
GET / GET /about GET /contact POST /contact GET /books GET /books/:bookId GET /books/:bookId/reserve POST /books/:bookId/reserve GET /authors GET /authors/:authorId显然,书籍相关、作者相关的路由可以各自成组。Router就是为此设计的。
创建第一个 Router
在项目中新建routes文件夹,创建routes/authorRouter.js:
// routes/authorRouter.js const { Router } = require("express"); const authorRouter = Router(); authorRouter.get("/", (req, res) => res.send("All authors")); authorRouter.get("/:authorId", (req, res) => { const { authorId } = req.params; res.send(`Author ID: ${authorId}`); }); module.exports = authorRouter;要点解析:
- 从
express对象中解构出Router函数,调用它创建authorRouter实例; - 可以在这个 router 上使用与
app相同的.get、.post等方法,实现作用于该路由器的路由与中间件(路由级中间件); - 由于这个 router 将来只会挂载在
/authors前缀下,路由路径本身不需要包含/authors,它们"继承"父路径——否则会匹配到/authors/authors/:authorId这种错误路径。
同理创建另外两个路由组:routes/bookRouter.js和routes/indexRouter.js(它们的中间件函数只需为每条路由发送一段独特的响应,以便确认匹配到了哪条路由)。
把 Router 挂载到服务器
创建好三个 router 后,在app.js中引入并挂载:
// app.js const express = require("express"); const app = express(); const authorRouter = require("./routes/authorRouter"); const bookRouter = require("./routes/bookRouter"); const indexRouter = require("./routes/indexRouter"); app.use("/authors", authorRouter); app.use("/books", bookRouter); app.use("/", indexRouter); const PORT = 3000; app.listen(PORT, (error) => { if (error) { throw error; } console.log(`My first Express app - listening on port ${PORT}!`); });挂载逻辑:任何以/authors开头的请求会进入authorRouter进行路由匹配;以/books开头的请求会跳过作者路由,进入bookRouter;其余请求则全部走indexRouter。
这里的
app.use("/authors", authorRouter)与前文中间件原则完全一致:app.use是应用级中间件的注册方式,router 本身也是一个中间件函数,因此"挂载 router"本质上就是"把一整组路由作为中间件插入请求链"。controllers.md 甚至指出"Express app 在底层也只是一个 router"——app 与 Router 共享同一套路由与中间件机制。
完整目录结构
在 controllers.md 中,课程给出了跟随学习后得到的典型项目结构:
express-app/ ├─ errors/ │ ├─ CustomNotFoundError.js ├─ controllers/ │ ├─ authorController.js ├─ routes/ │ ├─ authorRouter.js │ ├─ ... other routers ├─ app.js ├─ db.js这一结构把"路由定义"(routes/)与"业务逻辑"(controllers/)分离:路由只负责匹配并转发,控制器负责处理数据与响应,正是 MVC 模式的体现。
Router 与控制器联动
router 文件还可以引入控制器函数,让路由定义与逻辑实现解耦。仓库 controllers.md 展示了这种写法:
// routes/authorRouter.js const { Router } = require("express"); const { getAuthorById } = require('../controllers/authorController'); const authorRouter = Router(); // ... 其他路由处理器 authorRouter.get("/:authorId", getAuthorById);这样authorRouter只关心"哪个路径对应哪个控制器",控制器内部如何处理数据、如何响应则完全独立,便于测试与复用。
在项目实战中运用路由:Mini Message Board 与用户 CRUD
路由知识在仓库的两个实战项目中得到直接应用,可作为练习验证所学。
Mini Message Board(project_mini_message_board.md)
该项目只有两条核心路由,却涵盖了 GET/POST 与表单处理:
- 索引路由
"/":在indexRouter中定义router.get("/"),通过res.render("index", { title: "Mini Messageboard", messages: messages })把消息数组作为 locals 传给模板渲染; - 新消息路由
"/new":定义router.get("/new")渲染表单模板,再定义router.post("/new")接收表单提交——表单的action="/new"与method="POST"决定请求发往何处; - 读取表单数据:在
router.post("/new")中通过req.body获取表单字段(字段名对应输入的name属性),这要求应用级中间件app.use(express.urlencoded({ extended: true }))把表单数据解析进req.body; - 提交后通过
res.redirect("/")回到首页,避免重复提交。
路由参数与查询参数在这里虽然没有直接出现,但"GET 展示、POST 处理"的双路由模式(router.get("/new")+router.post("/new"))是后续所有表单功能的基础模板。
用户 CRUD 应用(forms_and_data_handling.md)
更完整地展示了路由参数与 Router 的组合:
// routes/usersRouter.js const { Router } = require("express"); const usersController = require("../controllers/usersController"); const usersRouter = Router(); usersRouter.get("/", usersController.usersListGet); usersRouter.get("/create", usersController.usersCreateGet); usersRouter.post("/create", usersController.usersCreatePost); usersRouter.get("/:id/update", usersController.usersUpdateGet); usersRouter.post("/:id/update", usersController.usersUpdatePost); usersRouter.post("/:id/delete", usersController.usersDeletePost); module.exports = usersRouter;这里/create(无参数)与/:id/update、/:id/delete(带路由参数)并列,展示了一个 Router 中"静态路径"与"动态路径"共存的典型布局。注意删除操作只定义了 POST 路由而没有 GET 路由,因为删除后立即res.redirect("/"),无需单独的展示页面。
测试路由:为什么需要 Postman
浏览器地址栏只能发送 GET 请求,无法发送 POST 请求,因此测试 POST 路由需要专用工具。课程推荐使用Postman:它允许你脱离浏览器发送 GET 和 POST(以及其他动词)请求,直观地验证每个路由是否按预期匹配、参数与查询参数是否正确解析、响应状态码与内容是否符合预期。
结合 introduction_to_express.md 的说明,测试流程为:运行node app.js(或node --watch app.js以在改动后自动重启)启动服务器,然后在 Postman 中向http://localhost:3000/...发送请求并观察响应。node --watch是 Node 内置的监听模式,会监视入口文件及其依赖,检测到变化自动重启——这比 Nodemon 更简单,是课程推荐的方式。
知识自测
以下是本课 routing.md 知识检查部分的核心问题,可作为学习检验:
- 如何定义一个只匹配特定 HTTP 动词的路由?——使用
app.METHOD(如app.get、app.post); - 如何定义一个匹配所有 HTTP 动词的路由?——使用
app.all(); - 如何为路由定义路径模式?——字符串精确匹配、
{}可选字符、{*splat}通配符、正则表达式; - 路由的定义顺序如何影响匹配结果?——先定义先匹配,兜底路由(splat)必须放在具体路由之后;
- 哪个对象会被填充路由参数?——
req.params; - 如何在路由中访问查询参数?——
req.query; - 如何把路由抽取为独立的 router?——创建
Router()实例,用app.use("/prefix", router)挂载; - 有一个路由组挂载在
/users前缀下,要匹配GET /users/delete请求,router 内的 GET 路由路径应该是什么?——/delete(router 内路径继承并扩展父路径)。
学习路径指引
本课是 Express 系列课程 nodeJS/express/ 的一部分。建议按以下顺序系统学习:先通过 introduction_to_express.md 掌握基础服务器与请求旅程,再以本课掌握路由的完整语法与组织方式,随后通过 controllers.md 学习控制器与中间件(含res.status、res.send等响应方法及错误处理中间件),再结合 forms_and_data_handling.md 学习表单处理、校验与净化。最后通过 project_mini_message_board.md 和 project_inventory_application.md(后者要求为分类与条目设计完整的 CRUD 路由)把路由知识落地为可运行的应用。
【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考