Skip to content

中间件与express.Router 路由模块化(Day11 2/3) ​

本页对应 Day11_Node,共 6 段视频,约 1 小时 15 分。

学习目标 ​

  1. 能够按请求到达顺序说明 Express 中间件执行流程,并实现访问日志中间件、路由处理和错误处理中间件,验证错误时返回 500。
  2. 能够使用 express.Router 将路由拆分为独立模块,并在入口文件中按路径挂载和验证各路由匹配行为。
  3. 能够配置 EJS 模板引擎与 views 目录,使用 res.render 传递数据,并用 <%= %>、<%- %>、<% %> 分别完成转义输出、非转义输出和循环/分支语句。
  4. 能够将模板文件扩展名改为 .html,通过显式引入 EJS、设置 app.engine 和模板目录完成配置,并排查模板找不到导致的 500 错误。
  5. 能够使用 express-generator 生成 Express 项目,识别 app.js、bin/www、routes、views、public 的职责,安装依赖并通过 npm start 启动项目。
  6. 能够使用指定旧版 lowdb 初始化 JSON 数据文件,并完成 get、push/unshift、assign、remove、write、value 等增删改查与持久化操作。

知识点 ​

中间件是什么:请求要“按顺序过站” ​

Express 自身功能极简,完全由路由和中间件构成;从本质上说,一个 Express 应用就是在调用各种中间件。中间件(Middleware)是一个函数,它可以接收请求对象、响应对象,以及调用下一个中间件的函数(通常写作 next)。
为什么需要:把日志、路由、错误处理等步骤拆成可组合的环节,请求进来后按顺序经过它们。怎么想:转写稿里把它比作“收费站”,请求是一个对象,要一个一个地过;谁写在最前面,谁就先执行。怎么做:把中间件按业务顺序组织起来,让请求对象在中间经历完整流程。易错点:不要把中间件理解成“某个固定阶段”,它本质上是函数,顺序由书写位置决定。

中间件的四个功能:能做事,也能交接 ​

笔记明确中间件可以:执行任何代码、修改请求和响应对象、结束响应、调用堆栈中的下一个中间件。
为什么需要:一个请求往往不只需要返回内容,还要记日志、改数据、处理异常。怎么想:中间件既能“干活”,也能决定“是否继续往下走”。怎么做:需要记录就写日志代码;需要改请求/响应就修改对象;需要返回就结束响应;需要继续就调用下一个中间件。易错点:把“执行代码”当成“自动继续”——执行完不等于会进入下一个中间件,必须显式交接。

不结束响应就必须 next(),否则请求挂起 ​

如果当前中间件没有结束响应,则必须调用 next() 方法将控制权交给下一个中间件,否则请求就会挂起。
为什么需要:Express 不会替每个中间件自动决定下一步,必须由中间件明确表示“我处理完了,继续”。怎么想:中间件链像流水线,当前环节不返回响应,就要把请求对象交下去。怎么做:日志中间件打完日志后调用 next();如果已经结束响应,就不再需要往下交。易错点:只写日志、忘了 next(),浏览器会一直等,表现为请求挂起;这类问题不是业务逻辑错,而是中间件链断了。

顺序决定执行:访问日志为什么在最前 ​

转写稿分析流程时说得非常直接:请求到达服务器后,第一步先经过访问日志中间件,原因不是它有特殊能力,而是“我把它写到了最前面,就这么简单”。
为什么需要:日志要记录请求进入时的信息,所以要在业务处理前执行。怎么想:请求对象按中间件书写顺序逐个经过,谁在前谁先执行。怎么做:把访问日志中间件放在路由之前;路由模块再按路径匹配处理。易错点:把日志中间件写到路由后面,正常请求可能在路由里已经结束响应,日志就记录不到完整入口流程。

路由本质上也是中间件 ​

转写稿明确提醒:路由本质上也是中间件,所以它们也会按顺序执行;不过路由之间按路径各自匹配、各处理各自的,不会故意让两个路由都匹配。
为什么需要:理解这一点,才能把路由放进整条中间件链里看,而不是把路由当成孤立阶段。怎么想:请求先过访问日志,再进入路由模块;路由模块里可能有 index router、login router 等,各匹配各自路径。怎么做:把路由模块作为中间件链的一环挂载,路径匹配才执行对应回调。易错点:误以为“路由一定在中间件之后特殊执行”,忽略顺序;或者故意让多个路由匹配同一路径,导致流程混乱。

