先说结论: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。
然后是安装 Express:
npm 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?author=joe&page=1。
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.log
console.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 压测与性能摸底
上线之前做一次压测,能发现很多隐蔽问题。最轻量的工具是ab(ApacheBench),比如测试单个接口的 QPS:
ab -n 10000 -c 100 http://localhost:3000/articles这个命令的意思是:一共发送 10000 个请求,同时保持 100 个并发连接。跑完之后看每秒请求数和响应时间分布。如果 QPS 低于预期,别急着怀疑 Express,先检查你的数据库有没有加索引,有没有慢查询。
另外建议了解一下autocannon,它是 Node 开发者比较喜欢的压测工具,使用起来更灵活。
写到最后,我分享一个自己的小习惯:每次新建 Express 项目,我都会花 10 分钟把目录结构、配置中心、错误处理、日志这几件事一次性搭好,而不是“等以后再加”。“以后再加”的结果通常是项目已经写了一百个接口,再想加全局错误处理,就要面对一百个接口里散落的 try/catch,改动成本瞬间变高。
我的体会是,Express 的简单恰恰要求你把工程化的纪律装进心里。它不强制你组织目录、不强制你统一错误格式、不强制你打日志,但恰恰是这种克制,让你能掌控自己项目的每一块组织方式。框架能做的是帮你把 HTTP 身体的活干好,而让代码库长期保持健康的,还是你自己。