资讯动态

精读Express基础篇:路由、中间件与请求响应对象核心实战

发布时间:2026/10/17 1:15:57 来源:尧图企业网站定制
这一系列我一直想写但“基础篇”三个字越是常用越容易写废。这几天把 Express 的中英文文档和一个线上项目里沉淀下来的代码又从头到尾过了几遍发现真正阻碍入门者理解这个框架的往往不是某个 API 不会用而是对整套运行模型没有画面路由是怎么匹配的中间件是怎么串起来的req 和 res 到底从哪一步开始可读出错之后又该往哪里抛。这篇文章是“精读 express 系列——基础篇(1)”先把路由、中间件、请求与响应对象这三块最核心的地基夯实最后用一个内存版任务清单接口把它们串起来。适合已经写过几个 Express Demo、但总觉得自己在“背 API”的读者也适合刚看完官方入门指南、想再深挖一层的新手。1. 为什么“精读”一个跑了十几年的框架Express 解决的核心痛点1.1 先看没有 Express 的世界长什么样很多人学 Express 是从app.get(/ , ...)开始的反而不知道没有这层封装时一个原生 Node 服务要写多少样板代码。我见过不少新同事把 Express 当成“固定的写接口姿势”一旦要处理一个不在默认姿势里的场景比如自定义 404、统一错误格式、多层依赖的路径参数就会卡住。要理解为什么 Express 是这么设计的最好先回到 Node 原生http模块看一看。举个例子只用原生模块写一个最简单的“返回 hello”的服务大概是下面这个味道const http require(http); const server http.createServer((req, res) { const url new URL(req.url, http://localhost); if (url.pathname / req.method GET) { res.writeHead(200, { Content-Type: text/plain }); res.end(hello); return; } res.writeHead(404); res.end(Not Found); });服务本身不难难的是“业务的复杂度每增加一点这段代码就要多叠几层 if”。比如你要支持/user/:id需要自己拆路径、解析参数要支持多个方法需要不断判断req.method要统一返回 JSON需要手动JSON.stringify和设置响应头。等到项目里有一二十个接口时这段代码基本没法维护。Express 做的事情恰恰是把这些高频动作标准化路由提取成声明式注册路径参数直接放在:id里响应统一成res.send/res.json。开发者只需要关心“某个方法、某个路径下要做什么”而不用每次重复解析 URL。当时我把两种方式并排写在同一张纸上对比才对“框架到底省掉了什么”有了直观感受而不再把 Express 当成黑盒。1.2 版本选择带来的阅读差异直接面对 4 和 5 的文档断层精读之前还有一个绕不开的问题现在npm install express默认装的往往是 5.x但网上大量教程、老项目、面试题还在讲 4.x。官方文档也是两套语气混着写经常让你在示例里看到 4.x 的写法又在迁移说明里看到 5.x 的提醒。我自己读的时候会做一张小表把核心差异拎出来避免被绕晕对比点Express 4.xExpress 5.x异步错误处理不自动捕获 async handler 里的 rejected promise内部包装后会自动把异常交给错误中间件app.use和路由通配符常用*表示任意路径通配符语法有调整更推荐用中间件或正则req.query解析默认使用 query parser行为相对复杂解析规则更接近标准 URL 解析嵌套对象语法有差异删除的 API相对稳定移除了部分过时方法如res.sendfile旧签名等这里我建议初学阶段以 4.x 文档为骨架因为生产环境存量项目仍旧以 4 为主但写代码时如果环境是 5就要知道哪些坑是版本带来的。比如异步错误处理4.x 里必须手动包try/catch再调用next(err)5.x 可以直接在 async 函数里抛出错误框架会把 rejected promise 接住。我的结论是基础篇先不纠结版本细节把“路由 中间件 请求响应对象”这条主线讲清楚之后在具体用法上再区分版本“方言”。2. 从空目录到跑通第一个服务搭建与启动排错2.1 最小可运行项目的文件组成先别急着写业务代码我们要从一个空目录把一个 Express 服务跑起来。整个过程越干净越好这样后面观察路由和中间件执行顺序时不会被无关文件干扰。先执行下面两条命令mkdir express-basic cd express-basic npm init -y npm install expressnpm init -y会生成一个最简package.json依赖则统一由 npm 装入项目的node_modules。这里我建议保留默认的main: index.js设置但我们实际新建app.js作为入口因为后面要挂一堆路由单独拆一个应用文件比一股脑写在index.js里清晰。接着写一个最小的app.jsconst express require(express); const app express(); app.get(/, (req, res) { res.send(Hello Express); }); const port process.env.PORT || 3000; app.listen(port, () { console.log(server running at http://localhost:${port}); });这里有一个我早期经常忽略的点const app express()里的express不是“类”而是一个普通函数调用它返回一个应用实例。这个实例本身也是回调函数所以如果要创建 HTTP 服务直接把它传给 Node 原生的http.createServer(app)也成立app.listen只是在内部帮你完成了这一步。我习惯把端口写在环境变量后面是因为部署时端口通常由平台动态注入硬编码成3000会让本地和线上行为不一致。本地开发默认用 3000 就好不用追求奇怪端口。2.2 启动命令和两个高频报错的排查启动服务可以用最朴素的命令node app.js如果你用的是较新的 Node 版本也可以直接用node --watch app.js启动监听模式改完代码自动重启不需要额外装 nodemon。这个能力对新人足够用了。我第一次带别人搭环境时发现报错基本集中在两处。第一处是端口占用Error: listen EADDRINUSE: address already in use :::3000这个报错说明 3000 端口已经被别的进程占用了。最简单的处理是换一个端口启动比如PORT3001 node app.js如果更底层的排查需要可以用工具查看是哪个进程占用了端口。不过本地开发没必要纠结直接换端口反而更快。第二处是Cannot find module express。这种通常不是依赖没装而是你在一个没有执行过npm install的目录里直接跑代码或者当前目录和安装目录不是同一个。排查方法很简单确认终端当前路径下存在node_modules/express目录。如果不存在回到项目根目录执行一次npm install。3. 路由不止是“注册”匹配顺序、参数与 404 兜底3.1 路径匹配的基本规则Express 的路由注册语法是app.METHOD(path, handler)其中METHOD对应 HTTP 方法常见的有get、post、put、delete等。path可以是一个普通字符串也可以包含路由参数app.get(/user/:id, (req, res) { res.json({ id: req.params.id }); });/user/:id中的:id是具名参数Express 会把请求路径里对应位置的实际值放进req.params.id。假设请求/user/9527那么返回的就是{ id: 9527 }。注意参数永远是一个字符串如果你下一步要拿它当数字用得自己转换比如Number(req.params.id)并且要处理NaN的情况。除了字符串路径path也支持正则表达式。比如你想匹配“用户 id 必须是 6 位数字”的场景可以写app.get(/^\/user\/\d{6}$/, (req, res) { res.send(user code: ${req.url}); });不过我个人在基础篇里不太建议一上来就用正则。因为正则路径的可读性差后期维护成本高而且 Express 对正则的匹配结果会体现在req.params[0]这类数组下标里语义没有具名参数清楚。更推荐的做法是仍然用/user/:id写法在 handler 里做格式校验。3.2 一个容易被忽视的顺序问题路由注册顺序对结果有决定性影响。Express 会按照代码里注册的先后顺序依次匹配请求一旦某个路由匹配成功后边的路由就不会再执行。这个机制本身简单但容易踩坑。最典型的坑是“带参数的路由写在固定路由前面”app.get(/user/:id, (req, res) { res.json({ message: 这里是参数路由, id: req.params.id }); }); app.get(/user/profile, (req, res) { res.json({ message: 这里是个人主页 }); });当浏览器请求/user/profile时第一个路由会把profile当成:id的值直接命中并返回参数路由的结果第二个固定路由永远不会被执行。这不是 Express 的 bug而是路由本身的匹配规则就是“先注册先服务”。正确做法是把更具体的路由放在前面app.get(/user/profile, (req, res) { res.json({ message: 这里是个人主页 }); }); app.get(/user/:id, (req, res) { res.json({ message: 这里是参数路由, id: req.params.id }); });我把这条规则总结成一句话路由匹配像排队固定路径优先于参数路径通用兜底永远最后注册。3.3 404 与参数异常的处理路由没有命中任何app.get/app.post时默认会返回一个白底黑字的 404 页面。实际项目里我们通常想统一 JSON 格式的错误响应这时候可以用一个“万能中间件”来兜底app.use((req, res) { res.status(404).json({ error: Not Found, path: req.originalUrl }); });注意这里用的是app.use而不是app.get。app.use不限定 HTTP 方法只要请求走到这个位置且此前没有被任何路由处理它就会执行。把它注册在所有路由的最后面就是一道 404 保险。参数异常同样可以在路由内部处理。比如/user/:id的id期望是数字但用户传了abc我一般这样校验app.get(/user/:id, (req, res) { const id Number(req.params.id); if (!Number.isInteger(id)) { return res.status(400).json({ error: id must be an integer }); } res.json({ id }); });基础篇阶段先不引入更重的校验库先养成“请求参数默认不可信”的习惯比后面再补更省事。4. 中间件体系是 Express 的灵魂next()、洋葱模型和异步错误4.1 中间件的执行模型很多人把“中间件”想得很神秘实际上它是 Express 最朴素的扩展机制一个普通函数接收req、res、next三个参数执行完任务后调用next()把控制权交给下一个中间件或路由。我们用一段代码来观察执行顺序const express require(express); const app express(); // 中间件 A app.use((req, res, next) { console.log(A start); next(); console.log(A end); }); // 中间件 B app.use((req, res, next) { console.log(B start); next(); console.log(B end); }); app.get(/, (req, res) { console.log(handler); res.send(ok); });请求/时终端输出是这样的A start B start handler B end A end这就是常说的“洋葱模型”请求从最外层中间件进入经过next()一层层往里走到达最终的 handler 后再一层层向外返回。next()好比在说“我已经处理完当前这一段轮到下一个环节了”。这里有一个我反复看到的问题写成return next()和单独写next()有区别吗在大多数场景下效果一样但带了return可以让函数提前结束避免在调用next()后继续执行一些不该执行的代码。尤其在做参数校验时我更喜欢用return next()提前跳出去而不是在if / else里手动控制流程。记住一个铁律中间件要么调用next()要么给客户端发送响应必须二选一。如果两者都不做请求会一直挂起客户端等到超时这个问题不会报错排查起来反而更费劲。4.2 同步错误与异步错误的处理差异错误处理中间件是唯一有四个参数的函数靠参数位数量被 Express 识别app.use((err, req, res, next) { console.error(err); res.status(500).json({ error: server error }); });当某个路由里主动抛出同步错误Express 会自动跳过后续普通中间件直接跳到最近的错误处理中间件。例如app.get(/crash, (req, res) { throw new Error(boom); });只要上面注册了错误处理中间件/crash就会进入ERR逻辑返回 500。但如果这个错误发生在 async 函数里事情就变了。在 Express 4 中async handler 抛出的 rejection 不会自动传给错误处理中间件它会被 Node 的事件循环接住表现为“请求挂着不响应”或者进程直接报UnhandledPromiseRejection。所以我在写 Express 4 风格的代码时遇到 async handler 会手动做一层包装app.get(/data, async (req, res, next) { try { const data await mockAsync(); res.json(data); } catch (err) { next(err); } });Express 5 则会把返回 promise 的 handler 自动接住rejected promise 会直接流入错误中间件代码确实简洁不少。但考虑到你手里的老项目很可能是 4.x我仍然建议先掌握手动try/catch next(err)的写法这样无论在哪一个版本里都不会出错。5. 请求对象和响应对象的高频易错点5.1 三个“req.”系列query、params、bodyreq包含了客户端请求的所有信息应用里用得最频繁的是三个“取参入口”req.query来自 URL 查询字符串比如/search?keywordnode里的keyword。req.params来自路由路径参数比如/user/:id里的id。req.body来自请求体主要用在 POST、PUT、PATCH 等带 body 的请求里。前两个不需要额外配置就能用req.body则绕不开“中间件 解析器”这件事。如果你在一个 POST 接口里直接读取req.body在没有添加解析中间件时得到的往往是undefined。Express 从 4.16 版本开始内置了express.json()但必须显式调用才会生效const express require(express); const app express(); app.use(express.json()); app.post(/todos, (req, res) { res.json({ received: req.body }); });加了express.json()之后req.body才会被解析成 JavaScript 对象。它内部把请求体读出来按 JSON 解析一遍再挂到req.body上。常见的朋友圈级报错“为什么 req.body 总是 undefined”八成就是忘了这一行。req.query有一个小地方也值得记住它永远是字符串形式的值。/search?age18拿到的req.query.age是18而不是数字18做比较时要小心“字符串不等于数字”的问题。5.2 res.send、res.json 与响应状态设置res是 Express 对 Node 原生响应对象封了一层后的结果。最常用的方法是res.send和res.json。初学者会问它们到底有什么区别res.send会根据传入内容的类型自动推断响应头。比如传字符串时默认Content-Type: text/html传对象时会自动转成 JSON 并设置application/json。res.json则更专一无论传什么它都先把内容按 JSON 序列化并设置Content-Type: application/json。看一个小例子app.get(/send-string, (req, res) { res.send(hello); // 响应体helloContent-Type: text/html }); app.get(/json-string, (req, res) { res.json(hello); // 响应体helloContent-Type: application/json });区别在响应体前者直接返回hello后者返回带双引号的 JSON 字符串hello。如果 API 设计里要统一返回 JSON我更推荐直接使用res.json语义清晰不会让对象和字符串混在一起产生歧义。设置状态码写在响应方法前面可以链式调用app.post(/todos, (req, res) { res.status(201).json({ message: created }); });这里需要注意res对象一旦调用了send、json或end这次响应就算结束了后续再写res.send会抛出 “Cannot set headers after they are sent” 之类的错误。通常是我在代码里同时调用了next()又自己发了响应或者在一个分支里发了响应后没有return结果执行到另一个分支还想再发一次就会踩这个坑。在 handler 里处理完响应后尽量用return把函数截断。5.3 Content-Type 和编码的实测接口返回中文时很多人担心乱码。实测下来Express 的res.send和res.json默认都会带charsetutf-8所以中文字符基本不会乱。比如app.get(/chinese, (req, res) { res.json({ message: 你好世界 }); });响应的Content-Type是application/json; charsetutf-8浏览器能正常显示。真正容易出问题的是手动设置res.setHeader覆盖了默认编码。我见过一种写法自己手动设置Content-Type: text/plain却漏了charsetutf-8结果中文在某些客户端出现乱码。如果你确实需要手动控制响应头建议写完整比如res.setHeader(Content-Type, text/plain; charsetutf-8)或者直接res.type(txt)这类 Express 封装的更方便的方法。6. 用最小任务清单接口收尾把基础篇(1)的内容串成实战6.1 接口设计只讲概念容易飘我们做一个最小但结构完整的任务清单接口把上面提到的路由、中间件、请求对象、响应对象全部用一遍。接口规划如下方法路径作用返回状态GET/todos返回全部任务200GET/todos/:id返回单个任务200 / 404POST/todos新增任务201DELETE/todos/:id删除任务204 / 404为了保持基础篇的聚焦暂时不接数据库任务先放在内存数组里服务进程重启后数据会丢失这正好可以让后续“接数据库”成为下一篇文章的铺垫。6.2 写一个内存版 CRUD完整的app.js代码如下const express require(express); const app express(); app.use(express.json()); let todos [ { id: 1, title: learn express, done: false }, { id: 2, title: write blog, done: true } ]; let nextId 3; app.get(/todos, (req, res) { res.json(todos); }); app.get(/todos/:id, (req, res) { const id Number(req.params.id); const todo todos.find((item) item.id id); if (!todo) { return res.status(404).json({ error: todo not found }); } res.json(todo); }); app.post(/todos, (req, res) { const title req.body req.body.title; if (!title) { return res.status(400).json({ error: title is required }); } const newTodo { id: nextId, title, done: false }; todos.push(newTodo); res.status(201).json(newTodo); }); app.delete(/todos/:id, (req, res) { const id Number(req.params.id); const index todos.findIndex((item) item.id id); if (index -1) { return res.status(404).json({ error: todo not found }); } todos.splice(index, 1); res.status(204).end(); }); // 404 兜底必须放在所有路由之后 app.use((req, res) { res.status(404).json({ error: Not Found }); }); const port process.env.PORT || 3000; app.listen(port, () { console.log(server running at http://localhost:${port}); });留意 POST 接口里的参数判断req.body req.body.title先确认req.body本身存在再读它的属性。因为有些客户端不按 JSON 格式发请求虽然加了express.json()req.body也可能是一个空对象直接读.title不报错但拿到的是undefined。所以对入参做一次防御性判断成本极低收益明确。路由顺序上GET /todos/:id注册在GET /todos之后没有冲突DELETE /todos/:id同理。如果未来再增加GET /todos/recent这种固定路径一定要把它放在GET /todos/:id前面否则recent会被当成 id 处理。6.3 用 curl 自测的关键路径启动服务后可以用curl直接验证几个核心链路# 查看全部任务 curl http://localhost:3000/todos # 查看 id 为 1 的任务 curl http://localhost:3000/todos/1 # 新增任务注意需要 JSON 内容类型 curl -X POST http://localhost:3000/todos \ -H Content-Type: application/json \ -d {title:read express source} # 删除任务 curl -X DELETE http://localhost:3000/todos/1 # 请求一个不存在的路径测试 404 兜底 curl http://localhost:3000/not-exist新增任务后返回的id是从nextId动态递增的验证 POST 的入参解析和 201 状态码是否正常删除任务后curl默认不输出响应体因为状态码是 204这本身就是一种约定。如果看到 404 成了 JSON 格式说明兜底中间件已经生效。我通常会再测一个“参数错误”的场景curl -X POST http://localhost:3000/todos \ -H Content-Type: application/json \ -d {title:}因为title是空字符串接口应该返回 400这就是入参校验的价值。6.4 这个最小项目的边界和启示这个内存版任务清单并不是一个合格的线上项目它只能帮你把基础篇(1)的知识点串起来。它最大的局限是数据存在内存里进程一重启就全丢其次是没有任何鉴权、日志、统一错误格式封装也还没涉及模板引擎、静态资源、文件上传这些常见能力。但正因为它“小”才能看清 Express 最核心的执行链路。我在实际学这套流程时养成了一个习惯——遇到问题先把整个请求流程在脑子里走一遍客户端发来一个请求 → 经过express.json()等全局中间件 → 按注册顺序匹配路由 → 匹配成功执行 handler → handler 读取req.query/req.params/req.body→ 通过res.json返回 → 如果没有匹配进入 404 兜底中间件 → 如果 handler 抛错进入错误中间件。这套链路一旦跑通后面再学模板引擎、静态文件、session、鉴权中间件都只是在“路由与中间件”这条主线上增加一环而已不会再有那种“这个功能是不是又发明了一套新体系”的陌生感。我自己的小建议是把这个迷你项目放在本地顺手把GET /todos/:id里的Number转换、404 返回 JSON、错误中间件这三个点分别改成你认为“更优雅”的写法再观察请求结果的变化。基础篇嘛动手试错比多看十篇教程都管用。翻转这些边界条件的过程就是你真正理解 Express 过程的一部分。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