接手过一个 Node 服务,路由文件八百多行,全塞在 app.js 里,改一个接口得先滚一遍屏幕找位置。翻代码的时候发现一个更要命的问题,有个鉴权中间件被写在了路由后面,实际上一次都没执行过,接口是裸奔的状态。这类问题的根子不在业务代码写得糙,而在于对 Express 的执行顺序没搞明白。
这篇把 Express 从最底下的 http 模块开始捋一遍,讲清楚中间件到底是什么、use 和 get 这些方法的执行顺序凭什么是那样、路由怎么用 Express.Router 拆出去,最后补一段 2018 年之后 Express 变过的写法。读完你应该能自己判断一个中间件该放在哪一行。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- Express 相对原生
http模块到底包了一层什么 - 中间件的定义、
next的两种用法与错误传递机制 use注册的中间件为什么严格按注册顺序执行all与 HTTP 动词方法的区别,路径的模式匹配怎么写response与request上最常用的那几个属性和方法- 用 hbs 模板引擎从静态页面过渡到动态渲染的完整例子
Express.Router拆路由、router.param处理路径参数、app.route链式写法- multer 处理文件上传的最小可用配置
- 这篇里哪些写法在 Express 4 / 5 上已经不成立了
# 一、Express 到底包了一层什么
官网在 expressjs.com,中文文档在 expressjs.com.cn。Express 是基于 Node.js 的 Web 开发框架,用它能比较快地搭出一个功能完整的网站。
想要一个现成的骨架,装应用生成器 express-generator 就行。
$ npm install express-generator -g
生成出来的目录结构是 bin/www 加 app.js 加 routes/,这套结构现在看有点老,但它把入口、应用配置、路由三块分开的思路是对的,后面第十节讲 Router 的时候你会看到一样的分法。
先说结论,Express 的核心就是对 Node 内置 http 模块的再包装。不用 Express 直接起一个服务,代码是这样。
var http = require("http");
var app = http.createServer(function(request, response) {
response.writeHead(200, {"Content-Type": "text/plain"});
response.end("Hello world!");
});
app.listen(3000, "localhost");
createServer 的回调只有一个,所有请求都从这一个函数进来。路径判断、参数解析、错误处理,全得你自己在这个函数里写 if。项目一大,这个函数就会变成前面说的那种八百行的怪物。
同样的功能用 Express 写是这样。
var express = require('express');
var app = express();
app.get('/', function (req, res) {
res.send('Hello world!');
});
app.listen(3000);
差别不在代码短了几行,而在于 Express 在 http 模块之上加了一个中间层。这个中间层干的事,就是把那个巨大的回调函数拆成一串可以排队执行的小函数。
# 二、中间件就是排队处理请求的函数
中间件(middleware)就是处理 HTTP 请求的函数。它最大的特点是,一个中间件处理完,再传递给下一个中间件。App 实例在运行过程中,会调用一系列的中间件。
每个中间件从 App 实例接收三个参数,依次是 request 对象(代表 HTTP 请求)、response 对象(代表 HTTP 回应)、next 回调函数(代表下一个中间件)。每个中间件都可以对 request 对象进行加工,并且决定是否调用 next 方法,把 request 对象再传给下一个中间件。
一个什么都不干、只负责往下传的中间件长这样。
function uselessMiddleware(req, res, next) {
next();
}
这里有个坑要注意,next 带不带参数是两件完全不同的事。不带参数就是正常往下走;带上参数就代表抛出一个错误,参数是错误文本。
function uselessMiddleware(req, res, next) {
next('出错了!');
}
抛出错误以后,后面的普通中间件全部跳过,Express 会一路找到第一个错误处理函数为止。错误处理函数的签名是四个参数 (err, req, res, next),Express 靠形参个数来区分它和普通中间件,少写一个参数它就不认了。这个我踩过,错误中间件写成三个参数,结果错误一路穿透到默认处理器,返回一个带堆栈的 HTML 页面直接甩给了前端。
还有一件事不能忘,调用了 next() 之后如果又调用了 res.send(),或者反过来,就会撞上 Cannot set headers after they are sent 这个报错。一个请求的生命周期里,响应只能结束一次。
# 三、use 方法与最原始的路由
use 是 Express 注册中间件的方法,它返回一个函数。下面是连续调用两个中间件的例子。
var express = require("express");
var http = require("http");
var app = express();
app.use(function(request, response, next) {
console.log("In comes a " + request.method + " to " + request.url);
next();
});
app.use(function(request, response) {
response.writeHead(200, { "Content-Type": "text/plain" });
response.end("Hello world!\n");
});
http.createServer(app).listen(1337);
收到 HTTP 请求后先调第一个中间件,在控制台输出一行信息,然后通过 next 把执行权交给第二个中间件,输出 HTTP 回应。第二个中间件没有调 next,request 对象就不再往后传了。
顺着上面聊,use 内部可以对访问路径做判断,靠这个就能实现最朴素的路由,根据不同的请求网址返回不同的内容。
var express = require("express");
var http = require("http");
var app = express();
app.use(function(request, response, next) {
if (request.url == "/") {
response.writeHead(200, { "Content-Type": "text/plain" });
response.end("Welcome to the homepage!\n");
} else {
next();
}
});
app.use(function(request, response, next) {
if (request.url == "/about") {
response.writeHead(200, { "Content-Type": "text/plain" });
response.end("Welcome to the about page!\n");
} else {
next();
}
});
app.use(function(request, response) {
response.writeHead(404, { "Content-Type": "text/plain" });
response.end("404 error!\n");
});
http.createServer(app).listen(1337);
这段通过 request.url 判断请求的网址,从而返回不同的内容。app.use 一共登记了三个中间件,只要请求路径匹配上了,就不会把执行权交给下一个。所以最后一个中间件承担了兜底职责,前面都没匹配上,它就返回 404。
顺便说一句,原文这个例子的第二个中间件里只写了 writeHead 没写 response.end,请求会一直挂着直到超时。上面这版我把 end 补上了。这种漏掉 end 的 bug 在真实项目里很常见,表现是接口一直 pending,前端还以为是网络慢。
除了在回调函数内部判断请求的网址,use 也允许把请求网址写在第一个参数上。只有请求路径匹配这个参数,后面的中间件才会生效。这样写更清晰。
// 只对根目录的请求,调用某个中间件
app.use('/path', someMiddleware);
于是上面那段可以改写成下面的样子。
var express = require("express");
var http = require("http");
var app = express();
app.use("/home", function(request, response, next) {
response.writeHead(200, { "Content-Type": "text/plain" });
response.end("Welcome to the homepage!\n");
});
app.use("/about", function(request, response, next) {
response.writeHead(200, { "Content-Type": "text/plain" });
response.end("Welcome to the about page!\n");
});
app.use(function(request, response) {
response.writeHead(404, { "Content-Type": "text/plain" });
response.end("404 error!\n");
});
http.createServer(app).listen(1337)
原文这段的第一行是 ar express = require("express"),少了个 v,照抄会直接报语法错误,这里改对了。
有一点必须记牢,use 的路径参数是前缀匹配。写 app.use('/home', fn) 的话,/home/detail 也会命中。这和后面 app.get('/home', fn) 的精确匹配是两种行为,混着用很容易出现「为什么这个中间件在子路径上也跑了」的困惑。
# 四、all 方法和 HTTP 动词方法
针对不同的请求,Express 提供了 use 方法的一些别名。上面那段也可以用别名的形式来写。