Skip to content

中间件与express.Router(Day10 3/3) ​

本页对应 Day10_Node,共 7 段视频,约 1 小时 18 分。

学习目标 ​

  1. 能编写 Express 路由,从 req 中获取 URL、客户端 IP 和指定请求头(如 user-agent),并通过响应内容验证获取结果。
  2. 能使用 req.query 获取 GET 查询字符串,完成表单提交后的参数读取与响应验证。
  3. 能定义含路径参数的路由(如 /news/:id.html),使用 req.params 获取参数,并对不存在或非法参数返回 404。
  4. 能使用 app.use 配置 body-parser 中间件,解析表单 POST 请求体并通过 req.body 获取数据,完成响应或写入文件验证。
  5. 能使用响应对象的 status、set、send、json、download、redirect 等方法实现状态码/响应头设置、JSON 响应、文件下载、重定向和 404 页面。
  6. 能使用 express.Router 拆分新闻列表与详情路由,并结合模板引擎或模板字符串根据数据动态生成列表和详情页,验证不同 ID 显示对应内容及异常处理。

知识点 ​

从“应用就是中间件链”理解 Express ​

Express 自身功能极简,本质上由路由和中间件构成。换句话说,一个 Express 应用并不是一堆孤立的路由函数,而是在请求到来后,按注册顺序调用一串中间件。为什么需要先建立这个认识?因为很多初学者把中间件当成“额外插件”,结果在 app.use、路由顺序、next() 上反复出错。怎么想:把一次请求想成一条流水线,请求对象 req 和响应对象 res 从第一个中间件流到下一个中间件,直到某个环节把响应发出去,流程才结束。怎么做:先记住中间件的形态——它是一个函数,接收请求对象 req、响应对象 res,以及请求-响应循环中的下一个中间件,一般命名为 next。易错点:不要把它理解成普通路由;中间件可以挂在所有请求上,也可以只挂在某段路径上,关键看注册方式与顺序。

中间件到底能做什么 ​

笔记给出的功能很明确:执行任何代码;修改请求和响应对象;终结请求-响应循环;调用堆栈中的下一个中间件。为什么需要这四条?因为它们解释了中间件为何能承担日志、解析请求体、鉴权、设置响应头等工作。怎么想:中间件不是只能“放行”,它可以在 req 上挂数据,也可以改 res,还可以直接 res.send() 结束响应。怎么做:写一个最小中间件:

js
const express = require('express');
const app = express();

app.use((req, res, next) => {
  console.log('进入第一个中间件');
  req.msg = '来自中间件';
  next();
});

app.get('/', (req, res) => {
  res.send(req.msg);
});

app.listen(3000, () => {
  console.log('server running');
});

访问 / 时先打印日志,再进入路由,最后响应“来自中间件”。易错点:如果当前中间件没有终结请求-响应循环,就必须调用 next() 把控制权交给下一个中间件,否则请求会挂起。可以这样验证:临时注释掉 next(),浏览器会一直等待;恢复后请求立即进入路由。这个调试过程能直观看到“中间件链断掉”的后果。

顺序:中间件必须放在需要它的路由之前 ​

转写稿里安装并使用 body-parser 时,有一句很关键的推导:这段代码要“加到前面”,因为请求会按顺序先执行它。为什么需要强调顺序?因为 Express 按注册顺序执行中间件。怎么想:如果解析请求体的中间件放在路由后面,路由先执行,此时 req.body 还没有被解析,自然拿不到表单数据。怎么做:以表单 POST 为例,先安装并引入 body-parser,再在路由前注册:

js
const express = require('express');
const bodyParser = require('body-parser');
const app = express();

app.use(bodyParser.urlencoded({ extended: false }));

app.post('/submit', (req, res) => {
  console.log(req.body);
  res.send('提交成功');
});

转写稿中反复调试“请求体拿不到”的过程,最后就是靠这个中间件解决的;同时要纠正同音字:是 body-parser,不是“包底 parser”;req.body 也不是“包底”。易错点:body-parser 要放在处理 POST 的路由之前;表单提交通常用 urlencoded,不要误用 JSON 解析去接普通表单;extended: false 表示按较简单的方式解析,初学阶段按文档示例即可。

