主题
中间件与express.Router 路由模块化(Day11 2/3)
本页对应 Day11_Node,共 6 段视频,约 1 小时 15 分。
学习目标
- 能够按请求到达顺序说明 Express 中间件执行流程,并实现访问日志中间件、路由处理和错误处理中间件,验证错误时返回 500。
- 能够使用
express.Router将路由拆分为独立模块,并在入口文件中按路径挂载和验证各路由匹配行为。 - 能够配置 EJS 模板引擎与
views目录,使用res.render传递数据,并用<%= %>、<%- %>、<% %>分别完成转义输出、非转义输出和循环/分支语句。 - 能够将模板文件扩展名改为
.html,通过显式引入 EJS、设置app.engine和模板目录完成配置,并排查模板找不到导致的 500 错误。 - 能够使用
express-generator生成 Express 项目,识别app.js、bin/www、routes、views、public的职责,安装依赖并通过npm start启动项目。 - 能够使用指定旧版
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 后输出。
课堂画面






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