正常结束响应与错误处理中间件的分工 ​

转写稿的流程是:路由模块中的路由回调如果没有出错,会结束响应;如果出错,才会进入错误处理中间件,错误处理中间件进行错误日志并响应 500。注意前提:前面有错误才到得了错误处理中间件;没有错误时,路由里已经把响应结束了,就到不了那里。
为什么需要:正常流程和异常流程要分开,错误处理不能干扰正常响应。怎么想:路由回调负责正常返回;错误处理中间件负责兜底记录和返回 500。怎么做:正常路由结束响应;错误处理放在后面,等前面抛错时接管。易错点:以为错误处理中间件每个请求都会走;或者正常响应结束后还期待错误处理再执行。

把整条链串起来看 ​

本节的核心不是背概念,而是建立“请求对象按顺序经过中间件”的模型:请求到达服务器 → 先经过写在前面的访问日志中间件 → 再进入路由模块 → 路由回调正常则结束响应,出错则交给后面的错误处理中间件记录日志并响应 500。
为什么需要:只有把顺序、交接、结束响应三件事分清,才能调试“请求为什么挂起”“日志为什么没打”“500 为什么没出现”。怎么想:先看中间件顺序,再看有没有调用 next(),最后看谁结束了响应。怎么做:按流程逐段排查,不把路由和中间件割裂。易错点:忘记 next() 导致挂起;顺序放错导致日志缺失;把错误处理当成必经环节。本节笔记未附图片和代码,以上按原文概念与转写稿流程整理,不额外编造代码。

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

为什么需要: 入口文件里如果同时写首页、登录、表单提交等路由,文件会越来越长,路径和业务混在一起,后面加功能、查问题都困难。
怎么想: 把“同一组路径”的路由放进一个独立文件,用 express.Router() 创建一个小型路由系统,再在入口文件里挂载。笔记里称它为 mini-app,意思是它可以像 app 一样定义路由和中间件。
怎么做: 建 routes/index.js、routes/login.js,每个文件导出 route,入口只负责 require 和 app.use。
易错点: express.Router() 是调用后得到路由对象,不是把 express 直接导出;每个路由文件最后必须 module.exports = route,否则入口拿到的是空对象。

二、创建路由模块:Router 实例就是 mini-app ​

为什么需要: 路由模块要能独立定义 get、post,还能在模块内加中间件;Router 实例正好提供完整路由能力。
怎么想: 模块内的 / 是“相对路径”,挂载到入口后才拼成完整路径。比如 login.js 里的 route.get('/'),挂到 /login 后才是 /login。
怎么做: 创建 index.js:

js
// 导入模块
const express = require('express');

// 创建路由对象
const route = express.Router();

// 路由
route.get('/', (req, res) => {
    res.redirect('/index');
});

// 路由
route.get('/index', (req, res,next) => {
    res.send(`
    <h1>首页</h1>
    <hr>
    <a href="/login">登录</a>
    `);
});

// 将路由对象作为暴露数据
module.exports = route;

创建 login.js:

js
// 导入模块
const express = require('express');

// 创建路由对象
const route = express.Router();

// 路由
route.get('/', (req, res) => {
    res.send(`
    <h1>登录</h1>
    <hr>
    <form action="/login" method="post">
        <input placeholder="请输入用户名" type="text" name="username">
        <input placeholder="请输入密码" type="password" name="userpwd">
        <button>提交</button>
    </form>
    `);
});

// 路由
route.post('/', (req, res) => {
    res.send('<h2>提交成功!</h2>');
});

// 将路由对象作为暴露数据
module.exports = route;

易错点: index.js 里 res.redirect('/index') 是让浏览器重新请求 /index;如果以后把 indexRouter 挂到别的前缀下,这个重定向地址也要跟着检查。login.js 的表单 action="/login" 要和入口挂载前缀一致。

三、在入口挂载:app.use 决定路径前缀 ​

为什么需要: 路由模块只是“零件”,不挂载就不会进入请求流程。
怎么想: app.use(router) 表示模块内路径原样使用;app.use('/login', router) 表示给模块内所有路径加 /login 前缀。
怎么做: 在入口文件中写:

js
// 导入路由模块
const indexRouter = require('./routes/index');
const loginRouter = require('./routes/login');

// 挂载路由模块
app.use(indexRouter);  //
app.use('/login', loginRouter);  // 挂载路由 指定路径