next() 与“终结响应”的边界 ​

为什么需要区分 next() 和 res.send()?因为前者是“交给下一个”,后者是“我来结束”。怎么想:一个中间件要么继续放行,要么终结循环,通常不要既终结又放行。怎么做:日志、解析、挂载数据这类中间件调用 next();真正返回内容的中间件用 res.send()、res.json()、res.end() 等结束。易错点:调用了 next() 之后又执行 res.send(),容易出现响应重复;已经 res.send() 后还调用 next(),也可能让后续中间件继续处理并报错。更隐蔽的是忘记 next():代码不报错,但请求一直挂起,浏览器转圈。排查时先看中间件是否放行,再看是否已经有响应被发出。

把中间件放回请求处理链中理解 ​

转写稿里还出现过用 app.use 做静态资源托管、用 app.use 注册 body-parser 的场景,这些都在说明:app.use 注册的是应用级中间件,它先于后面的路由参与请求处理。为什么需要这样串起来?因为实际项目里,请求体解析、静态资源、日志等往往都在路由前完成。怎么想:请求进来后,先经过一串 app.use,再匹配具体路由;路由本身也是请求处理链的一环。怎么做:把通用逻辑放前面,把具体业务路由放后面;需要继续时调用 next(),需要返回时直接响应。易错点:不要把所有逻辑都塞进一个中间件;也不要在中间件里忘记区分“所有请求”和“某路径请求”。本节笔记的核心可以压成一句话:Express 应用就是在调用各种中间件,而中间件的正确性,取决于它是否在正确的位置、是否在需要时调用 next()、是否在合适的时候终结请求-响应循环。

为什么要把路由拆成模块 ​

为什么需要:当应用只有一两个接口时,把所有 app.get、app.use 都写在入口文件里还能接受;但路由一多,入口文件会越来越长,新闻列表、用户、后台等路由混在一起,阅读和维护都困难。转写稿里反复出现“写路由、加中间件、再启动服务”的过程,本质上就是路由和中间件不断增多。此时需要一种办法,把一组相关路由封装起来。

怎么想:不要一上来就复制粘贴,而是先判断哪些路由属于同一类。比如鸟类的首页和 about 页,可以看成一个“鸟类路由模块”。模块内部自己定义中间件和路由,外部只负责挂载。这样入口文件只保留启动服务和挂载模块的代码。

怎么做:使用 express.Router 创建路由实例。它本身是一个完整的中间件和路由系统,常被称为“mini-app”。也就是说,router.use、router.get 的写法和 app.use、app.get 很接近,但它可以独立导出。

易错点:不要把 Router 理解成“另一个 Express 应用实例”而重复启动服务;它只是路由和中间件的容器。转写稿里说的“中间键”应纠正为“中间件”,Router 里同样可以挂中间件。

编写并导出 birds 路由模块 ​

为什么需要:把鸟类相关路由放进单独文件,可以让入口文件更干净,也方便以后把整个模块挂到不同前缀下。

怎么想:先创建 birds.js,在文件内引入 express,再创建 router。接着用 router.use 加一个记录时间的中间件,用 router.get 定义首页和 about 页,最后导出 router。

怎么做:笔记中的代码如下,保留原代码:

javascript
var express = require('express');
var router = express.Router();

// 该路由使用的中间件
router.use(function timeLog(req, res, next) {
  console.log('Time: ', Date.now());
  next();
});
// 定义网站主页的路由
router.get('/', function(req, res) {
  res.send('Birds home page');
});
// 定义 about 页面的路由
router.get('/about', function(req, res) {
  res.send('About birds');
});

module.exports = router;

这里 router.use 中的 next() 很关键。转写稿里讲 app.use 挂载中间件时也强调过顺序和执行流程:中间件执行完要交给下一个处理环节,否则请求会停住。router.get('/') 和 router.get('/about') 定义的是模块内部路径,不是完整 URL。

易错点:第一,module.exports = router 导出的是 Router 实例,不要写成 module.exports = router()。第二,router.use 里忘记 next(),访问页面会卡住。第三,require('./birds') 的路径要相对于入口文件,文件位置不对会报“找不到模块”。

