ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Node.js + Express后端接口开发实战:路由、中间件与错误处理

2026/10/5 2:53:32 拓冰建站 浏览量
Node.js + Express后端接口开发实战:路由、中间件与错误处理 1. 从零到一搭建环境与项目骨架做后端开发这些年见过不少团队在选型时纠结得不行Java 重、Python 快但部署要操心PHP 老牌但招人难。我倒觉得如果不是特别复杂的业务场景Node.js 搭配 express 框架——这对组合可能是你快速落地后端业务接口模块最舒服的搭配之一。Node.js 自带非阻塞 I/O 和事件驱动机制特别适合处理高并发的 I/O 密集型请求而 express 作为 Node.js 生态里最成熟、最稳定的 Web 框架把路由、中间件、参数解析这些脏活累活都替你料理好了。这篇文章我就用一套实际可跑的接口 Demo完整走一遍后端接口模块从设计到落地的全过程。先说清楚这篇内容解决什么问题你想在最短时间内搭出一套标准化的后端接口服务包含 RESTful 路由设计、统一的请求参数校验、规范化的响应结构和错误处理同时还不想一上来就被 TypeScript、NestJS 那些工程化重武器劝退——那 express 就是你的最优解。适合刚入门 Node.js 后端的朋友照着敲一遍也适合已经写过一些接口但总觉得代码堆在一起的开发者看看别人是怎么梳理结构的。1.1 Node.js 版本选型为什么我劝你老老实实用 LTS很多新手上来就跑到官网下载最新的 Node.js看着版本号最大就觉得最牛。这是一个典型的误区。Node.js 的版本迭代非常激进偶数的版本是 LTSLong Term Support版本属于长期维护版官方保证至少 30 个月的 bug 修复和安全补丁奇数版本则属于当前版本只维护几个月就停止更新主要给那些想尝鲜新特性的开发者用。以现在的时间点来看主流生产环境跑的是 Node.js 20.x LTS 和 22.x LTS。20.x 经过一年多时间的磨砺生态兼容性最好各种第三方包对它支持最完善22.x 相对较新但像 express 这种核心框架早就完成了适配。我个人建议你在本地开发时安装 20.x LTS 版本别追新。生产环境里稳定压倒一切。至于 24.x 这类刚发布的版本社区时不时出现error installing 24.21.0: Node.js v24.21.0 is not yet released这种提示本质上是版本管理工具 nvm 的远端版本列表还没同步官方发布节奏属于正常现象没必要为此焦虑也侧面说明没必要第一时间追新版本。安装方式上Windows 用户直接下载官方 .msi 安装包一路下一步macOS 用户推荐用 Homebrew 执行brew install node20Linux 用户最省心的方式是先装 nvmNode Version Manager然后用nvm install 20指定版本安装。我个人强烈推荐 nvm 方案因为它能让你在同一台机器上随时切换 Node.js 版本。遇到老项目跑不起来、新项目要求新版本这种尴尬局面nvm use 20一下就解了。1.2 初始化项目express 4 还是 express 5项目目录建好之后在终端里执行npm init -y生成 package.json然后安装依赖。这里有一个版本问题需要说清楚express 目前有 4.x 和 5.x 两个大版本。express 5 在 2024 年下半年正式发布了稳定版带来了不少新特性其中最直观的一个改进路由处理器里 async 函数抛出的异常会被自动捕获并交给错误处理中间件不再需要手动包一层 try-catch。这个改进对开发体验的提升相当明显。但我的建议是如果你只是快速构建一个业务接口 Demo或者你的团队对这个框架还没有积累可以先以 express 4.19.x 为主流标准。原因有三点第一网上绝大多数的教程、文档、技术博客还是基于 4.x 写的你遇到问题搜索解决方案时4.x 的资料覆盖率远高于 5.x第二生态里部分中间件还没有完全适配 5.x贸然升级可能踩到兼容性坑第三4.x 经过了近十年的生产环境检验坑都被前人踩平了稳定到没脾气。如果你确实想体验 5.x 的特性单独开一个分支实验没问题别拿生产项目冒险。执行npm install express4.19.2安装稳定版本。顺便说一句npm 在国内网络环境下经常慢得像蜗牛爬建议提前配置镜像源npm config set registry https://registry.npmmirror.com这一步能帮你省下大量的等待时间属于在国内做 Node.js 开发的必配操作。1.3 项目结构设计别把鸡蛋全放一个篮子里初始化完成后先别急着写接口。如果你直接把所有路由、所有逻辑全塞进一个 app.js 文件里确实能跑但一旦接口数量超过十个维护成本会指数级上升。我见过不少朋友的祖传代码一个文件五六千行改一个接口能牵连出一堆 Bug。合理的项目结构应该是从一开始就设计好的。对于 Demo 级别的项目我建议至少做这样的分层project/ ├── src/ │ ├── app.js # express 实例创建与全局配置 │ ├── server.js # HTTP 服务启动入口 │ ├── routes/ # 路由注册层 │ │ ├── user.routes.js │ │ └── product.routes.js │ ├── controllers/ # 控制器层处理业务逻辑 │ │ ├── user.controller.js │ │ └── product.controller.js │ ├── middlewares/ # 自定义中间件 │ │ ├── validate.js │ │ └── auth.js │ └── utils/ │ └── response.js # 统一响应格式封装 ├── package.json └── .gitignore这个结构把路由注册和业务处理拆开了路由文件只负责 URL 映射和挂载中间件控制器里写真正的业务处理逻辑。将来业务量大了还可以在 controllers 和 routes 之间加一层 service 层处理复杂业务、加一层 model 层对接数据库。但 Demo 阶段这套结构已经够用了。核心思想就是单一职责每个文件只干一件事方便定位问题也方便团队协作。2. 核心接口开发路由设计与控制器实现结构搭好之后进入最核心的环节写接口。为了让 Demo 不流于表面我设计了一个典型的用户管理模块覆盖增删改查全流程。这个模块几乎包含了你以后写任何业务接口都会用到的核心知识点RESTful 路径设计、路由模块化、请求参数获取的三种姿势。2.1 RESTful 接口设计规范一眼看懂接口含义在设计接口之前得先聊一聊 RESTful 风格。它本质上是一种约定俗成的接口设计规范核心思想是把后端提供的服务抽象成对资源的操作用 HTTP 方法来表达操作类型。同一个 URL 路径/api/users配合不同的 HTTP 方法就代表了不同的语义HTTP 方法路径功能说明请求体GET/api/users获取用户列表无GET/api/users/:id获取单个用户详情无POST/api/users创建新用户JSON 用户信息PUT/api/users/:id全量更新用户信息JSON 用户信息DELETE/api/users/:id删除指定用户无这种设计的好处显而易见URL 干净统一不同的客户端网页、App、小程序复用同一套接口调用方只看 URL 和方法就能猜到接口是干什么的。很多新手容易犯的错误是搞一堆/api/getUserInfo、/api/createUser、/api/deleteUserById这种动词式路径冗长且不规范。RESTful 提倡用名词复数作为资源名用 HTTP 方法描述动作一开始就要养成这个习惯。有个实操细节需要提一下路径中的参数比如:id建议在控制器里做格式校验。我见过有人传一个非数字的 id 进来数据库查询直接报错接口返回 500。宁可前面多写两行校验也不要让错误一路传导到数据库层。2.2 路由模块化express.Router 的正确用法express 提供了express.Router()这个 API用来创建模块化的路由处理单元。它的作用就是把一组相关的路由打包成一个子应用然后通过app.use()挂载到主应用上。这是 express 项目里组织多模块路由的官方推荐姿势。看一下用户模块的路由文件怎么写// src/routes/user.routes.js const express require(express); const router express.Router(); const userController require(../controllers/user.controller); const { validateUser, validateUserId } require(../middlewares/validate); router.get(/, userController.getUserList); router.get(/:id, validateUserId, userController.getUserDetail); router.post(/, validateUser, userController.createUser); router.put(/:id, validateUserId, validateUser, userController.updateUser); router.delete(/:id, validateUserId, userController.deleteUser); module.exports router;注意这里路由路径都是相对路径没有写/users这个前缀。因为统一的资源前缀要在 app.js 挂载时指定// src/app.js const express require(express); const app express(); const userRoutes require(./routes/user.routes); app.use(express.json()); app.use(/api/users, userRoutes); module.exports app;这样设计的妙处在于如果将来接口从/api/users迁移到/api/v1/users只需要改动 app.js 里的一行挂载路径路由文件完全不用动。这种前缀和路由解耦的思路听着简单但在真实项目中能省不少事。产品模块的路由可以完全照搬这套模式每个业务模块对应一个路由文件互不干扰。我之前带过一个新人刚开始把两个模块的路由全堆在 app.js 里改一个接口都要在一堆代码里找半天。后来我让他拆成模块化路由代码清晰度肉眼可见地提升了。2.3 控制器实现请求参数获取的三种姿势控制器是处理业务逻辑的地方也是操作请求参数的主战场。在 express 里获取前端传参主要靠三个对象req.params、req.query、req.body。这三个对象的使用场景完全不同混用是新手最容易踩的坑我逐个说清楚。req.params获取的是 URL 路径中的动态参数比如路由定义为/users/:id那么请求/users/42时req.params.id的值就是字符串42。要注意它拿到的永远是字符串如果要用数字比较必须手动转类型否则42 42是 false。req.query获取的是 URL 问号后面的查询字符串参数比如GET /api/users?page1pageSize10那么req.query.page的值是1req.query.pageSize的值是10。这个对象主要用来读取分页、筛选、排序这类可选参数。req.body获取的是请求体里的数据通常用于 POST 和 PUT 请求。但这里有一个关键的坑req.body在 express 里不是默认可用的你必须先在全局配置express.json()这个中间件它才能正确解析 Content-Type 为application/json的请求体。忘了配这个req.body只会是 undefined接口接收不到任何数据还查不出为什么。这个坑我踩过不止一次每次都让人抓狂。看一组完整的控制器代码示例// src/controllers/user.controller.js const { successResponse } require(../utils/response); // 模拟数据源 let users [ { id: 1, name: 张三, email: zhangsanexample.com, age: 25 }, { id: 2, name: 李四, email: lisiexample.com, age: 30 } ]; let nextId 3; exports.getUserList (req, res) { // 处理分页参数记得把字符串转数字 const page parseInt(req.query.page) || 1; const pageSize parseInt(req.query.pageSize) || 10; const startIndex (page - 1) * pageSize; const pagedUsers users.slice(startIndex, startIndex pageSize); successResponse(res, { list: pagedUsers, page, pageSize, total: users.length }); }; exports.getUserDetail (req, res) { const id parseInt(req.params.id); const user users.find(item item.id id); if (!user) { // 进入错误处理中间件返回统一格式的 404 const error new Error(用户不存在); error.status 404; throw error; } successResponse(res, user); }; exports.createUser (req, res) { const { name, email, age } req.body; const newUser { id: nextId, name, email, age }; users.push(newUser); successResponse(res, newUser, 201); }; exports.updateUser (req, res) { const id parseInt(req.params.id); const userIndex users.findIndex(item item.id id); if (userIndex -1) { const error new Error(用户不存在); error.status 404; throw error; } users[userIndex] { ...users[userIndex], ...req.body }; successResponse(res, users[userIndex]); }; exports.deleteUser (req, res) { const id parseInt(req.params.id); const userIndex users.findIndex(item item.id id); if (userIndex -1) { const error new Error(用户不存在); error.status 404; throw error; } users.splice(userIndex, 1); successResponse(res, null); };这里我用一个内存数组模拟数据库是为了让 Demo 足够简单、可以直接跑通。实际项目中controllers 层的数据操作会被替换成数据库 ORM 的调用但接口层和业务层的代码骨架不需要改动。上面updateUser用了展开运算符...将旧的用户信息和请求体合并实现了部分更新的效果。虽然严格来说 PUT 是全量更新但在实际业务中这种写法非常常见比强制客户端传全量字段友好得多。3. 数据校验与中间件给接口装上过滤器接口开发中有一件事比写业务逻辑本身还重要就是数据校验。一个没有做入参校验的接口就像一扇没上锁的门任何脏数据都能长驱直入。轻则接口报错、数据库写入垃圾数据重则引发安全问题。express 社区里校验方案不少但最主流、体验最好的还是 Joi 这套声明式校验方案。3.1 为什么不用手写 if 判断做校验很多新手最开始的写法是在控制器里拿到参数后写一串 if 判断字段是否存在、格式对不对。比如if (!req.body.name || req.body.name.length 2) { return res.status(400).json({ message: 用户名至少2个字符 }); } if (!req.body.email || !req.body.email.includes()) { return res.status(400).json({ message: 邮箱格式不正确 }); }这种写法不能说错但问题很明显第一校验逻辑散落在每个控制器里同样的字段可能要在不同接口里重复校验好几遍第二代码可读性差业务逻辑和校验逻辑搅在一起第三当字段变多、校验规则变复杂时if 嵌套会越来越深维护成本直线上升。Joi 的方案是把校验规则声明为一个 Schema 对象然后一行代码完成校验。规则是数据代码是逻辑两者天然分离。修改校验规则时不需要动业务代码老接口加字段时也不会影响原有逻辑。而且 Joi 自带几十种内置规则从邮箱格式、字符串长度、数字范围到数组枚举覆盖了日常开发 95% 的校验需求。3.2 使用 Joi 构建参数校验中间件先安装依赖npm install joi。然后创建统一的校验中间件// src/middlewares/validate.js const Joi require(joi); // 定义用户相关的校验规则 const userSchema Joi.object({ name: Joi.string().min(2).max(20).required() .messages({ string.min: 用户名至少2个字符, any.required: 用户名不能为空 }), email: Joi.string().email().required() .messages({ string.email: 邮箱格式不正确 }), age: Joi.number().integer().min(0).max(150).default(18) }); const userIdSchema Joi.object({ id: Joi.number().integer().positive().required() }); // 生成校验中间件的工厂函数 const validate (schema, source body) { return (req, res, next) { const { error, value } schema.validate(req[source], { abortEarly: false, stripUnknown: true }); if (error) { const errorMessages error.details.map(detail detail.message); return res.status(400).json({ code: 400, message: errorMessages.join(; ), data: null }); } // 将校验后的干净数据覆盖回请求对象 req[source] value; next(); }; }; exports.validateUser validate(userSchema); exports.validateUserId validate(userIdSchema, params);这个中间件的核心逻辑在validate工厂函数里。它接收两个参数schema是 Joi 校验规则source指定从请求的哪个位置取值body、query 或 params。abortEarly: false很关键它让 Joi 在一次校验中报告所有字段的错误而不是遇到第一个错误就停下来。这样前端一次性就能拿到所有问题项不用反复提交、反复挨批。stripUnknown: true则会在校验时自动剔除请求体里没有在 Schema 中声明过的冗余字段防止有人往请求体里塞多余的数据。推荐 Schema 和路由层结合使用的模式。在路由里给不同接口挂载对应的校验中间件控制器里拿到的req.body一定是经过校验的干净数据不再需要任何 if 判断。接口的健壮性有了控制器的代码也干净了。3.3 响应格式与全局错误处理中间件经常看到一些项目的接口返回值五花八门有的成功了只返回数据失败了返回一个字符串有的把状态码塞在 JSON 的 code 字段里跟 HTTP 状态码完全是两回事。前端对接这样的接口光是判断成功失败就得写好几套逻辑。规范化的做法是设计一套全局统一的响应结构所有接口不管成功还是失败都走同一套格式。我在 Demo 里封装了一个小工具// src/utils/response.js exports.successResponse (res, data null, statusCode 200) { return res.status(statusCode).json({ code: 0, message: success, data }); }; exports.errorResponse (res, message 服务器内部错误, statusCode 500) { return res.status(statusCode).json({ code: statusCode, message, data: null }); };这套格式用code表示业务状态码message描述业务信息data承载实际数据。前端的统一拦截器只需要检查code 0即可判定请求成功其余情况进入错误分支。这里有一个很容易被混淆的点HTTP 状态码和业务 code 是两个维度的东西。HTTP 状态码是给浏览器和 HTTP 客户端看的协议层状态业务 code 是给前端业务逻辑看的业务层状态。两者可以保持一致但职责不同不要混为一谈。全局错误处理中间件是 express 里最后一个兜底关卡。任何路由、任何控制器里没有被捕获的错误最终都会流到这个中间件里// src/app.js app.use((err, req, res, next) { console.error(捕获到未处理错误:, err); const statusCode err.status || 500; const message statusCode 500 ? 服务器内部错误 : err.message; errorResponse(res, message, statusCode); });注意这个中间件必须有四个参数(err, req, res, next)缺一个 express 都不会把它识别为错误处理中间件。这是 express 内部根据参数数量来区分的约定一个很隐蔽的坑。上面控制器里throw error并设置了error.status 404就是利用了这个机制。如果没写错误处理中间件或参数数量不对express 默认的错误处理会返回一个 HTML 格式的错误页面前端根本没法友好处理。3.4 常用内置与第三方中间件清单除了自定义中间件express 还自带了一批内置中间件另外生态里还有几个实际项目基本必装的第三方中间件。我把它们整理成一张速查表方便你按图索骥中间件作用备注express.json()解析 JSON 格式的请求体4.16 内置必配express.urlencoded({ extended: true })解析表单格式的请求体处理表单提交时必配cors处理跨域资源共享前后端分离项目标准配置morganHTTP 请求日志开发调试验证必备helmet设置安全相关的响应头生产环境建议加上compression对响应做 gzip 压缩减少传输体积提升加载速度写一个全局安装示例// src/app.js const express require(express); const cors require(cors); const morgan require(morgan); const helmet require(helmet); const compression require(compression); const app express(); app.use(helmet()); app.use(compression()); app.use(cors()); app.use(morgan(dev)); app.use(express.json()); app.use(express.urlencoded({ extended: true }));app.use()的调用顺序有讲究中间件按注册顺序依次执行跨域、日志这类通用中间件放最前面路由挂载放后面。如果你先把路由注册了再挂载日志中间件那么路由之前的请求日志全都会漏掉。这个顺序问题经常被忽视但排查问题时会觉得非常别扭。4. 自动化测试与接口调试指南接口写完了是不是就万事大吉了远没有。我见过太多项目接口在开发环境跑得好好的一上测试环境就各种 500。原因往往不是代码逻辑错了而是没做自动化测试改一处代码影响了另一个接口但没人发现。在 Demo 阶段养成写测试的习惯能让你在后面的真实项目中少掉很多头发。4.1 使用 Jest 和 Supertest 做接口级测试测试框架我推荐 Jest接口测试工具推荐 Supertest。这两个是目前 Node.js 生态里使用最广泛的组合。Jest 负责编写测试用例、断言和统计覆盖率Supertest 负责以真实 HTTP 请求的方式调用你的 express 应用并获取响应。先安装npm install --save-dev jest supertest在 package.json 里配置测试脚本{ scripts: { test: jest, start: node src/server.js, dev: nodemon src/server.js } }然后写第一组接口测试用例。这里有一个非常关键的技巧测试时不要启动 server.js而是直接导入 app.js 这个 express 实例交给 Supertest。这样做的好处是测试跑在独立进程中不需要占用真实端口也不用担心端口冲突// src/__tests__/user.api.test.js const request require(supertest); const app require(../app); describe(用户管理接口测试, () { test(GET /api/users 应返回用户列表, async () { const response await request(app) .get(/api/users) .expect(200); expect(response.body.code).toBe(0); expect(Array.isArray(response.body.data.list)).toBe(true); expect(response.body.data.total).toBeGreaterThan(0); }); test(POST /api/users 创建用户成功, async () { const response await request(app) .post(/api/users) .send({ name: 王五, email: wangwuexample.com, age: 28 }) .expect(201); expect(response.body.code).toBe(0); expect(response.body.data.name).toBe(王五); }); test(POST /api/users 缺少姓名时应返回400, async () { const response await request(app) .post(/api/users) .send({ email: invalid-email }) .expect(400); expect(response.body.code).toBe(400); }); test(DELETE /api/users/999 删除不存在用户应返回404, async () { const response await request(app) .delete(/api/users/999) .expect(404); expect(response.body.code).toBe(404); }); });Supertest 的链式调用写法非常直观.get()、.send()、.expect()一步步拼接出请求的完整形态。.expect(200)用来断言 HTTP 状态码后面再用常规 Jest 断言检查响应体的业务字段。这组测试覆盖了正常路径和异常路径跑一次npm test接口的增删改查是否正常就一目了然了。测试不是给代码上保险而是给后续迭代上保险。Demo 阶段你可能觉得十几行测试可有可无但一旦项目进入维护期敢不敢动一段老代码很大程度上取决于测试覆盖率撑不撑腰。事实上那些常年维护的项目最值钱的往往不是业务代码本身而是这套能快速验证什么都没坏的测试套件。4.2 使用 Nodemon 实现开发热更新开发接口时最烦的操作是什么改一行代码手动重启一次服务。改一次重启一次时间全浪费在 CtrlC 和 npm start 上了。Nodemon 就是为了解决这个问题而生的工具。它的原理是监听项目文件的变化一旦检测到文件被修改自动帮你重启 Node.js 进程。安装它npm install --save-dev nodemon然后配置 npm script前面已经写过dev: nodemon src/server.js。启动之后每次保存代码终端里会自动出现重启日志完全不用手动干预。这里有个使用技巧默认情况下 Nodemon 会监听整个项目目录这样 node_modules 里的大变动也会触发重启。建议在项目根目录建一个nodemon.json文件明确指定要监听的目录{ watch: [src], ext: js,json, ignore: [src/**/*.test.js] }watch限定只监听 src 目录ext限定监听 js 和 json 文件类型ignore排除测试文件。这一套配置下来Nodemon 只在你真正改代码的时候才重启安静且高效。顺带提一句热更新的另一个姿势Node.js 官方在 22 版本里实验性地引入了--watch参数用node --watch src/server.js也能实现类似的效果。但体验上还比不上 Nodemon 成熟兼容性方面也有一些限制。目前的主流选择还是 Nodemon等官方方案稳定下来再切换不迟。4.3 接口文档要不要上 Swagger写完接口该怎么让前端同事知道每个接口怎么调最原始的方式是写一份 Markdown 文档贴在项目 README 里。但接口一多、改动一频繁文档就极容易过期。前端照着文档调调不通再跑来问效率极低。成熟的方案是上 Swagger准确说是 OpenAPI 规范。在 express 项目里swagger-jsdoc负责把代码里的 JSDoc 注释解析成 OpenAPI 格式的 JSONswagger-ui-express把解析结果渲染成一个可交互的 API 文档页面。前端打开/api-docs这个地址能直接看到一个漂亮的在线文档页面每个接口的参数、返回值、错误码一目了然还能直接在页面上点击Try it out按钮发请求试接口。Swagger 的配置跟路由模块化一样也是声明式的。在路由文件上写注释在配置里指定扫描哪些文件一切自动生成。这样做文档的成本几乎为零效果却比 Markdown 文档好得多。不过作为 Demo 阶段我建议你先把核心功能跑通Swagger 可以作为第二步优化项。一上来就搞文档容易在细节里迷失方向反而忽略了接口本身。5. 常见问题与避坑实录最后这部分是我认为整篇文章含金量最高的地方。下面这些问题都是我在实战中真实踩过、帮别人排查过的经典坑。每一个都让你浪费过几小时甚至一整天。5.1 接口能启动但 POST 请求收不到数据这个问题的经典程度排在所有 express 坑的第一位。表象是接口能正常启动GET 请求一切正常但前端发 POST 请求时req.body永远是 undefined发给接口的 JSON 数据就像石沉大海。排查思路先看请求的 Content-Type 是否是application/json再看 express 应用里有没有配置express.json()中间件。前端如果用的是 axios 且没手动设置 Content-Typeaxios 会自动根据数据格式猜测。数据是对象时一般没问题但如果前端用了qs.stringify()转成了x-www-form-urlencoded格式你的express.json()就解析不了必须再加一个express.urlencoded({ extended: true })才能处理。所以最省心的做法是两个都配上去别让这种低级问题浪费你的时间。还有个隐藏版本如果你在路由处理器里用了multer这类文件上传处理库并且配置了multer的中间件它会拦截掉 multipart/form-data 类型的请求体此时req.body里可能只有文件字段之外的文本字段。这种情况跟 express 的 JSON 解析无关是文件解析库的工作机制决定的。5.2 异步错误导致进程崩溃这是 express 4.x 时代最有名的坑没有之一。看这段代码app.get(/api/users/:id, async (req, res) { const user await db.query(SELECT * FROM users WHERE id ${req.params.id}); res.json(user); });如果db.query抛出一个异常express 4.x 的路由处理器并不会自动捕获它。异常会一路冒泡到 Node.js 进程层面如果你的应用没做全局兜底整个服务直接崩溃。生产环境挂一次那就是事故。解决方案有三个。第一个方案是给每个 async 路由手动包 try-catch这也是最原始的方式代码会变得很啰嗦每个接口都要包一层。第二个方案是写一个包装函数const asyncHandler fn (req, res, next) { Promise.resolve(fn(req, res, next)).catch(next); };然后在路由定义时把 async 函数包进去异常会通过next(err)传给全局错误处理中间件。这个方案好用但容易被忘记。第三个方案就是前面提到的直接升级到 express 5.x它从框架层面解决了这个问题async 函数里的异常会自动进入错误处理中间件。这也是 express 5 相比 4 最让我心动的地方。如果你现在新建项目且不想折腾包装函数直接用 express 5 没毛病。5.3 EADDRINUSE 端口被占用启动项目时遇到Error: listen EADDRINUSE: address already in use :::3000字面意思就是 3000 端口已经被别的进程占用了。常见原因上次开发的服务没关干净或者有别的不相关的程序占了这个端口。排查命令按操作系统区分。macOS/Linux 用lsof -i :3000查占用进程的 PID然后kill -9 PIDWindows 在管理员权限的 CMD 里用netstat -ano | findstr :3000查 PID再taskkill /PID 你的PID /F。解决完端口冲突后建议你把端口号和进程管理列进联调前检查清单能省不少无意义的扯皮。另外提醒一点不要把端口号硬编码在代码里。用环境变量处理端口项目里新建一个.env文件配合 dotenv 库读取把PORT3000配进去。不同环境本地、测试、生产用不同端口代码同一套灵活切换。5.4 跨域问题CORS 到底怎么配前后端分离的项目基本绕不开跨域。你的前端跑在 5173 端口Vite 默认后端跑在 3000 端口浏览器直接发起请求就会被 CORS 策略拦下来。配置方式很简单装一个cors中间件然后app.use(cors())一挂就解决了。但这里有一个安全细节如果全放任cors()默认允许所有域名跨域访问那你的接口就被全网任何网站都可以调用了。正确的姿势是配置白名单const corsOptions { origin: [http://localhost:5173, https://your-domain.com], methods: [GET, POST, PUT, DELETE], allowedHeaders: [Content-Type, Authorization], credentials: true }; app.use(cors(corsOptions));这样只有白名单里的域名才能访问你的接口其他域名一律拒绝。credentials: true表示允许前端带上 Cookie 或 Authorization 头如果你用 JWT 做登录鉴权必须把这个选项打开否则浏览器会拦截带凭证的跨域响应。5.5 503 还是 500区分服务不可用与内部错误不少新手分不清 500 和 503 的使用场景。500 Internal Server Error 表示服务端代码出错了比如数据库连不上、代码抛异常503 Service Unavailable 表示服务暂时无法处理请求比如请求量太大被限流了、服务正在重启。这两个状态码在 HTTP 语义上有本质区别。目前 4.x 版本中如果你没做任何容错处理绝大多数异常都会落入 500 的范畴。等将来你自己设计限流中间件时503 会是限流后返回的状态码。提前建立这个区分意识后面排障会更顺手。5.6 环境变量管理不要把密钥写进代码最后一条虽然不是 express 特有的坑但几乎是每个初入后端的开发都会犯的毛病把数据库密码、JWT 密钥、API Key 直接写在代码文件里。代码一旦提交到 Git 仓库并推到 GitHub这些密钥就等于公开了。轻则被爬虫扫描到造成资源浪费重则数据库被脱库、业务被渗透。正确的做法是用环境变量管理敏感配置。项目根目录创建.env文件PORT3000 DB_HOSTlocalhost DB_PASSWORDyour_secret_password JWT_SECRETyour_jwt_secret_key然后把.env加进.gitignore文件里确保它永远不会被提交到 Git 仓库。代码里通过process.env.DB_PASSWORD读取。另外仓库里可以放一个.env.example文件里面只有键名没有真实值方便新同事照葫芦画瓢配置自己的本地环境。我在实际工作中见过太多次因为密钥外泄导致的事故小到测试环境数据库被清空大到生产环境的数据被加密勒索。这套环境变量管理方案虽然不复杂却是保护接口安全的第一道防线务必从 Demo 阶段就开始养成习惯。写在最后至此一个完整的 Express 后端业务接口模块 Demo 就算真正落地了。从环境初始化到项目结构设计从 RESTful 路由开发到参数校验中间件从全局错误处理到自动化测试再到那些让人头大的常见坑一条线走下来你其实已经把绝大部分后端接口开发要面对的核心问题都过了一遍。在实际操作中我最深的体会是接口开发真正难的从来不是框架 API 的记忆而是有没有从一开始就建立起规范意识——响应格式统一、入参校验前置、错误层层兜底、敏感配置不进代码库。这些看似繁琐的习惯才是让你的接口从能跑进化到好用、敢维护的关键分水岭。如果你照着这篇内容把 Demo 完整搭起来了下一步就可以试着给用户模块接一个真实的数据库比如 SQLite 或 MySQL把内存数组替换成持久化存储。之后再考虑引入 JWT 登录鉴权给接口加上身份认证。任何复杂的后端系统都是从这样一个清晰的模块骨架开始逐步生长出来的先把这个骨架打扎实了后面的路会顺很多。