易错点: 最容易犯的是“前缀写重”。已经 app.use('/login', loginRouter),模块里就写 route.get('/')、route.post('/');如果模块里再写 /login,完整路径会变成 /login/login。另外 require('./routes/index') 不带 .js 是 Node 的模块查找规则,路径写错会启动报错。

四、请求流程:路由本质也是中间件 ​

为什么需要: 不理解顺序,就会出现“为什么日志没打印”“为什么 404”“为什么错误处理没进来”的问题。
怎么想: 转写稿里的比喻很直接:请求像过收费站,按代码注册顺序一个一个经过中间件;访问日志中间件写在最前面,所以先经过它,不是因为它特殊。然后进入路由模块,indexRouter 和 loginRouter 各自匹配各自路径。路由本质也是中间件,正常匹配后由路由回调结束响应;如果前面出错,才交给后面的错误处理中间件记录日志并响应 500。转写稿里的“中间键”应纠正为“中间件”。
怎么做: 入口里把访问日志中间件放最前,路由挂载放后面,错误处理中间件放最后。调试时先看请求路径是否与“挂载前缀 + 模块内路径”拼得上;如果入口仍 require 已删除的路由文件,会报找不到模块,先检查 routes 文件是否存在、require 路径是否写对。
易错点: 不要在路由回调里既 res.send 又继续 next 到错误处理;路由模块不是绕过中间件,它本身就是中间件系统的一部分。

为什么要把 HTML 从 JS 里拿出来 ​

先看原来的写法:在 index.js 里用 res.send() 直接响应一大段 HTML 字符串。页面短还能忍,一旦页面有几百上千行,JS 文件里大部分都是 HTML,改样式、改结构都很痛苦。更关键的是,页面里的数据是“死”的:富豪排行榜的标题、描述、榜单内容都写死在字符串里,后端拿到数据也没法方便地替换进去。

所以模板引擎要解决两件事:第一,把页面单独放到 views 目录的文件里,不再塞进 JS 字符串;第二,在页面里留出“占位符”,由后端把数据传进去,渲染成最终 HTML 再响应给浏览器。可以把它理解成:以前后端拿到前端写好的页面,要把页面里的数据部分改成模板占位符,再在路由里传数据。现在 Express 用模板引擎把“读文件、替换数据、响应”这一套封装好了。

EJS 的定位与安装 ​

Express 常用模板引擎有 EJS、Jade 等。这里选 EJS,是因为 EJS 模板文件里仍然可以写普通 HTML,扩展名改成 .ejs 后,里面的标签、结构基本还是 HTML 那套;Jade 则要求用缩进写标签,和 HTML 差别较大。转写里提到的“EGS”就是 EJS,“GDE”是 Jade,注意不要写错。

EJS 不是 Express 自带的,需要安装:

bash
npm i ejs

安装后一般不需要手动 require('ejs'),只要设置 view engine 为 ejs,Express 会自动加载。易错点:没安装就设置引擎,启动或渲染时会报找不到模块;另外安装依赖后要确认 package.json 和 node_modules 正常。

设置模板引擎和模板目录 ​

模板引擎不是中间件,它是 Express 的视图设置,和中间件不冲突。创建 app 后设置两项:用哪个引擎、模板文件放在哪。模板文件放在 views 目录,路径建议用 path.resolve(__dirname, 'views') 得到绝对路径:

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

app.set('view engine', 'ejs');
app.set('views', path.resolve(__dirname, 'views'));

易错点:模板目录写错时,页面会 500,日志里提示找不到模板文件;修改模板扩展名后,如果模板文件还是 .ejs 却把引擎设成别的,也会找不到。转写中演示改扩展名时,改完刷新报 500,最后就是通过日志定位到“找不到模板文件”,再把扩展名和设置对齐才恢复。大多数项目没必要改扩展名,默认 .ejs 就够用。

用 res.render 把数据送进模板 ​

有了模板后,不再用 res.send() 读文件,而是用 res.render()。第一个参数是模板名,不用写 views 路径,也不用写扩展名;第二个参数是传给模板的数据对象:

js
app.get('/', (req, res) => {
  const data = require('./data/top.json');
  res.render('index', data);
});

假设 data/top.json 里有 title、description、message、top 四个字段。模板里写 <%= title %>,Express 就会去 data 对象里找 title。如果模板里写了 title,但 render 没传,渲染会报错。转写中先手写 { title: '富豪排行榜' } 验证,再把整个 data 传进去,页面标题、描述就都活了。易错点:模板需要的名字和传入对象的属性名必须一致;res.render('index') 只写模板名时不会自动带上数据。