在入口文件挂载路由模块 ​

为什么需要:birds.js 只是导出了一个 Router 实例,还没有接到 Express 应用上。必须通过 app.use 把它挂载,请求才会进入这个路由模块。

怎么想:入口文件先创建 app,再引入路由模块,最后用 app.use(birds) 挂载。挂载可以理解为“把一组路由接到应用上”。转写稿里用 app.use 挂载静态资源托管和 body-parser,说明 app.use 本来就是挂中间件/模块的入口;Router 也是同样的挂载思路。

怎么做:入口文件中写:

javascript
var birds = require('./birds');
...
app.use(birds);

挂载后,访问 / 会进入 router.get('/'),访问 /about 会进入 router.get('/about')。如果写成 app.use('/birds', birds),那么模块内 / 就对应 /birds,/about 对应 /birds/about。笔记里写的是 app.use(birds),所以不要误以为一定要加 /birds 前缀。

易错点:第一,模块内路径和挂载前缀会拼接,容易重复写前缀。第二,挂载顺序会影响中间件执行顺序;转写稿中多次提醒“加到前面会按顺序先执行”,所以需要先执行的中间件应放在前面。第三,挂载后要启动服务再测试,不要只改文件不重启。

调试与验证 ​

为什么需要:Router 是“模块化”的,出问题时容易分不清是模块没导出、没挂载,还是路径写错。

怎么想:按请求链路排查:入口是否 require 成功 → 是否 app.use 挂载 → Router 内路径是否匹配 → 中间件是否调用了 next()。

怎么做:启动服务后依次访问 / 和 /about。如果控制台持续输出 Time: ...,说明 router.use 中间件生效;如果页面分别返回 Birds home page 和 About birds,说明路由匹配正确。若访问 404,先检查挂载前缀和模块内路径是否拼接正确。

易错点:不要把 router.get('/about') 写成完整地址;不要漏掉 module.exports;不要忘记 next()。转写稿里“重定向”“响应”“请求头”“JSON”等词被口误成“重庆下”“显应”“请求投”“接证”,阅读时按正确术语理解即可。

在模板引擎之前:先看懂“后端拼页面” ​

这一节虽然标题是“模板引擎”,但转写稿真正做的是模板引擎的前置铺垫:先用 Express 获取请求报文信息,再在响应对象里手工拼接 HTML,做出一个“新闻列表/新闻详情”的后端渲染小案例。只有先体会“后端把数据替换进 HTML”的过程,后面学模板引擎才知道它到底替我们省了什么。

获取 URL、IP 和请求头 ​

为什么需要:后端处理请求,第一步就是知道“谁来了、访问了什么、带了什么信息”。URL、IP、请求头都是请求报文里的基础信息。

怎么想:Express 的 req 继承了原生请求对象,又增加了自己的便捷属性。URL 用 req.url,IP 用 req.ip,请求头不能一次性全拿时,用 req.get('头名') 取指定项。

怎么做:

js
const express = require('express');
const app = express();

app.get('/index', (req, res) => {
  const ip = req.ip;
  const url = req.url;
  const userAgent = req.get('user-agent');

  res.send(`
    <h1>Index</h1>
    <p>您的 IP:${ip}</p>
    <p>您的 URL:${url}</p>
    <p>您的浏览器信息:${userAgent}</p>
  `);
});

app.listen(3000, () => {
  console.log('server running');
});

转写稿里特意强调:这个 IP 是客户端 IP。把它显示给用户看,用户看到的是自己的 IP,不是服务器作者的 IP。

易错点:req.ip 不是 req.id;请求头要用 req.get('user-agent'),不要凭空写 req.headers.userAgent 这种大小写不严谨的写法。中文响应能正常显示,是因为 Express 的 res.send 会自动处理 Content-Type。

获取查询字符串:req.query ​

为什么需要:表单 GET 提交、搜索参数、分页参数常放在 URL 的 ? 后面。以前要手动用 url 模块解析,Express 直接给了 req.query。

怎么想:查询字符串本质是键值对,Express 已经解析成对象。表单里 name="wd"、name="type"、name="origin",提交后就能用 req.query.wd 等读取。

怎么做:

js
app.get('/search', (req, res) => {
  res.sendFile(path.resolve(__dirname, 'form.html'));
});

app.get('/submit', (req, res) => {
  const { wd, type, origin } = req.query;
  res.send(`
    <p>关键字:${wd}</p>
    <p>类型:${type}</p>
    <p>来源:${origin}</p>
  `);
});

易错点:路由匹配时,查询字符串不参与路径匹配,所以 /submit?wd=xx 仍然进入 /submit。另外表单提交方式若是 GET,参数才在 URL 上;POST 则要另取请求体。

获取路径参数:req.params ​

为什么需要:有些参数更适合放在路径里,例如 /news/1.html,看起来像静态文件,实际由后端动态生成。

怎么想:路由写成 /news/:id.html,冒号后面的 id 是变量名,可以随便取;请求 /news/845.html 时,req.params.id 就是 845。

怎么做:

js
app.get('/news/:category/:id.html', (req, res) => {
  const { category, id } = req.params;
  res.send(`分类:${category},ID:${id}`);
});

易错点:路径参数用 req.params,查询字符串用 req.query,不要混。路由里写了 :id.html,访问时就必须带上 .html,否则匹配不到。

获取请求体:body-parser ​

为什么需要:POST 表单数据不在 URL 上,而在请求体里。直接读 req.body 拿不到,需要解析。

怎么想:Express 请求对象继承了原生对象,可以用 data、end 事件自己拼,但很麻烦。第三方模块 body-parser 可以做成中间件,把表单请求体解析到 req.body。

怎么做:

js
const bodyParser = require('body-parser');
app.use(bodyParser.urlencoded({ extended: false }));

app.post('/submit', (req, res) => {
  console.log(req.body);
  res.send('提交成功');
});

易错点:模块名是 body-parser,不是少个 r 的山寨包;表单 POST 一般用 urlencoded,不要错用 JSON 解析。中间件要放在路由前面,否则 req.body 可能还是空。

响应对象:send、status、set、json、download、redirect ​

为什么需要:后端最终要把状态码、响应头、响应体交给浏览器。

怎么想:Express 的 res 支持链式调用。status 设状态码,set 设响应头,send 发响应体,json 自动把对象转 JSON,download 触发下载,redirect 做跳转。

怎么做:

js
app.get('/index', (req, res) => {
  res.status(201)
     .set('server', 'my-server')
     .set('token', 'abc')
     .send('<h1>index</h1>');
});

app.get('/data', (req, res) => {
  res.json({ name: 'news', id: 1 });
});

易错点:链式调用中 set 后不要误加分号,否则链就断了;res.json 会自动设置 JSON 相关 Content-Type;redirect 是让浏览器重新请求目标地址。

新闻列表案例:用 map + join 拼 HTML ​

为什么需要:新闻有 3 条就写 3 个详情页,3 万条就不能手写。后端要根据数据动态生成页面。

怎么想:准备一个数组 newsData,列表页把每条新闻的标题拼成 <li><a>...</a></li>,详情页根据 ID 用 find 找对应新闻。

怎么做:

js
const newsData = require('./data.json');

app.get('/news', (req, res) => {
  const list = newsData.map(item => {
    return `<li><a href="/news/details?id=${item.id}">${item.news_title}</a></li>`;
  }).join('');

  res.send(`
    <h1>新闻列表</h1>
    <ul>${list}</ul>
  `);
});

app.get('/news/details', (req, res) => {
  const id = req.query.id;
  const newsItem = newsData.find(item => item.id === id);

  if (!newsItem) {
    return res.status(404).send('您的新闻走丢了');
  }

  res.send(`
    <h1>${newsItem.news_title}</h1>
    <p>${newsItem.news_content}</p>
  `);
});

易错点:map 返回新数组,必须 join(''),否则数组转字符串会带逗号;find 返回第一个满足条件的元素,找不到返回 undefined,要判断并给 404。转写稿中 JSON 最外层写成数组导致报错,说明 JSON 文件格式必须合法。

升级为路径参数版 ​

