先说结论Express 是 Node.js 生态里生命力最强的 Web 框架没有之一。它不 fancy也不“全栈”但它用极简的中间件模型把 HTTP 请求处理这件事拆得明明白白以至于后来一大堆框架——包括 NestJS、Fastify 的很多设计思路——都得叫它一声前辈。这篇文章不是官方文档翻译也不是照抄入门教程。我打算从环境准备、核心原理、完整实操、常见坑位这几个角度把 Express 拆开揉碎讲清楚。目标是让刚接触 Node.js 的新手能照着扫完环境、写完第一个接口也让写过一阵子但没深入过中间件机制的同学对“为什么 Express 长这样”有个通透的理解。1. 环境准备先把 Node.js 装明白1.1 LTS 版本怎么选很多新手栽的第一个跟头不是代码问题而是版本选择问题。你去 Node.js 官网nodejs.org会看到两个下载按钮一个是 LTS一个是 Current。LTS 是 Long Term Support长期维护版我个人的习惯是除非项目必须用新特性否则一律 LTS。为什么因为 Express 本身就是个相对保守的框架它的核心依赖比如 body-parser、serve-static 这些对 Node 版本的兼容性要求是“稳”优先于“新”。你装一个 Current 版表面上看没什么问题但等你部署到服务器或者团队其他人用的是 LTS就有可能出现版本行为不一致。这种问题排查起来特别头疼。选 LTS 还有一个更实际的好处npm 生态里绝大多数的包都会优先保证对 LTS 的兼容。你装依赖的时候很少会碰到“这个包只支持 Node 20”但你还在跑 Node 16 的尴尬。1.2 Windows、macOS、Linux 安装差异Windows 上装 Node.js 最简单的方式是直接下载 .msi 安装包双击装完node -v和npm -v就都能用了。macOS 上我推荐用 nvm 装而不是官网 pkg因为 nvm 可以随时切换版本对同时维护多个项目的场景非常友好。Linux 服务器上则要分两种情况如果用的是 apt 或 yum直接装的版本通常偏老所以更靠谱的路径是去官网下载 tar.xz 压缩包解压后配置一下 PATH 环境变量。这里有个细节不要在解压目录下手动ln -s软链到 /usr/bin那样升级的时候容易留下脏链接。更干净的做法是在 /etc/profile.d/ 下新建一个脚本把解压目录写进 PATH。装完以后第一步永远是确认三件事node -v npm -v which nodewhich node这步很多人会忽略。如果你曾经装过旧版 Node或者你系统里已经有一个通过其他方式安装的 Node那么node命令指向的不一定是你刚装的这个版本。我之前就见过同事的服务器上node -v显示 v18但实际跑服务的那份 Node 还是老的 v14排查了半天才发现是 PATH 优先级问题。1.3 初始化项目package.json 到底怎么写环境装好接下来要面对的就是项目的初始化。传统的做法是mkdir express-demo cd express-demo npm init -y-y参数会帮你跳过提问生成一份默认的 package.json。但我觉得这里不应该一味图快有几个字段值得手动改一下。第一main字段。默认生成的是index.js如果你实际入口文件叫app.js或者server.js就要改过来。第二scripts字段。默认只有 test我会习惯性地加上start脚本scripts: { start: node app.js }这样后续启动服务只需要npm start就行不用每次敲node app.js。然后是安装 Expressnpm install express装完以后你会看到 package.json 里多了一个dependencies字段里面写着express: ^4.19.2。这里的^符号意味着 npm 会在 4.x 范围内自动匹配最新版本这对开发来说是方便但对生产环境来说我会用npm ci配合 package-lock.json 锁定依赖确保部署的每一台机器上依赖行为完全一致。提示如果你在国内网络环境下安装依赖经常超时可以考虑设置镜像源但注意镜像源有时候会有延迟刚发布的版本可能拉不到后面我会专门讲到这个坑。2. Express 的设计思路为什么中间件是它的灵魂2.1 从原生 http 到 Express它解决了什么在 Express 出现之前用 Node.js 写一个 HTTP 服务长这样const http require(http); const server http.createServer((req, res) { if (req.url /) { res.end(Home); } else if (req.url /users) { res.end(User list); } else { res.statusCode 404; res.end(Not Found); } }); server.listen(3000);看着不复杂但如果你要写一个真实项目马上会撞到几堵墙路由越来越多if/else嵌套变得没法维护每个请求都要手动解析 query 字符串和 body要想逻辑复用——比如每个接口都要校验 token——你只能复制粘贴或者用高阶函数包一层代码很快就臭了。Express 的核心回答是把所有与“处理一个具体请求”相关的逻辑都抽象成中间件函数。每个中间件拿到req、res和next三个参数它既可以修改这三个对象也可以决定是继续往下传递还是直接终结响应。于是校验 token、解析 body、记录日志、路由分发所有这些关注点都可以拆成独立的函数按顺序插在请求管线上。2.2 中间件机制与请求处理管线理解中间件的最形象方式是想象一条流水线请求从上游进来先经过第一个中间件它处理完可以调用next()把请求交给下一个下一个再传给下一个直到某个中间件直接res.send()返回响应。如果某个中间件不调用next()请求就卡在那里了。Express 官方文档里那张图就是典型的洋葱模型请求进入 - 中间件1 - 中间件2 - 路由处理器 - 响应返回但要说明的是真正的洋葱模型是“穿进再穿出”比如日志中间件在next()之前记请求时间在next()之后记结束时间就能精确计算整个请求的耗时。这就是中间件的灵活之处你可以在调用链的前后端各放一段逻辑。路由本质上也是一种中间件只不过它带了匹配规则的中间件。app.get(/user, handler)的意思是当请求方法是 GET 且路径匹配/user时把这个 handler 挂到处理链上。如果路径不匹配这个中间件会静默跳过让请求继续往下走。2.3 为什么我暂时没换 Fastify现在提到 Express 的对比对象绕不开 Fastify。Fastify 的优势是性能更好——它用了更高效的 JSON 序列化方案处理压力高的时候吞吐量确实强。但它和 Express 在中间件模型上有个本质差异Fastify 的插件体系是封装上下文而且它的req和reply是自定义对象不能直接使用大部分 Express 中间件。对大部分业务团队来说Express 的生态优势是压倒性的。你想要 session、模板引擎、文件上传、OAuth、ORM 集成几乎都有一个成熟的 Express 中间件直接装。Fastify 的生态这几年也在追但遇到一些冷门需求时你可能需要自己写适配器。这不是说 Fastify 不好而是选型要看团队熟悉度和生态覆盖度。我自己的判断标准是如果项目是 API 网关或者代理这种对吞吐量极其敏感的场景可以考虑 Fastify如果项目是典型的业务后端哪怕性能要求高也建议先在 Express 上做压测确认瓶颈真的是框架本身再考虑换不换。大多数时候瓶颈都在数据库查询和漏加的索引上框架那点差距根本不算什么。实操心得不要为了性能预优化。先把 Express 跑起来用压测工具测出数据再谈优化。你想象中的性能问题和实际测出来的性能问题往往不是同一个。3. 实操从零写一个带鉴权和错误处理的 API 服务3.1 路由设计页面路由与接口路由分开很多新手写 Express 的第一版项目会把所有接口都堆在 app.js 里。三十个接口之后这个文件就变成了没人敢碰的屎山。合理的做法是从一开始就按业务域拆分路由。我习惯在项目里建一个routes目录每个业务模块一个文件。比如这里我们做一个极简的博客 API就分成routes/articles.js和routes/auth.js。app.js只负责挂载路由const express require(express); const app express(); const authRouter require(./routes/auth); const articlesRouter require(./routes/articles); app.use(express.json()); app.use(/auth, authRouter); app.use(/articles, articlesRouter); app.listen(3000);这里有个重要细节app.use(/articles, articlesRouter)意味着路由文件里所有的路径都会自动带上/articles前缀。所以在articles.js里写路由时不需要再重复写/articles直接写/、/:id就行。前缀的好处是如果你要改版本号比如从/v1/articles变成/v2/articles只需要改 app.js 里一行代码路由文件完全不用动。3.2 参数解析query、params、body 怎么取写接口必考的第一个基本功就是三种参数的取法。路径参数req.params适合从 URL 里取资源标识比如/articles/123里的123。router.get(/:id, (req, res) { const id req.params.id; res.json({ id }); });查询参数req.query适合取筛选条件比如/articles?authorjoepage1。router.get(/, (req, res) { const { author, page 1 } req.query; res.json({ author, page }); });请求体req.body这个必须强调Express 默认是不会解析请求体里 JSON 的。在 Express 4 里你必须在路由之前挂载express.json()这个内置中间件req.body才会自动解析。如果你没挂载req.body会是undefined而不是空对象这个细节真的很坑。app.use(express.json()); // 现在 req.body 可以拿到 JSON 内容了 router.post(/, (req, res) { const { title, content } req.body; res.json({ title, content }); });express.json()还有一些选项比如限制请求体大小我建议默认就加上限制防止有人传一个超大的 JSON 打爆你的内存app.use(express.json({ limit: 1mb }));3.3 静态资源和 CORS前后端联调必备如果你用 Express 托管一个前端页面比如 Vue 或 React 构建后的 dist 目录需要挂载静态资源中间件app.use(express.static(public));这样访问http://localhost:3000/css/style.css就会自动对应到public/css/style.css。这个中间件还有很多细节设置比如index、maxAge这里不展开但至少要知道它干这件事。CORS 是前后端分离项目里必然遇到的问题。当你的前端跑在http://localhost:8080后端跑在http://localhost:3000浏览器会因为同源策略拦截跨域请求。最简单的方式是用cors这个包const cors require(cors); app.use(cors());全开放 CORS 适合开发阶段生产环境建议配置白名单app.use(cors({ origin: [https://yourdomain.com] }));3.4 错误处理中间件与 404 兜底新手写 Express 容易把每个路由内部的异常用try/catch包一遍这样写不仅累而且容易漏。Express 提供了统一的错误处理中间件机制任何中间件或路由里执行了next(err)错误处理中间件就会被触发。最佳实践是业务代码里不做try/catch把异常丢给 Express 统一处理。比如在异步路由里Express 4 不能直接捕获 async 函数里的异常如果你用了async/await标准写法是手动包一层。Express 5 已经原生支持 async 异常捕获但一般项目还是用 Express 4所以这里有个小技巧const wrapAsync (fn) (req, res, next) { Promise.resolve(fn(req, res, next)).catch(next); }; router.get(/:id, wrapAsync(async (req, res) { const post await db.findPost(req.params.id); if (!post) { const err new Error(Post not found); err.status 404; throw err; } res.json(post); }));然后在 app.js 的最后挂一个错误处理中间件app.use((err, req, res, next) { console.error(err.stack); res.status(err.status || 500).json({ message: err.message || Internal Server Error }); });别忘了 404 兜底。在路由全部挂载完后加一段app.use((req, res) { res.status(404).json({ message: Not Found }); });这样即使有人请求一个不存在的路径也会收到一个规范的 JSON 响应而不是浏览器默认的错误页。注意错误处理中间件必须放在所有路由之后。如果你放在路由前面它根本不会被触发因为请求在进入错误中间件之前就已经被路由处理并返回了。4. 常见问题与避坑实录我踩过的那些雷4.1 “v24.21.0 is not yet released”——版本号与镜像的坑这个报错信息看起来很奇怪为什么 Node.js v24.21.0 没有发布你却安装失败其实真实场景通常是你安装某个依赖时npm 告诉你它需要 Node.js 的某个版本而这个版本号并不存在于你当前使用的 Node 版本管理工具里。最常见的原因是用了 nvm但 nvm 的远程列表还没更新。nvm 从镜像拉取版本列表如果镜像同步有延迟你就可能执行nvm install 24.21.0却被告知版本不存在。解决方法很简单先执行nvm ls-remote看看实际能获取到哪些版本选一个可用的 LTS 版本安装或者更新镜像源。另一个高频场景是项目里配置了engines字段指定了过高的 Node 版本而你的机器或 CI 环境上 Node 版本不够。我一般建议engines写一个保守的版本下限并配合engines-strict检查。更稳妥的做法是部署环境统一用 Docker 镜像开发环境统一用 nvmrc 文件锁定 Node 版本这样基本不会遇到不一致。4.2 端口被占用EADDRINUSE 怎么处理启动 Express 服务时如果看到Error: listen EADDRINUSE: address already in use :::3000说明 3000 端口已经被某个进程占了。最直接的处理lsof -i :3000找到进程 PID 后杀掉它kill -9 PID如果这个端口经常被你本机的其他服务占用我建议在代码里做一个端口探测自动找下一个空闲端口。但这种方式只适合本地开发不适合生产环境。生产环境直接用PORT环境变量指定一个固定端口更合理。还有一个细节要注意有些占用端口的进程其实是之前没关掉的 Node 服务。我在 Windows 上见过node.exe在后台残留这时候去任务管理器杀掉所有 Node 进程是最快的办法。不过在 Linux 服务器上执行pkill node要小心它会杀掉所有 Node 进程包括和你共享服务器的同事的服务。4.3 Express 和 SQL Server Express同名不同命的误会有一个很容易踩的坑是很多初学者在搜索“Express 安装教程”的时候会搜到 SQL Server Express。实际上Node.js Express是 Web 框架SQL Server Express是微软的免费数据库引擎两者除了名字都含“Express”可以说毫无关系。类似的情况还有Express Engineering Research、各种以 Express 命名的工具或服务。我的建议是搜索时加上限定词比如“Express Node.js framework”、“Express 中间件教程”就能排除掉大半不相关的内容。当然如果你的项目确实还要连 SQL Server Express那写法是另外一套。Express 框架本身不关心你用什么数据库你只需要选择对应的数据库驱动比如mssql模块或sequelize这个 ORM然后在路由里调驱动的方法即可。这个话题可以写一整篇文章这里不展开。4.4 请求体解析body-parser 与 express.json 的区别很多教程里会让你安装body-parser这个包然后app.use(bodyParser.json())。但在 Express 4.16 之后Express 已经内置了express.json()功能和 body-parser 完全一致不需要额外安装额外依赖。我见过不少项目还特意装一个 body-parser纯属多余。这里有版本差异需要注意Express 3 没有内置 JSON 解析必须手动引入别的中间件Express 4 内置了但可以用express.json()来配置参数Express 5 的情况更复杂其中有一些 api 变更。所以如果你看的是两三年前的博客很可能被引导去安装 body-parser。看文档永远比看博客靠谱尤其是涉及版本迭代的时候。express.json()和express.urlencoded({ extended: true })是两个最常用的内置中间件。前者解析 JSON后者解析 form 表单格式的请求体。如果你的接口只接收 JSON那挂一个express.json()就够了。4.5 nodemon 与热更新开发效率翻倍的配置每次改完代码手动重启服务时间一长你会觉得 Node 开发很别扭。nodemon 就是解决这个问题的工具监听文件变化自动重启 Node 进程。安装方式npm install -D nodemon建议安装为项目的开发依赖而不是全局安装这样团队成员拉下代码后npm install就能获得一致的开发工具。然后在 package.json 中加一条脚本scripts: { dev: nodemon app.js }之后开发时用npm run dev改完代码保存服务秒级重启。这是个很小的习惯但显著影响开发体验。不要在生产环境用 nodemon生产环境应该老老实实npm start跑稳定的进程。5. 从能跑到跑得好几个值得养成的习惯5.1 用环境变量管理配置新手最容易犯的一个问题就是把数据库密码、JWT 密钥直接硬编码写在代码里。这个坏习惯一旦养成后面部署到生产环境的时候要么在代码里做一套极其丑陋的if (process.env.NODE_ENV production)分支要么就把密码提交到了 Git 历史里造成安全隐患。我的做法是统一用一个config.js文件管理所有配置项const config { port: process.env.PORT || 3000, db: { host: process.env.DB_HOST || localhost, user: process.env.DB_USER || root, password: process.env.DB_PASSWORD }, jwtSecret: process.env.JWT_SECRET || dev-secret-do-not-use-in-prod }; module.exports config;本地开发用.env文件配合dotenv包来设置环境变量服务器部署时在系统环境变量里设置真实值。这样代码库本身不放任何真实密钥只在本地留一个.env.example作为模板。5.2 日志与调试不要只会 console.logconsole.log 本身没什么问题调试阶段很好用但生产环境请不要依赖它。第一console.log 是同步的在高并发请求下会影响性能第二日志不结构化很难用日志平台做告警和分析。我推荐使用winston或pino这类日志库输出 JSON 格式的日志附带请求 ID、时间戳、耗时等字段。开发环境可以打印漂亮的可读格式生产环境输出 JSON。这个改造成本不高但后续排查线上问题时帮助巨大。这里上一个简单的 pino 接入示例const pino require(pino); const logger pino({ level: process.env.LOG_LEVEL || info }); // 在请求入口处记录日志 app.use((req, res, next) { const start Date.now(); res.on(finish, () { logger.info({ method: req.method, url: req.url, status: res.statusCode, duration: Date.now() - start }); }); next(); });5.3 压测与性能摸底上线之前做一次压测能发现很多隐蔽问题。最轻量的工具是abApacheBench比如测试单个接口的 QPSab -n 10000 -c 100 http://localhost:3000/articles这个命令的意思是一共发送 10000 个请求同时保持 100 个并发连接。跑完之后看每秒请求数和响应时间分布。如果 QPS 低于预期别急着怀疑 Express先检查你的数据库有没有加索引有没有慢查询。另外建议了解一下autocannon它是 Node 开发者比较喜欢的压测工具使用起来更灵活。写到最后我分享一个自己的小习惯每次新建 Express 项目我都会花 10 分钟把目录结构、配置中心、错误处理、日志这几件事一次性搭好而不是“等以后再加”。“以后再加”的结果通常是项目已经写了一百个接口再想加全局错误处理就要面对一百个接口里散落的 try/catch改动成本瞬间变高。我的体会是Express 的简单恰恰要求你把工程化的纪律装进心里。它不强制你组织目录、不强制你统一错误格式、不强制你打日志但恰恰是这种克制让你能掌控自己项目的每一块组织方式。框架能做的是帮你把 HTTP 身体的活干好而让代码库长期保持健康的还是你自己。