三种模板定界符:输出、转义、语句 ​

EJS 模板语法核心是三种:

ejs
<h1><%= title %></h1>
<p><%= description %></p>
<p><%- message %></p>

<% top.forEach(item => { %>
  <tr>
    <td><%= item.id %></td>
    <td><%= item.name %></td>
    <td><%= item.money %></td>
  </tr>
<% }) %>

<%= %> 是转义输出:如果 message 里有 <span style="color:red">,浏览器会看到标签文本,不会解析成红色文字。<%- %> 是非转义输出,适合你明确希望浏览器解析 HTML 的场景,比如 message 里故意写了 <span>,用减号才会变红。<% %> 是执行语句,不输出内容,循环、分支都用它。

易错点:等号和减号都必须写,不写不会输出;语句标签里只写 JS,HTML 要放在定界符外面。循环时要把“开始”和“结束”拆成两个标签,中间包住 HTML 行,否则模板结构会乱。转写中通过刷新页面看到:用等号时样式标签原样显示,换成减号后红色生效;<%= 1 + 1 %>、日期函数也能直接算出来,说明定界符里写的是 JS 表达式。

调试过程与项目生成器补充 ​

模板调试顺序可以按这个来:先确认 views/index.ejs 存在;再确认 app.set('views', ...) 指向它;然后确认 res.render('index', data) 的模板名和扩展名匹配;最后看模板里用到的字段在 data 里有没有。转写中还出现过删掉路由模块后仍保留 require 导致启动报错,去掉无用导入后正常,这类问题看终端报错就能定位。

用 express-generator 生成项目时,可以指定 EJS:

bash
npx express-generator --view=ejs 04

生成后目录里 views 放模板,public 放 CSS、图片、前端 JS 等静态资源,routes 放路由,app.js 是入口,bin/www 负责启动并默认监听 3000。生成器不会带 node_modules,拉下来后要 npm install,再 npm start。默认错误模板可能暴露路径,可以改成只显示 message,更安全。

从 res.send 拼接页面到使用模板引擎 ​

为什么需要: 如果后端直接用 res.send() 把一整段 HTML 字符串响应给浏览器,页面一长,JavaScript 文件里就会塞满 HTML,代码难读也难维护。更关键的是,页面里的数据是“死”的,想显示富豪榜、时间、随机数,只能手写或拼字符串。

怎么想: 把页面单独放到 views 目录里,让模板引擎负责“读页面 + 填数据 + 输出 HTML”。EJS 的特点是模板文件仍然像 HTML,只是多了几种定界符。

怎么做: 先安装并设置 EJS,再在路由里用 res.render() 渲染模板并传数据。转写稿中的流程是:npm install ejs,设置 view engine 为 ejs,设置模板目录为 views,然后写 res.render('index', data)。模板里用 <%= title %> 这类写法接收数据。

易错点: views 路径要写对,常用 path.resolve(__dirname, 'views');模板中写了 <%= title %>,但 res.render 没传 title,运行会报错。转写稿里就是先写死标题,再改成导入 data,刷新后才正常显示“富豪排行榜”。

执行语句 <% code %>:循环生成表格行 ​

为什么需要: 榜单、列表、表格行往往数量不固定。不能手写十行 <tr>,而要把数组循环成 HTML。

怎么想: EJS 里“只执行 JS、不直接输出值”的代码放在 <% %> 中;真正要显示的字段再用 <%= %>。HTML 标签本身写在定界符外面,模板引擎只负责循环控制。

怎么做:

ejs
<% top.forEach(item => { %>
    <tr>
        <td><%= item.id %></td>
        <td><%= item.name %></td>
        <td><%= item.money %> 亿美元</td>
    </tr>
<% }) %>

这里的 top 是数组,forEach 开始和结束分别写在两个 <% %> 里,中间的 <tr> 会被重复生成。

易错点: 开始和结束的 <% %> 要成对;<% %> 本身不会把结果输出到页面;循环体里的 HTML 不要误写进定界符内部;字段名要和数据对象一致,比如 item.id、item.name、item.money。

输出转义 <%= code %>:默认安全地显示数据 ​

为什么需要: 模板里经常要显示变量、表达式和计算结果。默认转义可以避免数据里的 HTML 标签被浏览器直接解析。