把 /news/details?id=1 改成 /news/1.html,路由写成 /news/:id.html,读取从 req.query.id 改成 req.params.id。用户看起来像访问静态文件,实际仍是后端按 ID 动态生成。这个“障眼法”正是理解后端渲染的关键:浏览器拿到的网页源代码里已经是生成好的 HTML,看不到后端变量。

易错点:改路由后,列表页里的 href 也要同步改成 /news/${item.id}.html;否则请求仍走旧地址。模板引擎后面要做的,就是把这里繁琐的字符串拼接替换成更清晰的模板渲染。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

示例1:Express 获取 URL、IP、请求头、查询字符串、路径参数 ​

js
const express = require('express');
const app = express();
const port = 3000;

// 获取 URL、IP、请求头
app.get('/index', (req, res) => {
  res.send(`
    <h1>请求信息</h1>
    <p>您的IP:${req.ip}</p>
    <p>您的URL:${req.url}</p>
    <p>您的浏览器信息:${req.get('user-agent')}</p>
  `);
});

// 获取查询字符串:/search?wd=如何一夜暴富&type=态度&origin=ORGN
app.get('/search', (req, res) => {
  res.send(`
    <h1>查询字符串信息</h1>
    <p>关键字:${req.query.wd}</p>
    <p>搜索类型:${req.query.type}</p>
    <p>来源:${req.query.origin}</p>
  `);
});

// 获取路径参数:/news/娱乐/123.html
app.get('/news/:cate/:id.html', (req, res) => {
  res.send(`
    <h1>路径参数信息</h1>
    <p>分类:${req.params.cate}</p>
    <p>ID:${req.params.id}</p>
  `);
});

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

要点解释:

  • req.url 获取请求 URL,req.ip 获取客户端 IP,req.get('user-agent') 获取指定请求头。
  • req.query 一步获取查询字符串对象,req.query.wd 对应 URL 中的 ?wd=...。
  • req.params 获取路由中 :cate、:id 这类路径参数,路由里的 .html 是固定后缀。
  • 易错点:req.query 和 req.params 不要混用;req.get() 只能获取指定请求头,不能一次获取全部请求头。
  • 运行前先安装依赖:npm install express。

示例2:body-parser 获取 POST 请求体 ​

html
<!-- form.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>提交信息</title>
</head>
<body>
  <form action="/submit" method="post">
    <input type="text" name="username" placeholder="用户名">
    <input type="text" name="phone" placeholder="电话">
    <input type="text" name="message" placeholder="留言">
    <button type="submit">提交</button>
  </form>
</body>
</html>
js
// index.js
const express = require('express');
const path = require('path');
const bodyParser = require('body-parser');
const app = express();
const port = 3000;

// 解析表单 POST 请求体
app.use(bodyParser.urlencoded({ extended: false }));

// 打开表单页面
app.get('/search', (req, res) => {
  res.sendFile(path.resolve(__dirname, 'form.html'));
});

// 接收表单提交的数据
app.post('/submit', (req, res) => {
  console.log(req.body);

  res.send(`
    <h1>提交成功</h1>
    <p>用户名:${req.body.username}</p>
    <p>电话:${req.body.phone}</p>
    <p>留言:${req.body.message}</p>
  `);
});

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

要点解释:

  • bodyParser.urlencoded({ extended: false }) 必须写在 POST 路由之前,否则 req.body 拿不到表单数据。
  • 表单的 method="post" 和 action="/submit" 要与 app.post('/submit', ...) 对应。
  • 表单控件的 name 要与 req.body.username、req.body.phone、req.body.message 对应。
  • 易错点:body-parser 包名不要拼错;表单提交默认是 application/x-www-form-urlencoded 格式,所以用 urlencoded 解析。
  • 运行前安装依赖:npm install express body-parser,并保证 form.html 与 index.js 同目录。

示例3:express.Router 模块化路由 ​

js
// birds.js
const express = require('express');
const router = express.Router();

// 该路由使用的中间件
router.use(function timeLog(req, res, next) {
  console.log('Time: ', Date.now());
  next();
});

// 定义网站主页的路由
router.get('/', function(req, res) {
  res.send('Birds home page');
});

// 定义 about 页面的路由
router.get('/about', function(req, res) {
  res.send('About birds');
});

