1. 项目缘起与整体设计思路
1.1 为什么选这个题目练手
做了几年后端开发,带过不少新人,我发现一个很普遍的现象:很多人学Node.js和Express,看完文档、跟着教程敲完一个Hello World,然后就不知道下一步该干什么了。知识点是散的,串不起来。你问他中间件是什么,他能背出定义;你让他从零写一个能跑通的API服务,他卡在目录结构上。
这个实战项目的出发点就是解决这个问题。用AI辅助,从零搭一个完整的API服务,麻雀虽小五脏俱全——有路由、有中间件、有数据校验、有错误处理、有日志、有环境变量管理。做完这一遍,你对一个后端服务的基本骨架就有肌肉记忆了。
适合谁看?如果你已经会JavaScript基础语法,知道const、箭头函数、async/await是什么,但没完整做过一个后端项目,那这篇就是写给你的。如果你已经做过几个Express项目,也可以看看我在AI协作和工程化细节上的处理方式,说不定有能借鉴的地方。
1.2 技术选型背后的考量
为什么是Node.js + Express,而不是Koa、Fastify或者NestJS?这个问题我在动手前认真想过。
Express是目前生态最成熟的Node.js Web框架,没有之一。中间件数量多、社区答案全、遇到问题搜索出来的结果最多。对于练手项目来说,这一点极其重要——你不想在排查一个基础问题时,发现全网只有三个相关讨论,其中两个还没人回答。
Node.js这边,我建议直接用LTS版本。写这篇文章时Node.js 20是活跃LTS,Node.js 22也已经是LTS了。版本选择上有个原则:生产环境用LTS,练手也用LTS。不要追Current版本的新特性,除非你明确知道自己在做什么。LTS意味着更长的维护周期和更稳定的依赖兼容性。
AI在这个项目里扮演什么角色?我的定位是:AI是结对程序员,不是代驾。它帮你生成样板代码、补充边界情况、解释报错信息,但架构决策、目录组织、错误处理策略这些需要你自己想清楚。如果你全程让AI生成然后复制粘贴,做完这个项目你什么也学不到。
1.3 最终的项目结构长什么样
在动手写代码之前,先把目录结构定下来。这一步很多人会跳过,直接npm init然后开始写app.js,写到后面发现所有代码挤在一个文件里,改一处牵动全身。
我最终采用的结构是这样的:
mini-api/ ├── src/ │ ├── app.js # Express应用配置 │ ├── server.js # 服务启动入口 │ ├── routes/ │ │ └── users.js # 用户相关路由 │ ├── middleware/ │ │ ├── logger.js # 请求日志中间件 │ │ ├── validate.js # 参数校验中间件 │ │ └── errorHandler.js # 统一错误处理 │ └── utils/ │ └── response.js # 统一响应格式封装 ├── .env # 环境变量(不提交到git) ├── .env.example # 环境变量模板 ├── .gitignore ├── package.json └── README.md这个结构不算复杂,但覆盖了一个API服务最核心的几个关注点:应用配置与启动分离、路由按业务模块拆分、中间件独立管理、工具函数统一存放。后面加新功能,往对应目录里塞文件就行,不会乱。
注意:
app.js和server.js分开是有意为之。app.js只负责创建和配置Express应用,server.js负责监听端口。这样做的好处是测试的时候可以直接引入app.js,不用真的启动一个HTTP服务。
2. 环境搭建与AI协作的实操细节
2.1 Node.js环境准备与版本管理
安装Node.js这件事本身没什么难度,但有几个细节值得说。
去Node.js官网下载LTS版本,Windows和macOS都有安装包,一路下一步就行。Linux用户如果用Ubuntu,不要用apt install nodejs,那个版本通常很旧。用NodeSource的源来装:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt-get install -y nodejs装完之后验证:
node -v # 应该输出 v20.x.x npm -v # 应该输出 10.x.x如果你电脑上已经有其他Node.js版本,或者以后需要在不同版本之间切换,建议装一个版本管理工具。Windows上用nvm-windows,macOS和Linux上用nvm。这东西的好处是,你可以在Node.js 18、20、22之间随意切换,不同项目用不同版本互不干扰。
# 安装nvm后 nvm install 20 nvm use 20 nvm alias default 20 # 设为默认版本实操心得:我见过太多人因为Node.js版本不对导致
npm install报错,排查半天以为是网络问题。养成习惯,拿到一个新项目先看package.json里的engines字段,确认Node.js版本要求。
2.2 初始化项目与依赖安装
新建目录,初始化:
mkdir mini-api && cd mini-api npm init -y-y表示全部用默认值,之后可以手动改package.json。我习惯把main改成src/server.js,scripts里加上start和dev:
{ "name": "mini-api", "version": "1.0.0", "main": "src/server.js", "scripts": { "start": "node src/server.js", "dev": "node --watch src/server.js" } }Node.js 18.11以上自带--watch模式,改代码自动重启,不需要额外装nodemon。这个细节很多人不知道,还在用nodemon。当然nodemon功能更丰富,但练手项目用--watch足够了。
接下来装依赖:
npm install express dotenv就这两个。express是框架,dotenv用来加载.env文件里的环境变量。其他东西暂时不需要,用到再加。
注意:不要一上来就装一堆依赖。每装一个包,问自己“现在真的需要吗”。依赖越多,安全漏洞风险越大,版本冲突概率越高。练手项目更是如此,保持精简。
2.3 用AI辅助开发的正确姿势
这个环节我想多说几句,因为很多人用AI写代码的方式是低效甚至有害的。
错误用法:打开AI对话窗口,输入“帮我写一个Express API服务”,然后把生成的代码全部复制到项目里。结果代码能跑,但你不知道每一行在干什么,出了问题完全无法排查。
正确用法:把AI当成一个随时可以问的资深同事。具体来说:
第一,让AI解释代码而不是生成代码。比如你写了一个中间件,不确定next()的调用时机对不对,可以把代码贴给AI,问“这个中间件的执行顺序是什么,如果我不调用next()会发生什么”。这种用法能帮你建立正确的心理模型。
第二,让AI补充边界情况。你写了一个创建用户的接口,可以让AI帮你想想“这个接口有哪些边界情况没考虑到”。它可能会提醒你:请求体为空怎么办、字段类型不对怎么办、重复创建怎么办。这些你自己想可能要花不少时间。
第三,让AI帮你读报错。Node.js的报错信息有时候比较隐晦,特别是涉及异步操作的时候。把完整报错贴给AI,让它解释可能的原因和排查方向,比你自己瞎试效率高得多。
第四,关键代码自己写。路由处理逻辑、错误处理策略、数据校验规则,这些核心部分必须自己动手。AI可以帮你检查,但不能替你思考。
实操心得:我习惯在AI给出建议后,追问一句“为什么这样做,有没有其他方案”。很多时候AI的第一反应不是最优解,追问能逼出更多信息,也帮你理解不同方案之间的取舍。
3. 核心模块的编码实现与原理拆解
3.1 Express应用配置:app.js的每一行都有意义
先看src/app.js的完整代码:
const express = require('express'); const logger = require('./middleware/logger'); const usersRouter = require('./routes/users'); const errorHandler = require('./middleware/errorHandler'); const app = express(); // 解析JSON请求体 app.use(express.json()); // 解析URL编码的请求体 app.use(express.urlencoded({ extended: true })); // 自定义日志中间件 app.use(logger); // 挂载路由 app.use('/api/users', usersRouter); // 404处理 app.use((req, res) => { res.status(404).json({ code: 404, message: '接口不存在', data: null }); }); // 统一错误处理(必须放在最后) app.use(errorHandler); module.exports = app;逐段拆解。
express.json()和express.urlencoded()是内置中间件,分别处理Content-Type: application/json和application/x-www-form-urlencoded的请求体。没有这两行,req.body就是undefined。这是新手最容易踩的坑之一——前端明明发了数据,后端拿不到,排查半天发现是没加解析中间件。
app.use(logger)挂载自定义日志中间件。中间件的执行顺序就是app.use的注册顺序,所以日志中间件放在路由之前,这样每个请求都会先经过日志记录。
app.use('/api/users', usersRouter)把用户路由挂载到/api/users路径下。路由文件里定义的/、/:id等路径,实际访问时都要加上/api/users前缀。这种拆分方式让路由管理清晰很多。
404处理放在所有路由之后。如果前面的路由都没有匹配到请求,就会走到这里。注意这个中间件没有next参数,因为它就是最后一站了。
errorHandler必须放在最后。Express的错误处理中间件有四个参数(err, req, res, next),Express通过参数个数来识别它是不是错误处理中间件。如果放在路由之前,路由里抛出的错误就传不到它这里。
注意:
app.use((req, res) => {...})这个404处理有个细节——它只处理没有匹配到路由的请求。如果路由匹配到了但处理函数里抛了错,会直接跳到错误处理中间件,不会经过这个404处理。
3.2 日志中间件:记录什么、怎么记
src/middleware/logger.js:
function logger(req, res, next) { const start = Date.now(); // 响应结束后记录日志 res.on('finish', () => { const duration = Date.now() - start; const log = { method: req.method, url: req.originalUrl, status: res.statusCode, duration: `${duration}ms`, time: new Date().toISOString() }; console.log(JSON.stringify(log)); }); next(); } module.exports = logger;这个中间件做了三件事:记录请求方法、记录请求路径、记录响应状态码和耗时。
关键点在于res.on('finish', ...)。为什么不在next()之前直接打印日志?因为那时候响应还没发出去,你拿不到最终的状态码。finish事件在响应完全发送后触发,这时候res.statusCode才是最终值。
耗时计算用Date.now()取时间戳差值。这个精度对练手项目足够了。生产环境可能用process.hrtime.bigint()获取纳秒级精度,但没必要。
日志格式用JSON字符串。为什么不用模板字符串拼一个好看的格式?因为JSON格式可以直接被日志收集系统解析。你现在可能觉得无所谓,但养成这个习惯有好处——以后接入ELK或者类似系统时,不用改代码。
实操心得:日志里记录
req.originalUrl而不是req.url。originalUrl保留完整的原始路径,包括挂载点前缀。req.url在路由匹配后可能会被改写,丢失前缀信息。
3.3 参数校验中间件:把校验逻辑抽出来
参数校验是API服务里重复度最高的逻辑之一。每个创建、更新接口都要校验字段是否存在、类型是否正确。如果每个路由处理函数里都写一遍if (!req.body.name) {...},代码会非常臃肿。
我的做法是写一个校验中间件工厂函数:
// src/middleware/validate.js function validate(schema) { return (req, res, next) => { const errors = []; for (const [field, rules] of Object.entries(schema)) { const value = req.body[field]; if (rules.required && (value === undefined || value === null || value === '')) { errors.push(`${field} 是必填字段`); continue; } if (value !== undefined && rules.type && typeof value !== rules.type) { errors.push(`${field} 必须是 ${rules.type} 类型`); } if (value !== undefined && rules.minLength && value.length < rules.minLength) { errors.push(`${field} 长度不能少于 ${rules.minLength} 个字符`); } } if (errors.length > 0) { return res.status(400).json({ code: 400, message: '参数校验失败', errors }); } next(); }; } module.exports = validate;使用方式:
router.post('/', validate({ name: { required: true, type: 'string', minLength: 2 }, email: { required: true, type: 'string' } }), (req, res) => { // 到这里说明参数已经校验通过 });这个设计的好处是校验规则和业务逻辑分离。路由处理函数只关心业务,校验的事情交给中间件。加新字段只需要改schema,不用动处理函数。
当然这个校验器很简单,没有覆盖所有情况。比如邮箱格式校验、数字范围校验、嵌套对象校验都没做。但对于练手项目来说,这个程度刚好——你能看懂每一行,也能根据自己的需求扩展。
注意:校验中间件里用了
continue跳过当前字段的后续校验。这意味着如果一个字段既必填又类型不对,只会报“必填”错误。这个行为是合理的——字段都没填,讨论类型没有意义。
3.4 统一响应格式:让前端好过一点
API返回的数据格式应该统一。不要这个接口返回{ data: ... },那个接口返回{ result: ... },另一个接口直接返回数组。前端会疯。
src/utils/response.js:
function success(res, data = null, message = '操作成功') { return res.status(200).json({ code: 0, message, data }); } function fail(res, statusCode = 400, message = '操作失败', errors = null) { return res.status(statusCode).json({ code: statusCode, message, errors, data: null }); } module.exports = { success, fail };约定:code为0表示成功,非0表示失败。HTTP状态码和业务状态码分开——HTTP状态码表示请求本身的处理结果,业务code表示业务逻辑的执行结果。比如创建用户时邮箱已存在,HTTP返回200(请求处理成功),但业务code返回1001(邮箱重复)。
这个约定不是唯一的,你也可以用HTTP状态码直接表示业务错误。但分开的好处是前端可以根据code做精细化的错误提示,而不用去解析HTTP状态码。
实操心得:
data字段即使没有数据也保留,设为null。这样前端可以统一用res.data.data来取值,不用判断data字段是否存在。少写很多if。
3.5 路由实现:CRUD的完整写法
src/routes/users.js:
const express = require('express'); const router = express.Router(); const validate = require('../middleware/validate'); const { success, fail } = require('../utils/response'); // 模拟数据 let users = [ { id: 1, name: '张三', email: 'zhangsan@example.com' }, { id: 2, name: '李四', email: 'lisi@example.com' } ]; let nextId = 3; // 获取用户列表 router.get('/', (req, res) => { success(res, users); }); // 获取单个用户 router.get('/:id', (req, res) => { const id = parseInt(req.params.id, 10); const user = users.find(u => u.id === id); if (!user) { return fail(res, 404, '用户不存在'); } success(res, user); }); // 创建用户 router.post('/', validate({ name: { required: true, type: 'string', minLength: 2 }, email: { required: true, type: 'string' } }), (req, res) => { const { name, email } = req.body; // 检查邮箱是否重复 if (users.some(u => u.email === email)) { return fail(res, 409, '邮箱已被使用'); } const newUser = { id: nextId++, name, email }; users.push(newUser); res.status(201).json({ code: 0, message: '创建成功', data: newUser }); }); // 更新用户 router.put('/:id', validate({ name: { required: true, type: 'string', minLength: 2 }, email: { required: true, type: 'string' } }), (req, res) => { const id = parseInt(req.params.id, 10); const index = users.findIndex(u => u.id === id); if (index === -1) { return fail(res, 404, '用户不存在'); } const { name, email } = req.body; if (users.some(u => u.email === email && u.id !== id)) { return fail(res, 409, '邮箱已被使用'); } users[index] = { id, name, email }; success(res, users[index], '更新成功'); }); // 删除用户 router.delete('/:id', (req, res) => { const id = parseInt(req.params.id, 10); const index = users.findIndex(u => u.id === id); if (index === -1) { return fail(res, 404, '用户不存在'); } users.splice(index, 1); success(res, null, '删除成功'); }); module.exports = router;几个细节值得展开。
parseInt(req.params.id, 10)——URL参数永远是字符串,需要转成数字再比较。第二个参数10表示十进制,不写的话在某些情况下会有意外行为(比如parseInt('08')在老版本JavaScript里会被当成八进制)。养成写10的习惯。
users.some(u => u.email === email)检查邮箱重复。注意更新用户时,要排除自己:u.id !== id。否则用户不修改邮箱直接提交,会误报“邮箱已被使用”。
创建用户返回201状态码,表示资源创建成功。这是RESTful API的约定,不是强制的,但遵循约定让接口更规范。
注意:这里用内存数组模拟数据,服务重启数据就没了。这是故意的——练手项目先聚焦API逻辑,数据持久化是下一步的事。不要一开始就引入数据库,那会引入太多额外复杂度。
3.6 错误处理中间件:兜底的最后一道防线
src/middleware/errorHandler.js:
function errorHandler(err, req, res, next) { console.error('未捕获的错误:', err); // 如果响应已经发送,交给Express默认处理 if (res.headersSent) { return next(err); } const statusCode = err.statusCode || 500; const message = err.message || '服务器内部错误'; res.status(statusCode).json({ code: statusCode, message, data: null }); } module.exports = errorHandler;这个中间件捕获所有路由处理函数中抛出的错误。Express 5之前,异步函数里抛出的错误不会被自动捕获,需要手动try/catch或者用next(err)传递。Express 5开始支持自动捕获异步错误,但如果你用的是Express 4,这一点要特别注意。
res.headersSent检查——如果响应已经开始发送了,就不能再改状态码和响应体了。这时候只能交给Express的默认错误处理,它会直接关闭连接。
err.statusCode——自定义错误可以带上状态码。比如在路由里const err = new Error('用户不存在'); err.statusCode = 404; throw err;,错误处理中间件就能返回404而不是500。
实操心得:错误处理中间件里一定要打日志。我见过有人写了错误处理但没打日志,结果线上出问题完全不知道发生了什么。
console.error是最低要求,生产环境应该用专业的日志库。
4. 常见问题排查与避坑指南
4.1 启动报错排查速查表
| 报错信息 | 可能原因 | 解决方法 |
|---|---|---|
Cannot find module 'express' | 依赖没装 | 执行npm install |
EADDRINUSE: address already in use | 端口被占用 | 换端口或杀掉占用进程 |
req.body is undefined | 没加express.json() | 在路由前加解析中间件 |
Cannot read property 'xxx' of undefined | 对象层级访问错误 | 检查数据来源,加可选链?. |
ERR_REQUIRE_ESM | 模块系统不匹配 | 检查package.json的type字段 |
SyntaxError: Unexpected token | JSON格式错误 | 检查请求体JSON是否合法 |
端口被占用这个问题特别常见。排查方法:
# macOS/Linux lsof -i :3000 # Windows netstat -ano | findstr :3000找到PID后杀掉进程,或者直接在代码里换个端口。
4.2 中间件顺序引发的诡异问题
中间件顺序是Express里最容易出错的地方。我列几个典型场景。
场景一:日志中间件放在路由后面。结果路由处理完了才打日志,而且如果路由里直接返回了响应,日志中间件根本不会执行。因为Express的中间件是线性的,前面的中间件不调用next(),后面的就不会执行。
场景二:错误处理中间件放在路由前面。路由里抛出的错误传不到错误处理中间件,因为错误处理中间件在路由之前就已经执行过了。Express的错误处理是向前查找的——从出错位置往后找第一个四参数中间件。
场景三:404处理放在路由前面。所有请求都被404拦截了,路由根本匹配不到。404处理必须放在所有路由之后。
正确的顺序是:解析中间件 → 日志中间件 → 路由 → 404处理 → 错误处理。
4.3 异步错误捕获的坑
这是Express 4的一个经典问题。看这段代码:
router.get('/test', async (req, res) => { const data = await someAsyncFunction(); // 如果这里抛错 res.json(data); });如果someAsyncFunction抛错,Express 4不会自动捕获,错误会变成未处理的Promise rejection,进程可能直接崩溃。错误处理中间件也收不到这个错误。
解决方案有三种:
第一种,手动try/catch:
router.get('/test', async (req, res, next) => { try { const data = await someAsyncFunction(); res.json(data); } catch (err) { next(err); } });第二种,写一个包装函数:
const asyncHandler = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; router.get('/test', asyncHandler(async (req, res) => { const data = await someAsyncFunction(); res.json(data); }));第三种,升级到Express 5。Express 5会自动捕获异步错误,不需要额外处理。但Express 5还在beta阶段,生产环境慎用。
我推荐第二种方案。asyncHandler写一次,所有异步路由都能用,代码也干净。
4.4 环境变量管理的注意事项
.env文件不要提交到git。这是铁律。API密钥、数据库密码这些敏感信息一旦提交,就等于公开了。
.gitignore里加上:
node_modules/ .env *.log同时提供一个.env.example作为模板:
PORT=3000 NODE_ENV=development别人克隆你的项目后,复制.env.example为.env,填入自己的值。
加载环境变量的代码放在server.js最顶部:
require('dotenv').config(); const app = require('./app'); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`服务已启动,监听端口 ${PORT}`); });require('dotenv').config()必须在其他模块之前执行,否则其他模块里读process.env会拿到undefined。
实操心得:
process.env.PORT || 3000这个写法很常见,但有个小问题——如果PORT被设成了空字符串,||会返回3000。更严谨的写法是process.env.PORT ?? 3000,空字符串也会被保留。不过实际项目中很少有人把PORT设成空字符串,所以两种写法都行。
4.5 AI生成代码的审查要点
用AI辅助写代码,审查环节不能省。我总结几个重点检查项。
检查错误处理。AI生成的代码经常忽略错误处理,或者只做最基础的try/catch。你要确认:异步操作的错误有没有被捕获、错误有没有传递给错误处理中间件、错误信息会不会泄露敏感数据。
检查边界条件。AI倾向于处理“正常情况”,对边界条件的覆盖不够。比如数组为空、参数为null、数字为0、字符串为空串,这些情况要自己过一遍。
检查安全性。AI生成的代码可能包含SQL注入风险(如果涉及数据库)、XSS风险(如果涉及HTML输出)、敏感信息硬编码等问题。练手项目虽然不涉及真实数据,但养成审查习惯很重要。
检查依赖版本。AI可能生成基于旧版本API的代码。比如它可能用body-parser而不是express.json(),用request而不是fetch。这些要手动更新到当前推荐的做法。
5. 项目扩展方向与进阶建议
5.1 从内存存储到真实数据库
当前项目用内存数组存数据,重启就丢。下一步自然是接入数据库。选择上,练手项目我推荐SQLite——零配置、单文件、不需要额外安装服务。用better-sqlite3或者knex都可以。
接入数据库后,路由处理函数里的users.find、users.push要替换成数据库查询。这时候你会发现,把数据访问逻辑抽到单独的models或repositories层是必要的。路由处理函数不应该关心数据存在哪里、怎么查。
5.2 加上接口文档和测试
接口写完了,怎么让别人知道怎么调?手写文档容易过时,推荐用Swagger/OpenAPI。在代码里写注释,自动生成文档页面。swagger-jsdoc和swagger-ui-express这两个包配合使用,效果不错。
测试方面,supertest+jest或者vitest是常见组合。supertest可以直接测试Express应用,不需要真的启动服务。写几个核心接口的测试用例,以后改代码时跑一遍,能快速发现回归问题。
5.3 部署上线的注意事项
练手项目部署到服务器上,有几个点要注意。
进程管理用pm2。直接node src/server.js启动,终端一关服务就停了。pm2能让服务在后台运行,还支持自动重启、日志管理、集群模式。
npm install -g pm2 pm2 start src/server.js --name mini-api pm2 save pm2 startup反向代理用Nginx。Node.js直接监听80端口需要root权限,而且Nginx能处理静态文件、SSL终止、负载均衡这些事。Nginx配置里把请求转发到Node.js的端口就行。
环境变量在服务器上通过pm2的--env参数或者系统的环境变量设置,不要依赖.env文件。.env文件适合开发环境,生产环境应该用更安全的配置管理方式。
5.4 继续用AI辅助的进阶玩法
项目搭起来之后,AI还能在哪些环节帮上忙?
代码审查。把路由文件贴给AI,让它从安全性、性能、可读性三个角度提改进建议。它可能会指出你没注意到的N+1查询问题、缺少索引、错误信息泄露等。
生成测试用例。告诉AI你的接口定义和预期行为,让它生成测试用例。你审查和补充边界情况。这比从零写测试快很多。
排查性能问题。如果某个接口响应慢,把相关代码和日志贴给AI,让它分析可能的瓶颈。它可能会提醒你检查数据库查询、检查是否有同步阻塞操作、检查是否有内存泄漏。
学习新概念。遇到不懂的中间件、设计模式、架构方案,直接问AI。让它用生活化的例子解释,比看文档快。
实操心得:AI给出的建议不要照单全收。它有时候会过度设计,给一个练手项目推荐微服务架构或者复杂的缓存策略。记住你的项目规模和目标,只采纳匹配当前阶段的建议。
5.5 我踩过的几个坑
最后分享几个我在做这个项目时实际踩过的坑,希望能帮你省点时间。
第一个坑:express.json()的limit默认是100kb。如果你要接收大一点的JSON请求体,需要手动设置express.json({ limit: '1mb' })。我一开始不知道,传了一个稍大的数组就报PayloadTooLargeError,排查了好一会儿。
第二个坑:req.params和req.query的类型。两者都是字符串。req.params.id是"1"不是1。比较的时候要么转数字,要么用==(不推荐)。我习惯用parseInt转一下,明确意图。
第三个坑:路由路径的尾部斜杠。/api/users和/api/users/在Express默认配置下是不同的路径。strict routing关闭时(默认),两者等价。但如果你开启了strict routing,就要注意了。我建议统一不加尾部斜杠,避免混淆。
第四个坑:res.json()和res.send()的区别。res.json()会自动设置Content-Type: application/json,res.send()根据内容类型自动判断。返回JSON数据时用res.json()更明确。我见过有人用res.send返回对象,结果Content-Type不对,前端解析失败。
第五个坑:错误处理中间件的next参数不能省。即使你不用它,也必须写四个参数(err, req, res, next)。Express就是靠参数个数来识别错误处理中间件的。少写一个参数,它就被当成普通中间件了,错误不会传给它。
这些坑都不大,但每一个都可能让你多花半小时排查。提前知道,就能绕过去。