怎么想: 等号表示“输出”,但输出前会把 HTML 特殊字符转成字符实体。这样即使内容里有 <span>,页面也会原样显示标签文本,而不是执行样式。

怎么做:

ejs
<%= code %>
js
<p class="alert alert-warning">
    <%= Date.now() %> <br>
    <%= Math.random() %> <br>
    <%= 10 * 7 + 8 %> <br>
</p>

EJS 里不仅可以输出变量,也可以输出表达式。转写稿中演示了 Date.now()、Math.random()、10 * 7 + 8,刷新页面会得到计算结果。

易错点: 等号不能漏;<%= code %> 如果 code 中有 HTML 标签,会被转义成字符实体,原样显示;不要把需要浏览器解析的 HTML 默认交给等号输出。

输出非转义 <%- code %>:明确信任 HTML 时才用 ​

为什么需要: 有些数据本身就是可信的 HTML 片段,比如富文本、说明文字里的 <span style="color:red">,这时需要浏览器解析,而不是把标签当文字显示。

怎么想: 减号表示“不转义输出”。它和等号都是输出,但一个转义、一个不转义。只有确认内容安全时才使用。

怎么做:

ejs
<%- code %>

笔记中的区别是:

<%= code %> 如果 code 的值中有html标签,会被转义成字符实体,原样显示
<%- code %> 如果 code 的值中有html标签,浏览器会解析处理

转写稿中 message 里写了带样式的 span,用 <%= message %> 时页面原样显示标签;把等号改成减号后,浏览器才解析出红色文字。

易错点: 非转义有 XSS 风险,不能对不可信的用户输入随便用 <%- %>;等号和减号都要写,不写就不会输出;如果只是想显示文本,优先用 <%= %>。

调试过程与常见错误 ​

转写稿的调试顺序很清楚:先把 HTML 文件用 res.send() 直接响应,页面能打开;再改成 res.render(),模板里写 <%= title %>,但一开始没有传 title,页面报错;把 data 传给模板后,标题和描述正常替换;最后用 forEach 把 top 数组渲染成表格。另一个易错点是模板扩展名:默认用 .ejs,如果改成 .html,就要重新设置引擎和扩展名,否则会提示找不到模板文件。理解这三种定界符后,EJS 的核心就是:<% %> 写控制流,<%= %> 安全输出,<%- %> 信任 HTML 后输出。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

示例一:中间件 + express.Router 路由模块化 ​

app.js(入口文件)

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

// ① 访问日志中间件:写在最前面,请求就先经过它
app.use((req, res, next) => {
    console.log('访问日志:', req.method, req.url, Date.now());
    next();   // 不结束响应,必须调用 next() 交给下一个中间件
});

// ② 导入路由模块
const indexRouter = require('./routes/index');
const loginRouter = require('./routes/login');

// ③ 挂载路由模块(Router 本质也是中间件)
app.use(indexRouter);          // 不指定路径
app.use('/login', loginRouter); // 挂载路由 指定路径

// ④ 错误处理中间件:4 个参数,写在所有中间件最后
app.use((err, req, res, next) => {
    console.error('错误日志:', err.message);
    res.status(500).send('服务器内部错误');
});

app.listen(3000, () => {
    console.log('http://localhost:3000');
});

routes/index.js

js
// 导入模块
const express = require('express');

// 创建路由对象
const route = express.Router();

// 路由
route.get('/', (req, res) => {
    res.redirect('/index');
});

// 路由
route.get('/index', (req, res, next) => {
    res.send(`
    <h1>首页</h1>
    <hr>
    <a href="/login">登录</a>
    `);
});

// 将路由对象作为暴露数据
module.exports = route;

routes/login.js

js
// 导入模块
const express = require('express');

// 创建路由对象
const route = express.Router();

// 路由
route.get('/', (req, res) => {
    res.send(`
    <h1>登录</h1>
    <hr>
    <form action="/login" method="post">
        <input placeholder="请输入用户名" type="text" name="username">
        <input placeholder="请输入密码" type="password" name="userpwd">
        <button>提交</button>
    </form>
    `);
});

// 路由
route.post('/', (req, res) => {
    res.send('<h2>提交成功!</h2>');
});

// 将路由对象作为暴露数据
module.exports = route;

