做Node服务端开发的人,几乎都绕不开Express。哪怕新框架隔三差五冒出来,Express依然稳稳占着“老而弥坚”的位置:很多公司线上跑着的接口还是它写的,很多新人学Node后端的第一课也是它。这次精读,我准备把Express基础篇完整过一遍,从中间件机制、路由匹配到请求响应这条完整链路,把骨架拆开看明白,顺手整理几个实际开发里最容易踩的坑。这一篇就是基础篇的第一部分,适合刚接触Node后端、或者会用Express但没深究过内部逻辑的开发者。如果你也写过类似的代码却说不清为什么这样写,这篇正好帮你补齐这段空白。
1. 为什么精读Express?先看清它在Node生态里的位置
1.1 从原生http模块到Express,到底省了什么
先回到最原始的方式。用Node原生http模块写一个接口,你必须手动处理method判断、url解析、响应头设置,写出来的代码长这样:
const http = require('http'); http.createServer((req, res) => { if (req.method === 'GET' && req.url === '/user') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ name: 'amy' })); return; } res.writeHead(404); res.end('Not Found'); }).listen(3000);这段代码本身不难,但问题藏在后面:一旦业务复杂起来,这个if判断会疯狂膨胀。参数解析要自己写,鉴权要自己写,日志要自己打,错误处理又是另一套逻辑,全部混在同一个回调函数里,改一处就得牵动一大片。我见过不少老项目就是这么堆到几千行的,维护起来极其痛苦。
Express的解法,是把整个处理流程拆成一个个可组合的函数,每个函数只负责一件事,再按顺序串成一条处理链。这就是它和原生http最本质的区别,也是精读Express时最值得先理解的点。后面你会看到,所有看起来神奇的功能,比如路由、JSON响应、静态文件,本质上都是这条处理链上的一个环节。把这个模型刻在脑子里,后面学什么都是顺水推舟。
1.2 中间件机制:Express的灵魂设计
说到处理链,就绕不开中间件。Express的官方定义比较绕,我习惯用一个流水线来类比:请求从一端进来,像工件一样沿着传送带往前走,经过一个工位处理一下,再传给下一个工位。每个工位就是一个中间件,它可以读取请求、修改响应,也可以决定是否放行,甚至直接终止整条流水线。
一个中间件函数签名的标准写法是(req, res, next)。第三个参数next是关键,它表示“处理完了,放行到下一个工位”。如果你在中间件里忘了调用next,请求就会卡死在当前这一步,客户端那边表现为请求一直转圈,直到超时。这是我见过新手最容易犯的问题之一,不是逻辑写错,而是流程压根没走下去。
给它串起来看就很好懂了:
app.use((req, res, next) => { console.log('第一个中间件:记录日志'); next(); }); app.use((req, res, next) => { console.log('第二个中间件:解析请求体'); next(); }); app.get('/hello', (req, res) => { res.send('世界你好'); });如果请求的是/hello,控制台会依次打印两行日志,最后返回响应。如果请求的是/other,前两个中间件照样执行,但因为没有对应的路由,请求会在流水线末端得不到处理,Express最终会给出默认的404。这套机制的好处一目了然:通用的逻辑被抽成独立片段,可以重复挂载,可以按需增删,组合起来非常灵活。这也是Express能被无数项目长期使用的原因,精读它,本质就是在精读这套机制。
2. 一个请求的生命周期:请求进来之后发生了什么
2.1 中间件注册顺序,为什么能决定程序行为
理解了中间件是流水线之后,下一步要逼问自己:一个请求进来,Express内部到底按什么顺序执行这些函数?答案是注册顺序。所有app.use()、app.get()、app.post()注册的处理函数,会按代码出现的先后顺序组成一个队列。请求进入时,Express从头到尾遍历这个队列,把能匹配上的函数一个个执行。
这里的“匹配”要分两种来看。app.use()不带路径,或者只带路径前缀时,是前缀匹配:比如app.use('/api', fn),只要请求路径以/api开头就会执行。而app.get('/user', fn)这类路由方法则是精确匹配:路径必须完全一致,并且HTTP方法也得对得上,才会执行。同一个请求完全可以被多个中间件处理,这就是为什么日志中间件可以挂在最前面,而具体业务的响应可以放在最后面。
顺序的影响非常直观。如果把日志中间件放到路由之后注册,日志就永远打不出来,因为请求在走到日志那一步之前,已经被路由直接响应、结束流程了。这个道理听起来简单,排查问题的时候却特别容易被忽略。我调过不少别人的代码,第一眼看到问题表现为“接口能通但日志没打”,第一反应去看日志逻辑本身,最后发现只是注册顺序写反了。记住一条原则:越通用、越前置的中间件,越要放在文件靠前的位置。
2.2 内置解析与静态资源:基础服务的另一半
一个Web服务总不能天天只返回字符串。接收POST表单、读取JSON请求体、托管图片和前端页面,这些都是最基础的能力。Express把几个常用功能做成了内置中间件,挂上就能用,其中一个就是请求体解析。
app.use(express.json()); app.use(express.urlencoded({ extended: true }));express.json()用来解析Content-Type为application/json的请求体,解析结果挂到req.body上。express.urlencoded()对应表单提交,也就是application/x-www-form-urlencoded。这里有个extended参数值得细说:它决定嵌套对象的解析方式。设置为true时使用qs库,支持对象嵌套和数组;设置为false时使用Node自带的querystring,只支持扁平的键值对。大多数场景下建议开成true,避免前端传来一个嵌套对象结果被拆得七零八落。
静态文件服务是另一个高频需求。express.static('public')会把public目录下的文件直接对外开放。默认情况下,你访问/images/logo.png,对应的是文件系统里的public/images/logo.png。这里有个经典误区:很多人以为express.static('public')是把public目录本身暴露在根路径下,于是去请求/public/images/logo.png,结果404。实际上要想URL里带public前缀,你得这么写:
app.use('/public', express.static('public'));把/public这个前缀和静态目录映射关系讲清楚,这类路径问题就解决了一半。静态文件中间件也遵守同样的队列规则,放在前面就是先找文件,找不到再继续往后走路由;放在后面则是先走完业务逻辑再看静态文件。这个先后顺序,完全取决于你的项目需要。
3. 把核心API逐个精读:方法、属性与行为细节
3.1 路由方法与路由参数:URL是怎么被拆出来的
Express路由方法对应HTTP动词,除了常用的GET、POST、PUT、DELETE,还有app.all()用来匹配所有方法。写清楚它们各自的边界条件很重要:app.use()不区分HTTP方法,只要路径前缀匹配就执行;app.all('/user')区分路径但不区分方法,所有动词请求/user都会命中。这个差别在实际开发里经常用到,比如给某组接口统一加鉴权,用app.use('/admin', authMiddleware)比逐个路由挂中间件省事得多。
路由参数是另一个必懂的基础。URL里的动态部分用冒号声明:
app.get('/user/:id', (req, res) => { console.log(req.params.id); });此时请求/user/123,req.params.id就是字符串'123'。注意它永远是字符串类型,如果你要拿它当数字用,比如查数据库时传主键ID,自己做好类型转换。多个参数同理:/user/:uid/post/:pid,对应req.params.uid和req.params.pid。
和req.params一起出现的还有req.query,对应URL里?后面的查询串。/search?keyword=node&page=2这样请求过来,req.query就是{ keyword: 'node', page: '2' },同样全是字符串。这两个属性一个管路径里的动态段,一个管查询参数,是路由层拿到输入的主要来源。刚开始学时容易把req.params和req.query记混,我的记忆方法是:params是路径的一部分,query是问号之后的部分。
3.2 响应对象的方法差异:什么时候用send,什么时候用json
响应对象res身上那堆方法看着多,其实可以归成几类。最常用的是res.send()和res.json()。res.send()会根据传入内容自动设置Content-Type:传字符串就按文本处理,传对象或数组就自动JSON序列化并设置application/json。res.json()则专门用于返回JSON,内部其实会把对象转为JSON字符串并设置好响应头。
既然res.send()能自动序列化对象,那我为什么还推荐接口开发统一用res.json()?一是语义清晰,看到json()就知道这接口返回的是结构化数据。二是避免踩类型推断的坑。如果传一个null给res.send(),它的处理逻辑和传对象的处理方式并不完全一致。我更愿意把行为固定下来,减少意外。写接口时另外一个常用组合是res.status(201).json({...}),先设置状态码再返回数据,链式调用非常顺手。
res.end()也是新手会遇到的。它只结束响应,不帮你做任何序列化或头部处理。一般不建议直接用,除非你真的只是想快速结束一个空响应。还有个res.redirect()用于重定向,res.render()用于渲染模板,基础篇里不展开,但你要知道它们同样会结束流水线,调用了就不要指望后面还有中间件继续处理。
3.3 错误处理中间件:四个参数才是真正的错误处理
错误处理是Express里最容易被误解的一块。普通的中间件签名是两个或三个参数,错误处理中间件必须有四个参数,而且顺序一个都不能错:
app.use((err, req, res, next) => { console.error(err.stack); res.status(500).json({ message: '服务器内部错误' }); });为什么多一个err参数就能被Express识别为错误处理中间件?因为Express在内部检查函数.length,也就是函数的形参个数。四个参数就走错误处理分支,三个及以下就走普通中间件分支。因此,把这个函数放在路由之后注册,当作兜底。当某个中间件调用next(err)时,Express会跳过后面所有普通中间件,直接跳到最近的错误处理中间件。
这里有个细节容易忽略:如果中间件里直接throw new Error(...),同步错误会被Express捕获并转到错误处理;但如果是异步操作里的异常,比如setTimeout里抛错,Express根本接不到。这是Express 4的知名痛点,后面我会专门讲怎么处理。另外还要分清404和错误处理的区别:404是“没有路由匹配”,不是程序错误,所以不需要走到错误处理中间件,而是在所有路由之后挂一个普通中间件,统一返回404JSON即可。这两类兜底逻辑分开写,不要混在一起。
4. 动手精读实践:从零搭一个可复用的基础应用骨架
4.1 四行代码跑起来的最小应用
说了这么多机制,不落地等于白聊。先用最少的代码把服务跑起来,验证前面的所有概念。
const express = require('express'); const app = express(); app.get('/', (req, res) => { res.send('hello express'); }); app.listen(3000, () => { console.log('app listening on port 3000'); });新建一个目录,执行npm init -y,再执行npm i express,把上面这段存成app.js,运行node app.js,浏览器打开http://localhost:3000,看到hello express就算成功。这段代码里就藏着全套流程:express()创建应用,app.get()注册路由,listen()启动HTTP服务。你把express()创建出来的这个app理解为一条空流水线,后续所有use和get都是在往流水线上摆工位,listen()就是按下流水线的启动开关。理解到这一层,看任何Express项目的入口文件都不会再发怵。
4.2 按模块拆分路由:入口文件不该越写越长
如果所有路由都堆在app.js里,项目到后期一定没法看。Express提供了Router对象来拆分路由模块,这是实战项目必不可少的一步。先创建routes/user.js:
const express = require('express'); const router = express.Router(); router.get('/:id', (req, res) => { res.json({ userId: req.params.id }); }); router.post('/', (req, res) => { res.json({ action: 'create user' }); }); module.exports = router;然后在入口文件里挂载它:
const userRouter = require('./routes/user'); app.use('/user', userRouter);这样所有/user开头的请求都会交给userRouter处理。注意router.get('/:id')在挂载之后对应的完整路径是/user/:id,因为挂载时指定的前缀/user会拼接在前面。这种模块化写法让每个文件只关心自己的一片业务,新增一个用户接口就只改用户路由文件,不会牵扯全局。我习惯的项目结构还会再加一层middleware/目录放自定义中间件,controllers/目录放业务处理函数,路由文件只做路径分发。基础篇先把路由拆开,已经能解决大部分可维护性问题。
4.3 配置管理和启动细节:端口与环境变量
接下来是启动环节的细节。把端口写死在代码里,换个环境就得改代码,不优雅。推荐直接读环境变量:
const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`server running at http://localhost:${PORT}`); });这样在服务器上部署时,通过环境变量注入端口即可,本地开发默认3000,互不干扰。app.listen()返回的是一个http.Server实例,如果你后面要做单元测试,可以用它拿到server对象做关闭操作。开发调试阶段,建议配合自动重启工具,保存代码后服务自动重启,免去手动Ctrl+C再重新node app.js的重复动作。
还要注意端口冲突问题。有时运行node app.js直接报错EADDRINUSE,说明3000端口已经被其他进程占用了。排查方法很直接:换一个端口,或者找到占用进程处理掉。在一些IDE里,多个项目共用默认端口是常态,谁先启动谁占用,后启动的就会报这个错。这不是Express的问题,是系统端口分配的普遍规则,但遇到时知道怎么回事就不会慌。
5. 精读过程中常见问题与避坑记录
5.1 请求体解析失败:为什么req.body始终是空对象
这是Express开发里提问率最高的问题之一。典型场景是前端用POST提交了一段JSON,后端req.body却是undefined或空对象。第一步检查有没有挂载express.json()。Express从4.16版本开始才把express.json()和express.urlencoded()收进内置能力,但默认并不会自动启用,你必须显式app.use(express.json())。忘了挂载,解析器就不存在,req.body自然拿不到东西。
第二步检查Content-Type。如果前端用的是text/plain或application/x-www-form-urlencoded提交,但后端只挂了express.json(),同样解析不出来。要按实际提交格式挂对应的解析中间件。第三步,如果你自己在某个中间件里用req.on('data')读过原始请求流,再去用解析中间件就会失效,因为流的读取是一次性的。凡是手动消费过请求体的代码,都要谨慎,尽量让内置中间件统一处理。
除了这些,还有个顺序问题:请求体解析中间件必须放在路由之前注册。如果放在路由之后,请求到达路由时还没经过解析,路由返回响应之前根本拿不到body。这一点和前面讲的队列顺序是一脉相承的,遇到问题可以先想想自己在哪个位置挂的中间件。
5.2 异步错误导致进程崩溃:Express 4没帮你兜底
前面提到,Express 4的中间件如果是async函数,内部抛出的异常不会被自动捕获。举个常见例子:
app.get('/data', async (req, res) => { const data = await someAsyncFunction(); res.json(data); });如果someAsyncFunction()抛错了,这个错误不会进入错误处理中间件,反而会变成一个未处理的Promise rejection,严重时直接让Node进程崩溃。这在Express 4里非常经典。解决办法之一是写一个包装函数:
const asyncHandler = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };然后在路由里套一层:
app.get('/data', asyncHandler(async (req, res) => { const data = await someAsyncFunction(); res.json(data); }));这样异步错误会被传给next,最终进入统一错误处理中间件。每写一个异步路由就套一个asyncHandler看起来有点啰嗦,但它是Express 4时代最稳妥的做法。这里我还想多提一句:数据库查不到数据、第三方接口超时,这类运行期错误是最常见的业务异常,把它们全都捕获并通过next(err)交给统一错误处理,再配合日志系统记录下来,线上定位问题会轻松得多。
5.3 CORS和404:两个被反复问起的周边问题
开发前后端分离项目时,第一个跨域问题几乎一定会碰到。浏览器里前端页面跑在http://localhost:8080,接口跑在http://localhost:3000,直接fetch就会被CORS策略拦住。Express本身不处理跨域,你需要自行设置响应头或使用第三方中间件。最简单的做法是在全局加一个中间件,对所有响应设置允许跨域的头部:
app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); if (req.method === 'OPTIONS') { res.sendStatus(204); return; } next(); });开发环境图省事可以直接设成*,生产环境就按实际域名来限制,别一刀切全放开。另外注意OPTIONS预检请求,如果不显式处理,浏览器可能拿不到正确的预检结果。
404兜底也很关键。不写任何兜底时,Express会返回它默认的HTML格式404页面。做纯接口项目时,这个页面既不美观也不统一。推荐在所有路由之后挂一个普通中间件,统一返回JSON:
app.use((req, res) => { res.status(404).json({ message: '资源不存在' }); });注意它没有err参数,不是错误处理中间件,就是普通兜底。区分清楚404和500:404是请求路径不存在的正常响应,500是程序出错的异常响应。把这两条兜底分开写,后面的维护体验会好很多。
我个人在实际精读Express时体会最深的一点是:别急着抄网上的项目骨架,先从最小应用开始,每学一个概念就亲手加一段代码验证,中间件注册顺序、请求体解析、错误处理这几个点必须自己踩一遍才记得牢。基础篇这部分如果了解了,后续再深入实现原理、测试和性能优化,就会有很扎实的地基。