module.exports = router;
js
// app.js
const express = require('express');
const app = express();
const port = 3000;
const birds = require('./birds');

// 加载路由模块
app.use(birds);

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

要点解释:

  • express.Router() 创建的是一个“mini-app”,可以定义中间件和路由。
  • router.use(...) 中必须调用 next(),否则请求会挂起,无法继续执行后面的路由。
  • module.exports = router 导出路由模块,入口文件通过 require('./birds') 加载。
  • app.use(birds) 会把该 Router 挂载到应用上,因此可访问 / 和 /about。
  • 易错点:中间件忘记写 next();require 路径写错;Router 挂载顺序和中间件顺序要注意。

常见错误 ​

  1. 现象:请求一直转圈、接口无响应。原因:中间件既没有 res.send() 等终结响应,也没有调用 next()。修法:检查每个中间件分支,未终结请求时必须 return next() 或调用下一个中间件。
  2. 现象:req.body 为 undefined 或为空对象。原因:未挂载请求体解析中间件,或解析器类型与提交格式不匹配,或中间件挂在路由之后。修法:表单 POST 使用 express.urlencoded({ extended: false }),并确保解析中间件放在业务路由之前。
  3. 现象:详情页拿不到 ID,或拿到的是 undefined。原因:查询字符串应用 req.query.id,路径参数应用 req.params.id,二者混用;路由声明也未按 /news/:id.html 形式匹配。修法:先确认 URL 形式,再选择 query 或 params,并保证路由占位符名称与读取属性一致。
  4. 现象:用 map() 拼接多个 <li> 后页面多出逗号。原因:map() 返回数组,直接插入模板字符串会触发数组默认逗号拼接。修法:在 map() 后补 .join(''),明确用空字符串连接。
  5. 现象:设置响应头或状态码后报错,或后续响应设置不生效。原因:链式调用中途误加分号,提前结束了表达式;或响应已经发送后再次 send。修法:保持 res.status(...).set(...).send(...) 链式结构完整;每个请求只终结一次响应。
  6. 现象:详情页 ID 不存在时直接抛错或页面不友好。原因:find() 未命中时返回 undefined,后续继续读取 newsItem.title。修法:查到数据后先判断,未命中时 return res.status(404).send(...),避免继续执行后续逻辑。

练习 ​

  1. 编写一个全局中间件,打印请求方法、URL 和时间,然后调用 next();再验证不写 next() 时请求会怎样。
  2. 创建 /info 路由,把客户端 IP、请求 URL、User-Agent 请求头输出到页面;再用另一个路由演示 res.json() 返回对象。
  3. 做一个 GET 搜索表单,提交到 /search,使用 req.query 读取关键字、类型、来源,并把三项内容渲染到响应页面。
  4. 基于新闻数据实现详情页两种版本:一版用查询字符串 ?id=...,一版用路径参数 /news/:id.html;两版都要处理 ID 不存在时的 404。
  5. 用 express.Router() 拆分新闻列表与详情路由,在主入口中挂载;要求 Router 内部至少有一个记录时间的中间件。

原视频对照 ​

#视频时长
1express-获取url、ip和请求头07:38
2express-获取查询字符串06:07
3express-获取URL中的路径参数04:52
4express-获取请求体11:38
5express-响应对象13:22
6express新闻列表小案例0129:20
7express新闻列表小案例02-通过URL参数获取新闻ID05:42

小结 ​

  • 中间件首先是流程控制工具,写完后要检查“终结响应”还是“交给下一个”,不能两头都不做。
  • Express 中顺序就是行为:解析器、日志、Router、404 兜底的位置放错,表现会完全不同。
  • 读请求参数前先分清来源:URL 路径、查询字符串、请求体不是一回事,混用是高频错误。
  • 响应对象适合链式组织,但要保证一次请求只终结一次,重定向和 404 分支尤其要记得 return。
  • 动态页面不是“凭空动态”,本质仍是后端生成完整 HTML 后返回,浏览器源码中看不到后端变量。
  • 第三方模块不要背 API,优先看官方文档的最小示例,并核对包名、安装来源和依赖是否写进 package.json。
  • 调试时先确认请求是否进入目标路由,再确认中间件是否继续,最后确认响应是否已发送。