要点解释

  • app.use() 的顺序就是请求经过的顺序:谁写在前,请求先经过谁。访问日志中间件放在最前面,所以最先执行。
  • 中间件函数接收 req、res、next;不结束响应就必须 next(),否则请求挂起、浏览器一直转圈。
  • express.Router() 是一个 mini-app,module.exports = route 暴露后由入口文件 app.use 挂载;挂载时写了 '/login',路由文件内部写 '/' 即可,最终访问地址是 /login。
  • 易错点:错误处理中间件必须是 4 个参数 (err, req, res, next),且必须写在所有中间件之后;否则永远进不去。
  • 易错点:route.get('/index', (req, res, next) => {...}) 中回调虽然声明了 next,但内部用 res.send() 已经结束响应,不需要也不能再调用 next()。

示例二:EJS 模板引擎渲染数据 ​

app.js

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

// 第一步:设置模板引擎为 ejs(会自动加载 ejs,无需手动 require)
app.set('view engine', 'ejs');

// 第二步:设置模板文件存放的目录
app.set('views', path.resolve(__dirname, 'views'));

// 导入后端数据(json 导入后就是对象)
const data = require('./data/top.json');

app.get('/', (req, res) => {
    // 加载模板文件,并把数据传进去,最后响应给客户端
    res.render('index', data);
});

app.listen(3000, () => {
    console.log('http://localhost:3000');
});

data/top.json

json
{
    "title": "富豪排行榜",
    "description": "全球最有钱的10个人",
    "message": "本榜单经过缜密调查,<span style=\"color:#900\">信息来源可靠</span>,内容准确无误",
    "top": [
        { "id": 1, "name": "马斯克", "money": 12987 },
        { "id": 2, "name": "比尔盖茨", "money": 900 },
        { "id": 3, "name": "马云", "money": 387 },
        { "id": 4, "name": "张一鸣", "money": 687 }
    ]
}

views/index.ejs

html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title><%= title %></title>
</head>
<body>
    <h1><%= title %></h1>
    <p><%= description %></p>

    <!-- 非转义输出:内容里的 html 标签会被浏览器解析 -->
    <p class="alert alert-warning"><%- message %></p>

    <!-- 转义输出:可以直接写表达式 -->
    <p class="alert alert-warning">
        <%= Date.now() %> <br>
        <%= Math.random() %> <br>
        <%= 10 * 7 + 8 %> <br>
    </p>

    <table border="1">
        <tr>
            <th>排名</th><th>姓名</th><th>资产</th>
        </tr>
        <% top.forEach(item => { %>
            <tr>
                <td><%= item.id %></td>
                <td><%= item.name %></td>
                <td><%= item.money %> 亿美元</td>
            </tr>
        <% }) %>
    </table>
</body>
</html>

要点解释

  • app.set('view engine', 'ejs') 只是设置,不用自己 require('ejs'),Express 会自动加载;app.set('views', ...) 指定模板目录,所以 res.render('index') 只写文件名,扩展名 .ejs 会自动补全。
  • 三种定界符:<% %> 执行语句(循环、分支,不加等号也不加减号);<%= %> 输出转义数据;<%- %> 输出非转义数据。
  • 定界符内部只能写 JS:变量、表达式、语句。HTML 结构必须写在定界符外面 —— 这就是循环要拆成 <% top.forEach(...) { %> 和 <% }) %> 两段的原因。
  • 易错点:<%= message %> 会把 <span> 转成字符实体原样显示;想让浏览器解析标签必须用 <%- message %>。
  • 易错点:<%= %> 或 <%- %> 中的等号/减号不能省,省了就变成执行语句,什么都不会输出。
  • 易错点:res.render('index', data) 第二个参数是对象,模板里用到 title,对象里就必须有 title,否则直接报错。

示例三:lowdb 操作文件数据(CRUD) ​

js
// 安装:npm i [email protected]
// 注意:新版 lowdb 是 ESM 写法,用 CommonJS 的 require 会报错,需指定 1.0.0 版本
const low = require('lowdb');
const FileSync = require('lowdb/adapters/FileSync');
const path = require('path');

// 数据存在当前目录下的 db.json 中
const adapter = new FileSync(path.resolve(__dirname, 'db.json'));
const db = low(adapter);

// 初始化:文件不存在才初始化;已存在且有数据则什么都不做,不会清空
db.defaults({ posts: [], user: {} }).write();

// ---------- 增 ----------
db.get('posts').push({ id: 1, title: '今天天气不错' }).write();      // 加到末尾
db.get('posts').unshift({ id: 101, title: '插到最前面' }).write();   // 加到最前面

// ---------- 改 ----------
db.get('posts').find({ id: 1 }).assign({ title: '改过了' }).write();

// ---------- 查 ----------
const result = db.get('posts').value();
console.log(result);

// ---------- 删 ----------
db.get('posts').remove({ id: 1 }).write();

要点解释

  • new FileSync(...) 创建适配器,low(adapter) 得到 db 实例;数据本质就是写进一个 json 文件,数据量小时够用。
  • db.defaults({...}).write() 是初始化:只判断文件是否存在,存在且有内容就什么都不做,所以反复运行不会把已有数据清空。
  • db.get('posts') 取到的就是数组,可以直接用 push / unshift 等数组方法,也可以 find({ id: 1 }) 定位某一条。
  • 易错点:增、改、删后面都必须 .write(),否则内存里改了、文件没写,刷新后数据还是旧的。
  • 易错点:读取用 .value(),不要写成 .write()。
  • 易错点:lowdb 新版改成了 ESM 模块,require 会报错,所以安装时要锁定旧版本 [email protected]。

常见错误 ​

1. 中间件里忘了 next(),页面一直转圈

  • 现象:浏览器请求一直 pending,终端里看不到任何报错,也没有响应返回。
  • 原因:中间件既没有 res.send/end 结束响应,也没有调用 next(),请求对象停在当前这一站,后面的路由永远轮不到。
  • 修法:写完中间件回头检查每个分支是否都有出口——要么结束响应,要么 next();有条件分支(if)时尤其要确认每条路径都覆盖到。

2. res.send() 之后又调了 next(),报 ERR_HTTP_HEADERS_SENT

  • 现象:控制台抛 "Cannot set headers after they are sent to the client"。
  • 原因:一个请求只能结束一次,响应已发出后继续往下走,后面的中间件/路由又试图写响应头。
  • 修法:结束响应的分支直接 return res.send(...),用 return 截断,不要再落到 next()。

3. 404 / 错误处理中间件写在了路由前面,所有请求都变 404

  • 现象:明明定义了路由,访问却总是命中 404 或错误页。
  • 原因:中间件严格按注册顺序执行,兜底的 404 中间件排在前面就会先"接住"所有请求。
  • 修法:顺序固定为 日志等前置中间件 → 业务路由 → 404 兜底 → 四参错误处理中间件((err, req, res, next))。

4. Router 挂载路径与内部路径重复,访问变成 /login/login

  • 现象:app.use('/login', loginRouter) 后,访问 /login 拿不到页面。
  • 原因:挂载路径和 Router 内部 route.get('/login') 的路径叠加了,Router 内部路径是相对于挂载点的。
  • 修法:挂载点已经带了前缀,Router 内部就只写 /;要么就把挂载改成 app.use(loginRouter),两者只留一个。

5. EJS 里把 HTML 写进了 <% %> 里面,页面报语法错误或结构错乱

  • 现象:SyntaxError: Unexpected token '<',或者循环只渲染出一条。
  • 原因:定界符里只能写 JS 语句/表达式,HTML 必须写在定界符外面;循环的 { 和 } 要分别包在两个 <% %> 里,中间夹 HTML。
  • 修法:改成 <% arr.forEach(item => { %> … HTML … <% }) %> 的写法。

6. 模板里用了 title 但渲染时报 "title is not defined"

  • 现象:res.render('index') 直接 500。
  • 原因:只加载了模板没传数据,模板中引用的变量在渲染上下文里不存在。
  • 修法:res.render('index', data) 传入对象,且模板中引用的每个名字都必须在对象里有对应属性。

7. 改了模板扩展名后 500,日志里说找不到模板文件

  • 现象:Failed to lookup view "index" in views directory。
  • 原因:app.set('view engine', ...) 决定了去 views 目录找什么后缀的文件,扩展名和引擎设置不一致。
  • 修法:要么把文件后缀改回与引擎一致,要么按"手动 require 引擎 + app.set('view engine', 'html') + app.engine('html', ...)"三步一起改,缺一步都不行。

8. express-generator 生成完直接 npm start,报模块找不到 / 端口被占

  • 现象:Cannot find module 'xxx',或 EADDRINUSE :::3000。
  • 原因:脚手架只生成目录结构不带 node_modules;端口被上一个没退干净的进程占着。
  • 修法:先 npm install 再启动;端口占用用 netstat -ano | findstr 3000 找到 PID 后 taskkill /PID xxx /F 结束掉。

9. lowdb 修改数据后忘了 write(),重启数据全没了

  • 现象:db.data.posts.push(...) 后控制台看着正常,文件内容没变。
  • 原因:get() 拿到的是内存中的引用,只有 write() 才把内存落盘。
  • 修法:任何增/删/改之后都链式补上 .write(),读操作才不需要。

10. 直接 npm i lowdb 装到新版,require 报 ESM 错误

  • 现象:require() of ES Module ... not supported。
  • 原因:新版 lowdb 是纯 ESM 包,与当前 CommonJS 写法不兼容。
  • 修法:锁定旧版本 npm i [email protected],与现有 require 风格保持一致。

练习 ​

练习 1|中间件的顺序与出口 给应用写两个中间件:一个打印 [时间] METHOD URL,一个给 req 挂一个自定义属性。要求:① 两者都挂在所有路由之前;② 故意把第一个中间件的 next() 注释掉,观察现象并记录;③ 恢复后,再故意在某个路由里 res.send() 之后写 next(),观察报错。最后把两次现象和原因各写一行注释在代码里。

练习 2|路由模块化的路径拼接 建 routes/index.js、routes/login.js 两个 Router,在入口分别用 app.use(indexRouter) 和 app.use('/login', loginRouter) 挂载。要求:① 让 / 重定向到 /index;② 让 /login 能 GET 到表单、POST 提交成功;③ 故意在 login Router 内部把 route.get('/') 改成 route.get('/login'),访问验证出现什么路径才通,然后改回正确写法并说明原因。

练习 3|EJS 三种定界符对比 准备一份 data/top.json,含 title、description、message(message 里带 <span style="color:#900"> 标签)、top 数组(id/name/money)。用 EJS 渲染成表格,要求:① 标题用 <%= %> 输出;② message 分别用 <%= %> 和 <%- %> 各渲染一次,对比浏览器里的显示差异;③ 表格用 <% %> 循环生成,并额外输出 Date.now() 和 10 * 7 + 8 验证表达式能力。

练习 4|脚手架 + 模板改造 用 express-generator 生成项目(--view=ejs),要求:① 补上 npm install 并成功启动到 3000 端口;② 把 package.json 里的 node 改成 nodemon,验证改代码自动重启;③ 打开 views/error.ejs,删掉暴露文件路径的那部分输出,只保留 message,访问一个不存在的路径验证 404 页面变干净。

练习 5|lowdb 完成记账本 CRUD 用 [email protected] 建 db.json,字段 posts: []。要求:① 初始化(文件已存在且有数据时不清空);② 新增一条记录并 write(),连跑三次验证是追加而非覆盖;③ 用 find().assign() 改一条的金额;④ 用 remove({id: 1}).write() 删除;⑤ 用 get('posts').value() 读出全部并打印。最后把"忘了 write 会怎样"的验证结果写进注释。

原视频对照 ​

#视频时长
1流程分析03:34
2模板引擎34:18
3项目演示05:06
4修改模板文件的扩展名03:30
5express项目生成工具14:43
6lowdb使用演示14:24

小结 ​

  1. 顺序即逻辑:Express 里没有"优先级",只有注册顺序。日志 → 路由 → 404 → 错误处理,这条线写反了,功能一定出问题。
  2. 每个中间件都必须有明确出口:要么结束响应(res.send/end/render/json),要么 next(),二者必居其一且只能其一。
  3. Router 内部路径是相对的:挂载点 app.use('/login', ...) 已经把前缀吃掉了,内部只写 /,不要重复拼。
  4. EJS 的三种定界符各司其职:<% %> 写语句(循环/分支,HTML 必须放外面)、<%= %> 输出并转义、<%- %> 输出不转义——只有确实要让浏览器解析标签时才用后者。
  5. res.render 的第二参数是"数据合同":模板里用到的每个名字,都必须在传进去的对象里有对应字段,否则渲染直接失败。
  6. 模板引擎的扩展名不是随便改的:改后缀必须同时改引擎注册,否则报的是"找不到模板"而不是"语法错误",容易误判方向。
  7. 脚手架的产物 ≠ 可运行项目:npm install 是必做动作,node_modules 永远不进版本库,谁同步谁重装。
  8. lowdb 只是"能落盘的对象":改内存 ≠ 改文件,write() 是唯一落盘动作;数据量一大就该换真正的数据库了。