一篇面向实战的 Express 进阶博客:应用级/错误处理中间件 、express.Router 模块化 、EJS 服务端渲染 、express-generator + LowDB 记账本。示例可独立运行,不依赖外部讲义路径。
目录
- 导读:知识架构与权威参考
- 本文解决什么问题
- 知识脉络(Mermaid)
- 权威文档
- [与 Day10 的衔接](#与 Day10 的衔接)
- [1. Node.js 基础回顾](#1. Node.js 基础回顾)
- [1.1 核心模块体系](#1.1 核心模块体系)
- [1.2 HTTP 模块基础示例](#1.2 HTTP 模块基础示例)
- [1.3 HTTP 协议基础](#1.3 HTTP 协议基础)
- [2. Express 框架概述](#2. Express 框架概述)
- [2.1 Express 是什么](#2.1 Express 是什么)
- [2.2 Express 架构设计](#2.2 Express 架构设计)
- [2.3 Express 应用基本结构](#2.3 Express 应用基本结构)
- [3. 中间件机制深度解析](#3. 中间件机制深度解析)
- [3.1 中间件概念解析](#3.1 中间件概念解析)
- [3.2 中间件功能分类](#3.2 中间件功能分类)
- [3.2.1 Express 内置中间件详解](#3.2.1 Express 内置中间件详解)
- [3.2.2 第三方中间件推荐](#3.2.2 第三方中间件推荐)
- [3.3 应用级中间件](#3.3 应用级中间件)
- [3.4 错误处理中间件](#3.4 错误处理中间件)
- [3.5 中间件使用示例](#3.5 中间件使用示例)
- [3.6 中间件最佳实践](#3.6 中间件最佳实践)
- [3.7 经典中间件场景实战](#3.7 经典中间件场景实战)
- [3.8 中间件链设计原则总结](#3.8 中间件链设计原则总结)
- [4. 路由模块化架构](#4. 路由模块化架构)
- [4.1 路由模块化的必要性](#4.1 路由模块化的必要性)
- [4.2 express.Router 基础](#4.2 express.Router 基础)
- [4.3 路由模块的挂载](#4.3 路由模块的挂载)
- [4.4 路由模块化架构图](#4.4 路由模块化架构图)
- [4.5 RESTful 路由设计](#4.5 RESTful 路由设计)
- [5. 模板引擎与 EJS](#5. 模板引擎与 EJS)
- [5.1 模板引擎概述](#5.1 模板引擎概述)
- [5.2 EJS 模板引擎](#5.2 EJS 模板引擎)
- [5.3 Express 中配置 EJS](#5.3 Express 中配置 EJS)
- [5.4 EJS 模板实例](#5.4 EJS 模板实例)
- [5.5 条件渲染](#5.5 条件渲染)
- [5.6 修改模板扩展名](#5.6 修改模板扩展名)
- [5.7 EJS 高级实战:分页 + Flash 消息](#5.7 EJS 高级实战:分页 + Flash 消息)
- [5.8 模板继承与布局](#5.8 模板继承与布局)
- [5.9 LowDB 轻量级数据库详解](#5.9 LowDB 轻量级数据库详解)
- [5.10 项目实战案例:完整的记账本应用](#5.10 项目实战案例:完整的记账本应用)
- [6. 项目实战:记账本应用](#6. 项目实战:记账本应用)
- [6.1 项目概述](#6.1 项目概述)
- [6.2 技术栈选择](#6.2 技术栈选择)
- [6.3 项目结构设计](#6.3 项目结构设计)
- [6.4 路由设计](#6.4 路由设计)
- [6.5 数据库设计](#6.5 数据库设计)
- [6.6 项目实现](#6.6 项目实现)
- [6.7 项目部署流程](#6.7 项目部署流程)
- [7. 最佳实践与性能优化](#7. 最佳实践与性能优化)
- [7.1 项目结构最佳实践](#7.1 项目结构最佳实践)
- [7.2 错误处理最佳实践](#7.2 错误处理最佳实践)
- [7.3 安全最佳实践](#7.3 安全最佳实践)
- [7.4 性能优化技巧](#7.4 性能优化技巧)
- [7.5 日志管理](#7.5 日志管理)
- [7.6 环境配置管理](#7.6 环境配置管理)
- [7.7 数据库连接优化](#7.7 数据库连接优化)
- [7.8 缓存策略实现](#7.8 缓存策略实现)
- [7.9 请求验证最佳实践](#7.9 请求验证最佳实践)
- [7.10 文件上传处理](#7.10 文件上传处理)
- [7.11 实时通信集成](#7.11 实时通信集成)
- [8. 高级主题与架构设计](#8. 高级主题与架构设计)
- [8.1 微服务架构设计](#8.1 微服务架构设计)
- [8.2 消息队列集成](#8.2 消息队列集成)
- [8.3 认证授权系统](#8.3 认证授权系统)
- [8.3.1 Cookie/Session 与 JWT 认证深度对比](#8.3.1 Cookie/Session 与 JWT 认证深度对比)
- [8.4 测试策略](#8.4 测试策略)
- [8.5 部署与运维](#8.5 部署与运维)
- [8.6 Express 与 Koa 中间件模型深度对比](#8.6 Express 与 Koa 中间件模型深度对比)
- [9. 总结与进阶学习](#9. 总结与进阶学习)
- [9.1 核心概念总结](#9.1 核心概念总结)
- [9.2 学习路径建议](#9.2 学习路径建议)
- [9.3 技术栈演进路径](#9.3 技术栈演进路径)
- [9.4 常见问题解决手册](#9.4 常见问题解决手册)
- [9.5 性能优化检查清单](#9.5 性能优化检查清单)
- [9.6 安全检查清单](#9.6 安全检查清单)
- [9.7 监控和调试](#9.7 监控和调试)
- [9.8 真实世界案例研究](#9.8 真实世界案例研究)
- [9.9 面试准备重点](#9.9 面试准备重点)
- [9.10 社区与资源](#9.10 社区与资源)
- [10. 结语](#10. 结语)
- [11. 核心案例速查与知识点归纳](#11. 核心案例速查与知识点归纳)
- [11.1 课堂案例学习路线](#11.1 课堂案例学习路线)
- [11.2 中间件执行顺序(必背)](#11.2 中间件执行顺序(必背))
- [11.3 访问日志中间件(精简可运行)](#11.3 访问日志中间件(精简可运行))
- [11.4 路由模块化挂载](#11.4 路由模块化挂载)
- [11.5 记账本六步实施清单](#11.5 记账本六步实施清单)
- [11.6 可运行 HTML:记账表单(对接 POST)](#11.6 可运行 HTML:记账表单(对接 POST))
- [11.7 常见坑](#11.7 常见坑)
- [11.8 async 路由错误处理速查](#11.8 async 路由错误处理速查)
- 结语
导读:知识架构与权威参考
本文解决什么问题
| 模块 | 你会掌握 | 典型产出 |
|---|---|---|
| 中间件 | app.use、next()、四参数错误处理 |
访问日志、统一 500 |
| Router | express.Router()、app.use('/login', router) |
按业务拆分路由文件 |
| EJS | res.render、<%= / <%- |
列表页、表单页 |
| 工程化 | express-generator、bin/www |
标准 MVC 目录 |
| 数据 | LowDB + shortid | 轻量 JSON 持久化 |
| 实战 | 记账本 CRUD | 可演示的完整小项目 |
知识脉络(Mermaid)
#mermaid-svg-a6qMhAun4xSqNzdL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-a6qMhAun4xSqNzdL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-a6qMhAun4xSqNzdL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-a6qMhAun4xSqNzdL .error-icon{fill:#552222;}#mermaid-svg-a6qMhAun4xSqNzdL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-a6qMhAun4xSqNzdL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-a6qMhAun4xSqNzdL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-a6qMhAun4xSqNzdL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-a6qMhAun4xSqNzdL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-a6qMhAun4xSqNzdL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-a6qMhAun4xSqNzdL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-a6qMhAun4xSqNzdL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-a6qMhAun4xSqNzdL .marker.cross{stroke:#333333;}#mermaid-svg-a6qMhAun4xSqNzdL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-a6qMhAun4xSqNzdL p{margin:0;}#mermaid-svg-a6qMhAun4xSqNzdL .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-a6qMhAun4xSqNzdL .cluster-label text{fill:#333;}#mermaid-svg-a6qMhAun4xSqNzdL .cluster-label span{color:#333;}#mermaid-svg-a6qMhAun4xSqNzdL .cluster-label span p{background-color:transparent;}#mermaid-svg-a6qMhAun4xSqNzdL .label text,#mermaid-svg-a6qMhAun4xSqNzdL span{fill:#333;color:#333;}#mermaid-svg-a6qMhAun4xSqNzdL .node rect,#mermaid-svg-a6qMhAun4xSqNzdL .node circle,#mermaid-svg-a6qMhAun4xSqNzdL .node ellipse,#mermaid-svg-a6qMhAun4xSqNzdL .node polygon,#mermaid-svg-a6qMhAun4xSqNzdL .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-a6qMhAun4xSqNzdL .rough-node .label text,#mermaid-svg-a6qMhAun4xSqNzdL .node .label text,#mermaid-svg-a6qMhAun4xSqNzdL .image-shape .label,#mermaid-svg-a6qMhAun4xSqNzdL .icon-shape .label{text-anchor:middle;}#mermaid-svg-a6qMhAun4xSqNzdL .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-a6qMhAun4xSqNzdL .rough-node .label,#mermaid-svg-a6qMhAun4xSqNzdL .node .label,#mermaid-svg-a6qMhAun4xSqNzdL .image-shape .label,#mermaid-svg-a6qMhAun4xSqNzdL .icon-shape .label{text-align:center;}#mermaid-svg-a6qMhAun4xSqNzdL .node.clickable{cursor:pointer;}#mermaid-svg-a6qMhAun4xSqNzdL .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-a6qMhAun4xSqNzdL .arrowheadPath{fill:#333333;}#mermaid-svg-a6qMhAun4xSqNzdL .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-a6qMhAun4xSqNzdL .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-a6qMhAun4xSqNzdL .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-a6qMhAun4xSqNzdL .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-a6qMhAun4xSqNzdL .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-a6qMhAun4xSqNzdL .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-a6qMhAun4xSqNzdL .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-a6qMhAun4xSqNzdL .cluster text{fill:#333;}#mermaid-svg-a6qMhAun4xSqNzdL .cluster span{color:#333;}#mermaid-svg-a6qMhAun4xSqNzdL div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-a6qMhAun4xSqNzdL .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-a6qMhAun4xSqNzdL rect.text{fill:none;stroke-width:0;}#mermaid-svg-a6qMhAun4xSqNzdL .icon-shape,#mermaid-svg-a6qMhAun4xSqNzdL .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-a6qMhAun4xSqNzdL .icon-shape p,#mermaid-svg-a6qMhAun4xSqNzdL .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-a6qMhAun4xSqNzdL .icon-shape .label rect,#mermaid-svg-a6qMhAun4xSqNzdL .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-a6qMhAun4xSqNzdL .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-a6qMhAun4xSqNzdL .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-a6qMhAun4xSqNzdL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Express app
中间件链
路由 / Router
EJS 渲染
LowDB JSON
错误处理中间件
权威文档
| 主题 | 链接 |
|---|---|
| Express | expressjs.com |
| Express 中文 | expressjs.com.cn |
| EJS | ejs.co |
| express-generator | GitHub expressjs/generator |
| LowDB v1 | GitHub typicode/lowdb |
与 Day10 的衔接
- Day10 用原生路由拼页面;本章用 中间件 + 模板 + 生成器 升级为可维护项目结构。
- 记账本路由表与 Day10 新闻列表类似,但改为 服务端渲染 + 持久化存储。
1. Node.js基础回顾
1.1 核心模块体系
Node.js作为服务端JavaScript运行环境,提供了丰富的内置模块来处理各种服务端任务:
内置模块总览:
- path模块:处理文件路径,解决跨平台路径问题
- url模块:解析URL字符串,提取查询参数
- querystring模块:处理查询字符串的解析和序列化
- fs模块:文件系统操作,读写文件
- http模块:创建HTTP服务器和客户端
名词解释:
- 内置模块:Node.js核心自带的模块,无需安装即可使用
- 自定义模块:开发者自行编写的模块,通过module.exports暴露接口
- npm:Node Package Manager,全球最大的开源库生态系统
- CommonJS:Node.js的模块规范,定义了模块的导入和导出方式
- REPL:Read-Eval-Print Loop,Node.js的交互式解释器环境
1.2 HTTP模块基础示例
javascript
// 基础HTTP服务器示例
const http = require('http');
// 创建HTTP服务器
const server = http.createServer((req, res) => {
// 设置响应头
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
// 根据不同的URL路径返回不同的内容
if (req.url === '/') {
res.end('<h1>欢迎来到首页</h1>');
} else if (req.url === '/about') {
res.end('<h1>关于我们</h1>');
} else {
res.writeHead(404);
res.end('<h1>页面未找到</h1>');
}
});
// 监听端口
server.listen(3000, () => {
console.log('服务器运行在 http://localhost:3000');
});
【代码注释】
http.createServer(callback)每个请求触发一次回调;Express 底层仍基于此,用路由表替代手写if (req.url === '/')。req.url含路径与查询串;本例仅判断路径,未解析?id=(需url模块或 Express 的req.query)。res.end()发送响应体并结束;未设Content-Type时默认为text/html。- 404 分支应
writeHead(404)再end,与 Express 的res.status(404).send()等价。 - 课堂端口常用 8080;与 Express 项目统一端口便于联调。
代码详解:
require('http'):导入HTTP模块createServer():创建服务器实例,回调函数处理每个请求req:请求对象,包含请求的方法、URL、headers等信息res:响应对象,用于发送响应数据writeHead():设置响应状态码和响应头end():发送响应体并结束响应
实际应用:
- 简单的静态文件服务器
- API接口开发
- 代理服务器
- 微服务架构中的服务间通信
1.2 模块系统架构
#mermaid-svg-CsKqaw2kYMsTcIGd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-CsKqaw2kYMsTcIGd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CsKqaw2kYMsTcIGd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CsKqaw2kYMsTcIGd .error-icon{fill:#552222;}#mermaid-svg-CsKqaw2kYMsTcIGd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CsKqaw2kYMsTcIGd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CsKqaw2kYMsTcIGd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CsKqaw2kYMsTcIGd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CsKqaw2kYMsTcIGd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CsKqaw2kYMsTcIGd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CsKqaw2kYMsTcIGd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CsKqaw2kYMsTcIGd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CsKqaw2kYMsTcIGd .marker.cross{stroke:#333333;}#mermaid-svg-CsKqaw2kYMsTcIGd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CsKqaw2kYMsTcIGd p{margin:0;}#mermaid-svg-CsKqaw2kYMsTcIGd .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-CsKqaw2kYMsTcIGd .cluster-label text{fill:#333;}#mermaid-svg-CsKqaw2kYMsTcIGd .cluster-label span{color:#333;}#mermaid-svg-CsKqaw2kYMsTcIGd .cluster-label span p{background-color:transparent;}#mermaid-svg-CsKqaw2kYMsTcIGd .label text,#mermaid-svg-CsKqaw2kYMsTcIGd span{fill:#333;color:#333;}#mermaid-svg-CsKqaw2kYMsTcIGd .node rect,#mermaid-svg-CsKqaw2kYMsTcIGd .node circle,#mermaid-svg-CsKqaw2kYMsTcIGd .node ellipse,#mermaid-svg-CsKqaw2kYMsTcIGd .node polygon,#mermaid-svg-CsKqaw2kYMsTcIGd .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-CsKqaw2kYMsTcIGd .rough-node .label text,#mermaid-svg-CsKqaw2kYMsTcIGd .node .label text,#mermaid-svg-CsKqaw2kYMsTcIGd .image-shape .label,#mermaid-svg-CsKqaw2kYMsTcIGd .icon-shape .label{text-anchor:middle;}#mermaid-svg-CsKqaw2kYMsTcIGd .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-CsKqaw2kYMsTcIGd .rough-node .label,#mermaid-svg-CsKqaw2kYMsTcIGd .node .label,#mermaid-svg-CsKqaw2kYMsTcIGd .image-shape .label,#mermaid-svg-CsKqaw2kYMsTcIGd .icon-shape .label{text-align:center;}#mermaid-svg-CsKqaw2kYMsTcIGd .node.clickable{cursor:pointer;}#mermaid-svg-CsKqaw2kYMsTcIGd .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-CsKqaw2kYMsTcIGd .arrowheadPath{fill:#333333;}#mermaid-svg-CsKqaw2kYMsTcIGd .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-CsKqaw2kYMsTcIGd .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-CsKqaw2kYMsTcIGd .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CsKqaw2kYMsTcIGd .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-CsKqaw2kYMsTcIGd .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CsKqaw2kYMsTcIGd .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-CsKqaw2kYMsTcIGd .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-CsKqaw2kYMsTcIGd .cluster text{fill:#333;}#mermaid-svg-CsKqaw2kYMsTcIGd .cluster span{color:#333;}#mermaid-svg-CsKqaw2kYMsTcIGd div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-CsKqaw2kYMsTcIGd .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-CsKqaw2kYMsTcIGd rect.text{fill:none;stroke-width:0;}#mermaid-svg-CsKqaw2kYMsTcIGd .icon-shape,#mermaid-svg-CsKqaw2kYMsTcIGd .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CsKqaw2kYMsTcIGd .icon-shape p,#mermaid-svg-CsKqaw2kYMsTcIGd .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-CsKqaw2kYMsTcIGd .icon-shape .label rect,#mermaid-svg-CsKqaw2kYMsTcIGd .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CsKqaw2kYMsTcIGd .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-CsKqaw2kYMsTcIGd .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-CsKqaw2kYMsTcIGd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Node.js应用
内置模块
自定义模块
第三方模块npm
path/url/fs/http
业务逻辑模块
express/moment/lowdb
1.3 HTTP协议基础
HTTP(HyperText Transfer Protocol)是Web应用的基础协议,理解HTTP对于Web开发至关重要:
关键概念:
- 请求方法:GET、POST、PUT、DELETE等
- 状态码:200(成功)、404(未找到)、500(服务器错误)
- 请求头:Content-Type、Authorization等
- 响应头:Content-Type、Set-Cookie等
实际应用场景:
- RESTful API设计
- 前后端分离架构
- 微服务通信
2. Express框架概述
2.1 Express是什么
Express是Node.js平台上最流行的Web应用框架,提供了简洁而强大的功能来构建Web应用和API。
核心特性:
- 轻量级:核心功能精简,按需扩展
- 中间件系统:强大的插件机制
- 路由系统:灵活的URL映射
- 模板引擎支持:多种模板引擎集成
市场应用:
- 百度、阿里巴巴等大型互联网公司
- 企业级Web应用
- 微服务架构中的API网关
2.2 Express架构设计
#mermaid-svg-AG5bUUK1kxpuhHE3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-AG5bUUK1kxpuhHE3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-AG5bUUK1kxpuhHE3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-AG5bUUK1kxpuhHE3 .error-icon{fill:#552222;}#mermaid-svg-AG5bUUK1kxpuhHE3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-AG5bUUK1kxpuhHE3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-AG5bUUK1kxpuhHE3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-AG5bUUK1kxpuhHE3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-AG5bUUK1kxpuhHE3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-AG5bUUK1kxpuhHE3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-AG5bUUK1kxpuhHE3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-AG5bUUK1kxpuhHE3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-AG5bUUK1kxpuhHE3 .marker.cross{stroke:#333333;}#mermaid-svg-AG5bUUK1kxpuhHE3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-AG5bUUK1kxpuhHE3 p{margin:0;}#mermaid-svg-AG5bUUK1kxpuhHE3 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-AG5bUUK1kxpuhHE3 .cluster-label text{fill:#333;}#mermaid-svg-AG5bUUK1kxpuhHE3 .cluster-label span{color:#333;}#mermaid-svg-AG5bUUK1kxpuhHE3 .cluster-label span p{background-color:transparent;}#mermaid-svg-AG5bUUK1kxpuhHE3 .label text,#mermaid-svg-AG5bUUK1kxpuhHE3 span{fill:#333;color:#333;}#mermaid-svg-AG5bUUK1kxpuhHE3 .node rect,#mermaid-svg-AG5bUUK1kxpuhHE3 .node circle,#mermaid-svg-AG5bUUK1kxpuhHE3 .node ellipse,#mermaid-svg-AG5bUUK1kxpuhHE3 .node polygon,#mermaid-svg-AG5bUUK1kxpuhHE3 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-AG5bUUK1kxpuhHE3 .rough-node .label text,#mermaid-svg-AG5bUUK1kxpuhHE3 .node .label text,#mermaid-svg-AG5bUUK1kxpuhHE3 .image-shape .label,#mermaid-svg-AG5bUUK1kxpuhHE3 .icon-shape .label{text-anchor:middle;}#mermaid-svg-AG5bUUK1kxpuhHE3 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-AG5bUUK1kxpuhHE3 .rough-node .label,#mermaid-svg-AG5bUUK1kxpuhHE3 .node .label,#mermaid-svg-AG5bUUK1kxpuhHE3 .image-shape .label,#mermaid-svg-AG5bUUK1kxpuhHE3 .icon-shape .label{text-align:center;}#mermaid-svg-AG5bUUK1kxpuhHE3 .node.clickable{cursor:pointer;}#mermaid-svg-AG5bUUK1kxpuhHE3 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-AG5bUUK1kxpuhHE3 .arrowheadPath{fill:#333333;}#mermaid-svg-AG5bUUK1kxpuhHE3 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-AG5bUUK1kxpuhHE3 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-AG5bUUK1kxpuhHE3 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AG5bUUK1kxpuhHE3 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-AG5bUUK1kxpuhHE3 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AG5bUUK1kxpuhHE3 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-AG5bUUK1kxpuhHE3 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-AG5bUUK1kxpuhHE3 .cluster text{fill:#333;}#mermaid-svg-AG5bUUK1kxpuhHE3 .cluster span{color:#333;}#mermaid-svg-AG5bUUK1kxpuhHE3 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-AG5bUUK1kxpuhHE3 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-AG5bUUK1kxpuhHE3 rect.text{fill:none;stroke-width:0;}#mermaid-svg-AG5bUUK1kxpuhHE3 .icon-shape,#mermaid-svg-AG5bUUK1kxpuhHE3 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AG5bUUK1kxpuhHE3 .icon-shape p,#mermaid-svg-AG5bUUK1kxpuhHE3 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-AG5bUUK1kxpuhHE3 .icon-shape .label rect,#mermaid-svg-AG5bUUK1kxpuhHE3 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AG5bUUK1kxpuhHE3 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-AG5bUUK1kxpuhHE3 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-AG5bUUK1kxpuhHE3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} HTTP请求
Express应用
中间件层1
中间件层2
路由层
控制器层
响应
2.3 Express应用基本结构
javascript
// 导入Express模块
const express = require('express');
// 创建应用实例
const app = express();
// 定义路由
app.get('/', (req, res) => {
res.send('Hello World');
});
// 启动服务器
app.listen(3000, () => {
console.log('服务器运行在端口3000');
});
【代码注释】
- 四步骨架:
require→express()→app.METHOD(path, handler)→app.listen(port)。 app.get只匹配 GET;同路径 POST 需app.post或app.route('/').get().post()。res.send('Hello World')自动设置类型并发送;等价于简化版的writeHead+end。app可视为「中间件 + 路由」的容器;后续app.use在listen之前注册即可。- 生成器项目入口常为
bin/www,app.js只导出app不直接listen(见 §6.5)。
代码注释详解:
require('express'):导入Express框架模块express():创建Express应用实例app.get():定义GET请求路由req:请求对象,包含客户端发送的所有信息res:响应对象,用于向客户端发送响应app.listen():启动HTTP服务器并监听指定端口
3. 中间件机制深度解析
3.1 中间件概念解析
**中间件(Middleware)**是Express的核心概念,它是一个函数,可以访问请求对象(req)、响应对象(res)和下一个中间件函数(next)。
中间件执行流程:
响应 路由处理器 中间件2 中间件1 客户端 响应 路由处理器 中间件2 中间件1 客户端 #mermaid-svg-Xo0CJSird0xd7t0M{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Xo0CJSird0xd7t0M .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Xo0CJSird0xd7t0M .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Xo0CJSird0xd7t0M .error-icon{fill:#552222;}#mermaid-svg-Xo0CJSird0xd7t0M .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Xo0CJSird0xd7t0M .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Xo0CJSird0xd7t0M .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Xo0CJSird0xd7t0M .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Xo0CJSird0xd7t0M .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Xo0CJSird0xd7t0M .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Xo0CJSird0xd7t0M .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Xo0CJSird0xd7t0M .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Xo0CJSird0xd7t0M .marker.cross{stroke:#333333;}#mermaid-svg-Xo0CJSird0xd7t0M svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Xo0CJSird0xd7t0M p{margin:0;}#mermaid-svg-Xo0CJSird0xd7t0M .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Xo0CJSird0xd7t0M text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-Xo0CJSird0xd7t0M .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Xo0CJSird0xd7t0M .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-Xo0CJSird0xd7t0M .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-Xo0CJSird0xd7t0M .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-Xo0CJSird0xd7t0M #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-Xo0CJSird0xd7t0M .sequenceNumber{fill:white;}#mermaid-svg-Xo0CJSird0xd7t0M #sequencenumber{fill:#333;}#mermaid-svg-Xo0CJSird0xd7t0M #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-Xo0CJSird0xd7t0M .messageText{fill:#333;stroke:none;}#mermaid-svg-Xo0CJSird0xd7t0M .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Xo0CJSird0xd7t0M .labelText,#mermaid-svg-Xo0CJSird0xd7t0M .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-Xo0CJSird0xd7t0M .loopText,#mermaid-svg-Xo0CJSird0xd7t0M .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-Xo0CJSird0xd7t0M .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Xo0CJSird0xd7t0M .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Xo0CJSird0xd7t0M .noteText,#mermaid-svg-Xo0CJSird0xd7t0M .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-Xo0CJSird0xd7t0M .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Xo0CJSird0xd7t0M .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Xo0CJSird0xd7t0M .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Xo0CJSird0xd7t0M .actorPopupMenu{position:absolute;}#mermaid-svg-Xo0CJSird0xd7t0M .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-Xo0CJSird0xd7t0M .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Xo0CJSird0xd7t0M .actor-man circle,#mermaid-svg-Xo0CJSird0xd7t0M line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-Xo0CJSird0xd7t0M :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} HTTP请求 处理请求逻辑 next()调用 处理请求逻辑 next()调用 发送响应 HTTP响应
3.2 中间件功能分类
根据功能划分:
- 日志中间件:记录请求信息
- 认证中间件:验证用户身份
- 错误处理中间件:统一错误处理
- 静态文件中间件:处理静态资源
- Body解析中间件:解析请求体
- Cookie中间件:处理Cookie信息
- 会话中间件:管理用户会话
3.2.1 Express内置中间件详解
Express提供了多个内置中间件,开发者可以直接使用:
1. express.static() - 静态文件服务
javascript
const express = require('express');
const app = express();
// 托管静态文件
app.use(express.static('public'));
app.use('/static', express.static('public'));
// 访问示例
// http://localhost:3000/images/logo.png
// http://localhost:3000/static/css/style.css
【代码注释】
app.use(express.static('public')):请求/css/app.css映射到public/css/app.css;目录不存在则next()继续后续中间件。- 虚拟前缀
app.use('/static', express.static('public'))访问路径为/static/...,HTML 引用须一致。 - 与 Day10 原生静态服务对比:Express 内置 MIME、
index.html默认页、304 协商缓存。 - 记账本项目
public/放 Bootstrap、自定义 CSS;模板里<link href="/css/...">依赖此中间件。
实际应用场景:
- 网站的CSS和JavaScript文件
- 图片、字体等静态资源
- 下载文件服务
2. express.json() - JSON请求体解析
javascript
// 解析Content-Type为application/json的请求体
app.use(express.json());
app.post('/api/users', (req, res) => {
// 现在可以直接访问req.body中的JSON数据
const userData = req.body;
console.log(userData);
// 处理用户数据
res.json({ success: true, data: userData });
});
【代码注释】
- 解析
Content-Type: application/json的请求体;必须在路由之前app.use(express.json())。 - 未挂载时
req.body为undefined;前端fetchPOST JSON 需对应头与中间件。 - 与
urlencoded并存:表单用后者,API 用前者;记账本课堂以表单为主。
实际应用场景:
- RESTful API接口
- 前后端分离的AJAX请求
- 移动应用后端接口
3. express.urlencoded() - 表单数据解析
javascript
// 解析Content-Type为application/x-www-form-urlencoded的请求体
app.use(express.urlencoded({ extended: false }));
app.post('/login', (req, res) => {
// 现在可以直接访问req.body中的表单数据
const { username, password } = req.body;
// 验证用户名和密码
if (username === 'admin' && password === '123456') {
res.send('登录成功');
} else {
res.send('用户名或密码错误');
}
});
【代码注释】
- 解析
application/x-www-form-urlencoded(<form method="post">默认类型)。 extended: false使用querystring,不支持嵌套对象;true用qs,支持user[name]等。- 记账本
POST /account/create依赖此中间件,字段通过name属性进入req.body。 extended: true为 Express 生成器默认配置,与课堂表单字段名一致即可。
实际应用场景:
- 用户登录表单
- 数据录入表单
- 文件上传表单(配合multer)
3.2.2 第三方中间件推荐
常用第三方中间件:
1. Morgan - HTTP请求日志记录器
javascript
const morgan = require('morgan');
// 开发环境日志格式
if (process.env.NODE_ENV === 'development') {
app.use(morgan('dev'));
}
// 生产环境日志格式
if (process.env.NODE_ENV === 'production') {
app.use(morgan('combined'));
}
// 自定义日志格式
app.use(morgan(':method :url :status :res[content-length] - :response-time ms'));
【代码注释】
dev:彩色简短日志,适合本地;combined:含 IP、Referer,接近 Apache 访问日志。- 自定义格式令牌
:method、:url、:status、:response-time可对接日志平台。 - 应放在路由之前 ,才能记录到所有请求;生产可写文件或 Winston 替代
console。
实际应用场景:
- 开发调试
- 访问统计分析
- 性能监控
- 安全审计
2. Helmet - 安全HTTP头设置
javascript
const helmet = require('helmet');
// 使用所有Helmet安全特性
app.use(helmet());
// 或选择性使用
app.use(helmet.contentSecurityPolicy());
app.use(helmet.hsts());
【代码注释】
- 默认设置
X-Content-Type-Options、X-Frame-Options、CSP 等,降低 XSS、点击劫持风险。 - 可按需只启用子中间件,避免与内联脚本、CDN 冲突(CSP 需白名单域名)。
- API 纯 JSON 服务同样建议生产环境启用;与 HTTPS 配合
hsts强制安全连接。
实际应用场景:
- 防止XSS攻击
- 防止点击劫持
- 强制HTTPS连接
- 设置内容安全策略
3. CORS - 跨域资源共享
javascript
const cors = require('cors');
// 简单启用CORS
app.use(cors());
// 配置CORS选项
app.use(cors({
origin: 'https://example.com', // 允许的源
methods: ['GET', 'POST'], // 允许的HTTP方法
allowedHeaders: ['Content-Type'], // 允许的请求头
credentials: true, // 允许发送Cookie
maxAge: 3600 // 预检请求缓存时间(秒)
}));
【代码注释】
- 浏览器跨域时先发 OPTIONS 预检;
cors()自动加Access-Control-Allow-*响应头。 credentials: true时origin不能为*,须指定具体域名;Cookie 跨域需前后端配合。- 同源部署(Express 同时托管 HTML + API)可无 CORS;分离部署必配(见 §11.6)。
实际应用场景:
- 前后端分离项目
- 微服务架构
- API开放平台
- 移动应用后端
3.3 应用级中间件
应用级中间件绑定在Express应用对象(app)上,使用app.use()或路由方法挂载。
示例1:自定义访问日志中间件
javascript
const moment = require('moment');
const fs = require('fs');
const path = require('path');
module.exports = (req, res, next) => {
// 从请求报文中获取信息
const ip = req.ip.slice(7); // 去除::ffff:前缀
const method = req.method; // HTTP方法
const url = req.url; // 请求URL
const dt = moment().format('YYYY-MM-DD HH:mm:ss'); // 当前时间
// 拼接日志内容
const logMsg = `${ip} ${dt} ${method} ${url}\n`;
console.log(logMsg);
// 写入文件
fs.appendFile(
path.resolve(__dirname, '../logs/access.log'),
logMsg,
err => {
if (err) {
throw err;
}
// 成功写入日志后放行
next();
}
);
};
【代码注释】
- 课堂核心中间件 01-Express中间件 :
module.exports = (req, res, next) => {}供app.use(accessLog)挂载。 req.ip在代理后可能是::ffff:127.0.0.1,slice(7)去掉 IPv4 映射前缀;生产应读X-Forwarded-For。fs.appendFile异步写盘,回调成功后再next(),避免日志未落盘就进入路由(§11.3 精简版同理)。throw err会进入错误处理中间件;也可next(err)统一处理。- 日志目录
logs/需事先存在,否则首次写入报错。
代码详解:
req.ip:获取客户端IP地址slice(7):去除IPv6格式的::ffff:前缀moment().format():格式化时间戳fs.appendFile():异步追加文件内容next():将控制权传递给下一个中间件
实际应用:
- 网站访问统计
- 安全审计日志
- 用户行为分析
- 性能监控
3.4 错误处理中间件
错误处理中间件有4个参数(err, req, res, next),必须放在所有其他中间件之后。
示例2:错误处理中间件
javascript
const moment = require('moment');
const fs = require('fs');
const path = require('path');
module.exports = (err, req, res, next) => {
// 获取请求信息
const ip = req.ip.slice(7);
const method = req.method;
const url = req.url;
const dt = moment().format('YYYY-MM-DD HH:mm:ss');
// 拼接错误信息
const errMsg = `${ip} ${dt} ${method} ${url} \n ${err.stack} \n\n\n\n`;
// 写入错误日志
fs.appendFile(
path.resolve(__dirname, '../logs/error.log'),
errMsg,
err => {
if (err) {
throw err;
}
}
);
// 响应500错误页面
res.status(500).send('<h1>500 服务器出错!</h1>');
};
【代码注释】
- 签名必须是
(err, req, res, next)四个参数,Express 才识别为错误处理器;少一个参数会变成普通中间件。 - 须挂在所有路由之后 ;路由内
throw err或next(err)才会进入此函数。 - 写
error.log后res.status(500).send,勿再调用next()(响应已结束)。 - 生成器
app.js在开发环境res.render('error')展示堆栈,生产隐藏细节(见 §6.5)。 - 异步路由须
try/catch+next(e),否则未捕获的 Promise 拒绝可能绕过此中间件。
代码详解:
err:错误对象,包含错误信息和堆栈err.stack:错误的堆栈跟踪信息res.status(500):设置HTTP状态码为500- 必须声明4个参数,即使不使用next也要保留
3.5 中间件使用示例
javascript
const express = require('express');
const accessLog = require('./middleware/accesslog');
const catchError = require('./middleware/catcherror');
const app = express();
// 挂载访问日志中间件
app.use(accessLog);
// 自定义中间件
app.use((req, res, next) => {
console.log('Hello, 我是中间件 Tom, How Are You?');
// 可以选择结束响应或放行
// res.send('到这就结束了!');
// 放行到下一个中间件
next();
});
// 路径特定的中间件
app.use('/login', (req, res, next) => {
console.log('刷我的卡,我是中间件,我叫泰裤辣!');
next();
});
// 路由处理器本质上也是中间件
app.get('/', (req, res) => {
res.redirect('/index');
});
app.get('/index', (req, res) => {
res.send(`
<h1>首页</h1>
<hr>
<a href="/login">登录</a>
`);
});
// 错误处理中间件必须放在最后
app.use(catchError);
app.listen(8080, () => {
console.log('http server is running on :8080');
});
【代码注释】
- 推荐顺序:
accessLog→ 全局app.use→ 路径级app.use('/login')→app.get/post路由 →catchError。 - 注释掉
res.send、保留next()演示「放行」;若send且不return,后面中间件仍可能执行导致 ERR_HTTP_HEADERS_SENT。 app.use('/login', fn)只匹配以/login开头的路径;与app.use('/login', loginRouter)挂载子路由配合(§4.3)。- 路由处理器本质是
(req, res, next?)的中间件;多回调链需显式next()。 - 课堂端口 8080 与目录
01-Express中间件一致。
中间件执行顺序的重要性:
#mermaid-svg-NtcQyFKAKJANvFEq{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-NtcQyFKAKJANvFEq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-NtcQyFKAKJANvFEq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-NtcQyFKAKJANvFEq .error-icon{fill:#552222;}#mermaid-svg-NtcQyFKAKJANvFEq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-NtcQyFKAKJANvFEq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-NtcQyFKAKJANvFEq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-NtcQyFKAKJANvFEq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-NtcQyFKAKJANvFEq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-NtcQyFKAKJANvFEq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-NtcQyFKAKJANvFEq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-NtcQyFKAKJANvFEq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-NtcQyFKAKJANvFEq .marker.cross{stroke:#333333;}#mermaid-svg-NtcQyFKAKJANvFEq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-NtcQyFKAKJANvFEq p{margin:0;}#mermaid-svg-NtcQyFKAKJANvFEq .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-NtcQyFKAKJANvFEq .cluster-label text{fill:#333;}#mermaid-svg-NtcQyFKAKJANvFEq .cluster-label span{color:#333;}#mermaid-svg-NtcQyFKAKJANvFEq .cluster-label span p{background-color:transparent;}#mermaid-svg-NtcQyFKAKJANvFEq .label text,#mermaid-svg-NtcQyFKAKJANvFEq span{fill:#333;color:#333;}#mermaid-svg-NtcQyFKAKJANvFEq .node rect,#mermaid-svg-NtcQyFKAKJANvFEq .node circle,#mermaid-svg-NtcQyFKAKJANvFEq .node ellipse,#mermaid-svg-NtcQyFKAKJANvFEq .node polygon,#mermaid-svg-NtcQyFKAKJANvFEq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-NtcQyFKAKJANvFEq .rough-node .label text,#mermaid-svg-NtcQyFKAKJANvFEq .node .label text,#mermaid-svg-NtcQyFKAKJANvFEq .image-shape .label,#mermaid-svg-NtcQyFKAKJANvFEq .icon-shape .label{text-anchor:middle;}#mermaid-svg-NtcQyFKAKJANvFEq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-NtcQyFKAKJANvFEq .rough-node .label,#mermaid-svg-NtcQyFKAKJANvFEq .node .label,#mermaid-svg-NtcQyFKAKJANvFEq .image-shape .label,#mermaid-svg-NtcQyFKAKJANvFEq .icon-shape .label{text-align:center;}#mermaid-svg-NtcQyFKAKJANvFEq .node.clickable{cursor:pointer;}#mermaid-svg-NtcQyFKAKJANvFEq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-NtcQyFKAKJANvFEq .arrowheadPath{fill:#333333;}#mermaid-svg-NtcQyFKAKJANvFEq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-NtcQyFKAKJANvFEq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-NtcQyFKAKJANvFEq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NtcQyFKAKJANvFEq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-NtcQyFKAKJANvFEq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NtcQyFKAKJANvFEq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-NtcQyFKAKJANvFEq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-NtcQyFKAKJANvFEq .cluster text{fill:#333;}#mermaid-svg-NtcQyFKAKJANvFEq .cluster span{color:#333;}#mermaid-svg-NtcQyFKAKJANvFEq div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-NtcQyFKAKJANvFEq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-NtcQyFKAKJANvFEq rect.text{fill:none;stroke-width:0;}#mermaid-svg-NtcQyFKAKJANvFEq .icon-shape,#mermaid-svg-NtcQyFKAKJANvFEq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NtcQyFKAKJANvFEq .icon-shape p,#mermaid-svg-NtcQyFKAKJANvFEq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-NtcQyFKAKJANvFEq .icon-shape .label rect,#mermaid-svg-NtcQyFKAKJANvFEq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NtcQyFKAKJANvFEq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-NtcQyFKAKJANvFEq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-NtcQyFKAKJANvFEq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
请求
访问日志中间件
自定义中间件1
路径特定中间件
路由处理器
是否有错误?
错误处理中间件
正常响应
3.6 中间件最佳实践
使用建议:
- 顺序很重要:中间件按定义顺序执行
- 记得调用next():除非要结束响应
- 错误处理:使用try-catch包裹异步代码
- 职责单一:每个中间件只做一件事
- 性能考虑:避免在中间件中进行重计算
3.7 经典中间件场景实战
实际项目中,中间件是解决横切关注点(cross-cutting concerns)的利器。以下三个场景覆盖了绝大多数业务需求。
场景一:请求计时中间件
javascript
// middleware/timing.js
// 测量每个请求的处理耗时,帮助发现性能瓶颈
const requestTiming = (req, res, next) => {
const start = Date.now();
// 挂钩响应结束事件(res.send/end/json 触发后均会触发 finish)
res.on('finish', () => {
const duration = Date.now() - start;
const log = `[${new Date().toISOString()}] ${req.method} ${req.url} ` +
`- ${res.statusCode} - ${duration}ms`;
console.log(log);
// 慢请求告警:超过 1000ms 输出警告,便于排查瓶颈
if (duration > 1000) {
console.warn(`⚠️ 慢请求: ${req.method} ${req.url} 耗时 ${duration}ms`);
}
});
next(); // 立即放行,finish 事件在响应发送后自动触发,不阻塞主流程
};
module.exports = requestTiming;
【代码注释】
res.on('finish', ...)是 Express 中在路由执行后 获取响应结果的标准方式;close事件则表示客户端断开连接。- 这里使用
Date.now()而非process.hrtime();前者精度 ms,后者精度 ns,监控通常 ms 够用。 - 慢请求阈值(1000ms)可改为环境变量,方便不同环境调整。
- 生产可将
console.warn替换为 Winston 或 Datadog APM 上报。
适用场景: API 性能监控、SLA 报告、CI 阶段性能回归检测。
场景二:IP 黑名单中间件
javascript
// middleware/ipBlacklist.js
// 拦截已知恶意 IP,保护服务接口
const BLACKLIST = new Set(['192.168.1.100', '10.0.0.1']);
const ipBlacklist = (req, res, next) => {
// 兼容直连 IP 和代理转发(X-Forwarded-For 首个 IP 是真实客户端)
const clientIp = (req.headers['x-forwarded-for'] || req.ip || '')
.split(',')[0]
.trim()
.replace('::ffff:', ''); // 去除 IPv6 映射前缀
if (BLACKLIST.has(clientIp)) {
console.warn(`被封锁的 IP 尝试访问: ${clientIp} -> ${req.url}`);
return res.status(403).json({ error: '访问被拒绝' });
}
next();
};
module.exports = ipBlacklist;
【代码注释】
new Set(...)使.has()复杂度为 O(1),相比数组.includes()在黑名单较大时性能更好。- 生产场景黑名单通常存 Redis,以便动态添加/删除无需重启服务。
X-Forwarded-For可被伪造,若部署在可信代理(Nginx)后面,应先app.set('trust proxy', 1)。- 返回
403而非404:明确告知"禁止"而非"不存在",便于运维排查。
适用场景: 爬虫防护、竞争对手 IP 屏蔽、合规访问控制。
场景三:登录校验中间件(Session 版)
javascript
// middleware/requireLogin.js
// 保护需要登录才能访问的路由,未登录重定向到登录页
const requireLogin = (req, res, next) => {
if (!req.session || !req.session.userId) {
// 保存原目标 URL,登录成功后可跳回,提升用户体验
req.session.returnTo = req.originalUrl;
return res.redirect('/login');
}
// 将用户 ID 挂到 req,方便路由读取,无需每次查 Session
req.currentUserId = req.session.userId;
next();
};
module.exports = requireLogin;
三种挂载粒度对比:
javascript
// 粒度一:保护所有路由(全局,谨慎使用------登录页本身也会被拦截)
app.use(requireLogin);
// 粒度二:保护特定路径前缀(推荐)
app.use('/dashboard', requireLogin);
app.use('/account', requireLogin);
// 粒度三:仅保护单个路由(最细粒度)
router.get('/profile', requireLogin, profileHandler);
【代码注释】
- 粒度一挂载时要把
requireLogin放在/login、/register等公开路由之后,避免循环重定向。 req.session.returnTo存原 URL;登录成功路由里const url = req.session.returnTo || '/'; delete req.session.returnTo; res.redirect(url)。- 与 JWT 中间件的区别:Session 查服务端存储,JWT 只做本地签名验证(无网络 I/O,更快)。
- 记账本可直接把
requireLogin加到app.use('/account', requireLogin, accountRouter)一行完成保护。
适用场景: 后台管理系统、会员专区、记账本权限控制。
场景四:统一响应格式中间件
javascript
// middleware/responseFormatter.js
// 为 res 添加 success/fail 便捷方法,统一 JSON API 响应结构
const responseFormatter = (req, res, next) => {
// 成功响应:{ code: 0, data: ..., message: '...' }
res.success = (data, message = 'OK', statusCode = 200) => {
res.status(statusCode).json({ code: 0, data, message });
};
// 失败响应:{ code: <错误码>, data: null, message: '...' }
res.fail = (message = '操作失败', code = 1, statusCode = 400) => {
res.status(statusCode).json({ code, data: null, message });
};
next();
};
module.exports = responseFormatter;
// 使用示例(路由内):
// res.success({ id: 1, name: 'Alice' }); → { code: 0, data: {...}, message: 'OK' }
// res.fail('用户不存在', 404, 404); → { code: 404, data: null, message: '...' }
【代码注释】
- 在中间件里给
res挂方法,是 Express 扩展响应对象的惯用模式(类似morgan给req挂属性)。 - 统一格式后,前端可全局拦截
code !== 0的响应,避免在每个请求里单独判断。 - 课堂记账本以 EJS 渲染为主,此中间件更适合纯 API 项目;两者可共存,静态页用
res.render,接口用res.success。
3.8 中间件链设计原则总结
单一职责原则(SRP)在中间件中的体现:
请求 → [鉴权] → [限流] → [日志] → [请求体解析] → [业务路由] → [错误处理] → 响应
每个中间件只负责一件事,便于:
- 复用 :
requireLogin可挂到任意路由 - 测试:单独为每个中间件写单元测试
- 替换 :从 Session 切换到 JWT 只需改
requireLogin文件 - 排查:通过日志可精确定位是哪个中间件引入了延迟或错误
4. 路由模块化架构
4.1 路由模块化的必要性
随着应用规模增长,将所有路由定义在主文件中会导致代码难以维护。路由模块化解决了这个问题。
优势:
- 代码组织:按功能模块划分路由
- 团队协作:不同开发者负责不同模块
- 代码复用:路由可以在不同项目间复用
- 维护便利:修改某个功能不影响其他部分
4.2 express.Router基础
express.Router()创建了一个路由器实例,它是一个完整的中间件和路由系统,被称为"迷你应用"。
基本示例:
javascript
// routes/index.js
const express = require('express');
const route = express.Router();
// 首页路由
route.get('/', (req, res) => {
res.redirect('/index');
});
// 重定向后的首页
route.get('/index', (req, res) => {
res.send(`
<h1>首页</h1>
<hr>
<a href="/login">登录</a>
`);
});
module.exports = route;
【代码注释】
express.Router()创建「迷你应用」,导出后由主文件app.use(router)挂载。route.get('/')+res.redirect('/index'):访问根路径跳转;module.exports供require('./routes/index')。- 不指定前缀时,路由路径即模块内写的路径(
/、/index)。
javascript
// routes/login.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;
【代码注释】
- 表单
action="/login" method="post"在挂载为app.use('/login', loginRouter)时,应对应POST /login(或 action 写/login/)。 route.get('/')显示表单,route.post('/')处理提交;同路径靠 HTTP 方法 区分。- 未配置
urlencoded时req.body为空;登录模块演示页面,记账本才依赖 body 写库。
4.3 路由模块的挂载
javascript
// app.js
const express = require('express');
const accessLog = require('./middleware/accesslog');
const catchError = require('./middleware/catcherror');
// 导入路由模块
const indexRouter = require('./routes/index');
const loginRouter = require('./routes/login');
const app = express();
// 挂载中间件
app.use(accessLog);
// 挂载路由模块
app.use(indexRouter); // 不指定路径,使用路由模块内部定义的路径
app.use('/login', loginRouter); // 指定路径前缀
// 挂载错误处理中间件
app.use(catchError);
app.listen(8080, () => {
console.log('http server is running on :8080');
});
【代码注释】
- 02-Express路由模块化 :
app.use(accessLog)在前,app.use(catchError)在后。 app.use(indexRouter)无前缀 → 使用模块内/、/index;app.use('/login', loginRouter)→ 对外/login+ 模块内/。- 中间件与路由的注册顺序决定执行链;错误处理永远最后。
- 主文件保持精简,业务进
routes/*.js,便于分工与单元测试。
4.4 路由模块化架构图
#mermaid-svg-JGqzNordc2RjuFgm{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JGqzNordc2RjuFgm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JGqzNordc2RjuFgm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JGqzNordc2RjuFgm .error-icon{fill:#552222;}#mermaid-svg-JGqzNordc2RjuFgm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JGqzNordc2RjuFgm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JGqzNordc2RjuFgm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JGqzNordc2RjuFgm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JGqzNordc2RjuFgm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JGqzNordc2RjuFgm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JGqzNordc2RjuFgm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JGqzNordc2RjuFgm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JGqzNordc2RjuFgm .marker.cross{stroke:#333333;}#mermaid-svg-JGqzNordc2RjuFgm svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JGqzNordc2RjuFgm p{margin:0;}#mermaid-svg-JGqzNordc2RjuFgm .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-JGqzNordc2RjuFgm .cluster-label text{fill:#333;}#mermaid-svg-JGqzNordc2RjuFgm .cluster-label span{color:#333;}#mermaid-svg-JGqzNordc2RjuFgm .cluster-label span p{background-color:transparent;}#mermaid-svg-JGqzNordc2RjuFgm .label text,#mermaid-svg-JGqzNordc2RjuFgm span{fill:#333;color:#333;}#mermaid-svg-JGqzNordc2RjuFgm .node rect,#mermaid-svg-JGqzNordc2RjuFgm .node circle,#mermaid-svg-JGqzNordc2RjuFgm .node ellipse,#mermaid-svg-JGqzNordc2RjuFgm .node polygon,#mermaid-svg-JGqzNordc2RjuFgm .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-JGqzNordc2RjuFgm .rough-node .label text,#mermaid-svg-JGqzNordc2RjuFgm .node .label text,#mermaid-svg-JGqzNordc2RjuFgm .image-shape .label,#mermaid-svg-JGqzNordc2RjuFgm .icon-shape .label{text-anchor:middle;}#mermaid-svg-JGqzNordc2RjuFgm .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-JGqzNordc2RjuFgm .rough-node .label,#mermaid-svg-JGqzNordc2RjuFgm .node .label,#mermaid-svg-JGqzNordc2RjuFgm .image-shape .label,#mermaid-svg-JGqzNordc2RjuFgm .icon-shape .label{text-align:center;}#mermaid-svg-JGqzNordc2RjuFgm .node.clickable{cursor:pointer;}#mermaid-svg-JGqzNordc2RjuFgm .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-JGqzNordc2RjuFgm .arrowheadPath{fill:#333333;}#mermaid-svg-JGqzNordc2RjuFgm .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-JGqzNordc2RjuFgm .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-JGqzNordc2RjuFgm .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JGqzNordc2RjuFgm .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-JGqzNordc2RjuFgm .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JGqzNordc2RjuFgm .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-JGqzNordc2RjuFgm .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-JGqzNordc2RjuFgm .cluster text{fill:#333;}#mermaid-svg-JGqzNordc2RjuFgm .cluster span{color:#333;}#mermaid-svg-JGqzNordc2RjuFgm div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-JGqzNordc2RjuFgm .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-JGqzNordc2RjuFgm rect.text{fill:none;stroke-width:0;}#mermaid-svg-JGqzNordc2RjuFgm .icon-shape,#mermaid-svg-JGqzNordc2RjuFgm .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JGqzNordc2RjuFgm .icon-shape p,#mermaid-svg-JGqzNordc2RjuFgm .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-JGqzNordc2RjuFgm .icon-shape .label rect,#mermaid-svg-JGqzNordc2RjuFgm .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JGqzNordc2RjuFgm .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-JGqzNordc2RjuFgm .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-JGqzNordc2RjuFgm :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Express应用
首页路由模块
登录路由模块
用户路由模块
API路由模块
首页
关于我们
登录页面
注册页面
密码找回
用户列表
用户详情
用户API
产品API
4.5 RESTful路由设计
RESTful API路由规范:
| HTTP方法 | 路径 | 操作 | 示例 |
|---|---|---|---|
| GET | /users | 获取用户列表 | 获取所有用户 |
| GET | /users/:id | 获取单个用户 | 获取ID为1的用户 |
| POST | /users | 创建新用户 | 添加新用户 |
| PUT | /users/:id | 更新用户 | 更新ID为1的用户 |
| DELETE | /users/:id | 删除用户 | 删除ID为1的用户 |
实际应用:
javascript
// routes/users.js
const express = require('express');
const route = express.Router();
// 获取所有用户
route.get('/', (req, res) => {
res.json({ message: '获取所有用户' });
});
// 获取单个用户
route.get('/:id', (req, res) => {
const userId = req.params.id;
res.json({ message: `获取用户ID: ${userId}` });
});
// 创建用户
route.post('/', (req, res) => {
res.json({ message: '创建新用户' });
});
// 更新用户
route.put('/:id', (req, res) => {
const userId = req.params.id;
res.json({ message: `更新用户ID: ${userId}` });
});
// 删除用户
route.delete('/:id', (req, res) => {
const userId = req.params.id;
res.json({ message: `删除用户ID: ${userId}` });
});
module.exports = route;
【代码注释】
- REST 约定:
GET /列表、GET /:id详情、POST /创建、PUT /:id更新、DELETE /:id删除。 req.params.id为字符串;与数据库数字 id 比较需parseInt或统一用字符串 id(记账本用shortid)。- 挂载
app.use('/api/users', route)后完整路径为/api/users/:id。 - 列表路由
GET /须写在GET /:id之前 ,否则id可能匹配到"users"等字面量(路由顺序敏感)。
5. 模板引擎与EJS
5.1 模板引擎概述
模板引擎是一种将模板和数据结合生成HTML的工具,它让开发者能够使用模板语法来创建动态页面。
为什么需要模板引擎:
- 代码复用:避免重复的HTML代码
- 动态内容:根据数据动态生成页面
- 维护便利:HTML和逻辑分离
- 团队协作:前端和后端分工明确
主流模板引擎对比:
| 模板引擎 | 特点 | 适用场景 |
|---|---|---|
| EJS | 简单直接,类似HTML | 中小型项目,快速开发 |
| Pug(Jade) | 缩进式语法,简洁 | 追求代码简洁 |
| Handlebars | 逻辑分离,安全 | 大型项目,团队协作 |
| Nunjucks | 功能强大,类似Jinja2 | 复杂模板需求 |
5.2 EJS模板引擎
**EJS(Embedded JavaScript)**的特点:
- 简单学习:只需了解JavaScript
- 直接使用:无需特殊语法
- 性能良好:编译后执行效率高
- 调试方便:直接输出JavaScript代码
EJS语法总览:
| 语法 | 用途 | 示例 |
|---|---|---|
<% code %> |
执行JavaScript代码 | <% if(user) { %> |
<%= value %> |
输出转义的HTML | <%= user.name %> |
<%- value %> |
输出原始HTML | <%- htmlContent %> |
【代码注释】
<% %>:执行 JS 逻辑(if、forEach),不直接输出到页面。<%= value %>:输出并 HTML 转义 (<→<),防 XSS,用于用户输入的标题、备注等。<%- value %>:不转义 ,用于可信 HTML 片段;include子模板常用<%- include('partial') %>。<%=内可写表达式<%= 10 * 7 + 8 %>、<%= Date.now() %>,在服务端求值后输出。
5.3 Express中配置EJS
javascript
const path = require('path');
const express = require('express');
const app = express();
// 模板引擎设置
// 设置express使用的模板引擎为ejs
app.set('view engine', 'ejs');
// 设置模板文件存放目录
app.set('views', path.resolve(__dirname, 'views'));
// 路由渲染模板
app.get('/', (req, res) => {
// res.render('模板文件名', 数据对象);
res.render('index', {
title: '首页',
message: '欢迎使用EJS模板引擎'
});
});
app.listen(8080, () => {
console.log('http server is running on :8080');
});
【代码注释】
- 03-Express模板引擎 :
app.set('view engine', 'ejs')指定引擎;app.set('views', path)指定模板根目录。 res.render('index', data)渲染views/index.ejs,第二个参数对象在模板中作为变量(如title、message)。- 无需手动
readFile+res.send;Express 调用 EJS 编译渲染后返回 HTML。 - 生成器
express --view=ejs已写好上述配置,记账本直接res.render('account/index', { data })。
配置详解:
app.set('view engine', 'ejs'):设置默认模板引擎app.set('views', path):设置模板文件目录res.render(name, data):渲染模板并发送响应
5.4 EJS模板实例
示例1:基本的EJS模板
ejs
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %></title>
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/3.4.1/css/bootstrap.min.css">
</head>
<body>
<div class="container">
<div class="page-header">
<h1>
<%= title %>
<small><%= description %></small>
</h1>
</div>
<p class="alert alert-info">
<%- message %>
</p>
<!-- JavaScript表达式 -->
<p class="alert alert-warning">
<%= Date.now() %> <br>
<%= Math.random() %> <br>
<%= 10 * 7 + 8 %> <br>
</p>
</div>
</body>
</html>
<!-- 【代码注释】EJS模板示例,展示变量输出和表达式计算 -->
示例2:循环渲染数据
ejs
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title><%= title %></title>
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/3.4.1/css/bootstrap.min.css">
</head>
<body>
<div class="container">
<h1><%= title %></h1>
<p><%= description %></p>
<table class="table">
<thead>
<tr>
<th>排名</th>
<th>姓名</th>
<th>资产</th>
</tr>
</thead>
<tbody>
<!-- 循环遍历数据 -->
<% top.forEach(item => { %>
<tr>
<td><%= item.id %></td>
<td><%= item.name %></td>
<td><%= item.money %> 亿美元</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</body>
</html>
<!-- 【代码注释】使用forEach循环渲染表格数据 -->
路由代码:
javascript
const express = require('express');
const path = require('path');
const data = require('./datas/top.json');
const app = express();
// 模板引擎设置
app.set('view engine', 'ejs');
app.set('views', path.resolve(__dirname, 'views'));
// 渲染模板并传递数据
app.get('/', (req, res) => {
res.render('index', data);
});
app.listen(8080, () => {
console.log('Server running on port 8080');
});
【代码注释】
require('./datas/top.json')将 JSON 作为对象传入res.render('index', data),模板中直接用title、top等键名。- EJS 中
<% top.forEach(item => { %>与 JS 数组方法一致;注意forEach后要有闭合<% }) %>。 - 静态资源 CDN 链接在模板 head 中写死;本地资源用
/css/style.css+express.static。
5.5 条件渲染
ejs
<div class="panel <%= item.type > 0 ? 'panel-success' : 'panel-danger' %>">
<div class="panel-body">
<!-- 条件判断 -->
<% if (item.type > 0) { %>
<span class="label label-success">收入</span>
<% } else { %>
<span class="label label-danger">支出</span>
<% } %>
</div>
</div>
<!-- 【代码注释】使用条件语句根据数据类型显示不同样式 -->
5.6 修改模板扩展名
如果希望使用.html作为模板文件扩展名,可以这样配置:
javascript
const ejs = require('ejs');
const express = require('express');
const path = require('path');
const app = express();
// 注册.html扩展名使用EJS渲染
app.engine('html', ejs.renderFile);
// 设置默认模板引擎
app.set('view engine', 'html');
// 设置模板目录
app.set('views', path.join(__dirname, 'views'));
// 现在可以使用res.render('index.html')
app.get('/', (req, res) => {
res.render('index.html', { title: '首页' });
});
app.listen(3000);
【代码注释】
app.engine('html', ejs.renderFile)告诉 Express:扩展名为.html的文件也用 EJS 渲染。res.render('index.html')查找views/index.html;语法仍是 EJS,只是扩展名不同。- 记账本生成器默认
.ejs;若课堂要求.html,按此配置即可,无需改模板语法。
5.7 EJS 高级实战:分页 + Flash 消息
5.7.1 分页渲染
分页是列表页的必备功能,EJS 与 Express 配合只需几行即可实现。
路由层:计算分页参数
javascript
router.get('/', (req, res) => {
const page = parseInt(req.query.page) || 1; // 当前页,默认第 1 页
const pageSize = 10; // 每页条数
const all = db.get('accounts').value(); // 全量数据
// 计算总页数与当前页数据切片
const total = all.length;
const totalPage = Math.ceil(total / pageSize);
const data = all.slice((page - 1) * pageSize, page * pageSize);
res.render('account/index', {
data,
page,
pageSize,
total,
totalPage
});
});
EJS 模板:渲染分页导航
ejs
<!-- 分页导航,只在超过 1 页时显示 -->
<% if (totalPage > 1) { %>
<nav>
<ul class="pagination">
<!-- 上一页:第 1 页时禁用 -->
<li class="<%= page <= 1 ? 'disabled' : '' %>">
<a href="?page=<%= page - 1 %>"><<</a>
</li>
<!-- 页码列表 -->
<% for (let i = 1; i <= totalPage; i++) { %>
<li class="<%= i === page ? 'active' : '' %>">
<a href="?page=<%= i %>"><%= i %></a>
</li>
<% } %>
<!-- 下一页:最后一页时禁用 -->
<li class="<%= page >= totalPage ? 'disabled' : '' %>">
<a href="?page=<%= page + 1 %>">>></a>
</li>
</ul>
</nav>
<% } %>
【代码注释】
req.query.page为字符串,parseInt转数字并设默认值|| 1防止NaN。Math.ceil(total / pageSize)向上取整:11 条数据每页 10 条 → 2 页。.slice(start, end)左闭右开,(page-1)*pageSize到page*pageSize。- EJS 中
for循环写法与 JS 完全一致;<%= i === page ? 'active' : '' %>高亮当前页。 - 生产大数据量应在数据库层分页(MySQL
LIMIT/OFFSET,MongoDB.skip().limit()),而非全量加载后切片。
5.7.2 Flash 消息(操作反馈提示)
Flash 消息是一种"一次性显示后即清除"的提示,常用于操作成功/失败后的页面反馈,比单独的成功页更流畅。
安装依赖:
bash
npm install connect-flash express-session
app.js 配置:
javascript
const session = require('express-session');
const flash = require('connect-flash');
// flash 依赖 session,必须先配置 session
app.use(session({ secret: 'flash_secret', resave: false, saveUninitialized: false }));
app.use(flash());
// 将 flash 消息注入所有模板的局部变量,模板无需手动传入
app.use((req, res, next) => {
res.locals.successMsg = req.flash('success'); // 数组,可能为空
res.locals.errorMsg = req.flash('error');
next();
});
路由中写入 Flash 消息:
javascript
// 添加账单后,写入 flash 并重定向(Post-Redirect-Get 模式)
router.post('/create', (req, res) => {
try {
const id = shortid.generate();
db.get('accounts').unshift({ id, ...req.body }).write();
// 写入成功消息,重定向后在列表页读取并显示
req.flash('success', '账单添加成功!');
res.redirect('/account'); // PRG 模式:POST → 302 → GET,防止表单重复提交
} catch (err) {
req.flash('error', '添加失败,请检查输入');
res.redirect('/account/create');
}
});
router.get('/delete/:id', (req, res) => {
db.get('accounts').remove({ id: req.params.id }).write();
req.flash('success', '账单删除成功');
res.redirect('/account');
});
EJS 模板中展示 Flash 消息:
ejs
<!-- views/account/index.ejs - 页面顶部加入 Flash 区域 -->
<% if (successMsg && successMsg.length > 0) { %>
<div class="alert alert-success alert-dismissible">
<button type="button" class="close" data-dismiss="alert">×</button>
<%= successMsg[0] %>
</div>
<% } %>
<% if (errorMsg && errorMsg.length > 0) { %>
<div class="alert alert-danger alert-dismissible">
<button type="button" class="close" data-dismiss="alert">×</button>
<%= errorMsg[0] %>
</div>
<% } %>
【代码注释】
- Flash 消息存在 Session 中,读取一次后自动清除(这是 connect-flash 的核心特性)。
res.locals.successMsg让所有 EJS 模板都能访问,无需在每个res.render手动传入。- Post-Redirect-Get(PRG)模式 :表单 POST 成功后
redirect而非直接render,防止用户刷新页面重复提交表单。 successMsg[0]取数组第一条消息;若允许多条同时显示可改为forEach。- Flash 比独立"成功页"更流畅,用户操作后直接看到列表 + 顶部提示,减少页面跳转。
5.8 模板继承与布局
虽然EJS不直接支持模板继承,但可以通过包含的方式实现:
ejs
<!-- views/layouts/main.ejs -->
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header>
<%- include('../partials/header') %>
</header>
<main>
<%- body %>
</main>
<footer>
<%- include('../partials/footer') %>
</footer>
</body>
</html>
<!-- 【代码注释】布局模板,使用include引入公共部分 -->
<!-- views/partials/header.ejs -->
<nav class="navbar">
<a href="/">首页</a>
<a href="/about">关于</a>
<a href="/contact">联系</a>
</nav>
<!-- 【代码注释】头部公共模板 -->
<!-- views/index.ejs -->
<%- include('../layouts/main', {
title: '首页',
body: `
<div class="content">
<h1>欢迎来到首页</h1>
<p>这是首页内容</p>
</div>
`
}) %>
<!-- 【代码注释】具体页面模板,继承布局模板 -->
6. 项目实战:记账本应用
6.1 项目概述
记账本应用是一个完整的Web应用,演示了Express的实际使用场景,包括:
- 用户管理:添加、查看、删除账单记录
- 数据持久化:使用LowDB存储数据
- 模板渲染:使用EJS动态生成页面
- 表单处理:处理用户输入和提交
- 路由设计:RESTful风格的路由结构
6.2 技术栈选择
技术栈:
- 后端框架:Express.js
- 模板引擎:EJS
- 数据库:LowDB(轻量级JSON数据库)
- ID生成:ShortID(生成唯一ID)
- 前端框架:Bootstrap(UI样式)
6.3 项目结构设计
account-book/
├── app.js # 应用入口文件
├── package.json # 项目依赖配置
├── routes/ # 路由目录
│ ├── index.js # 主页路由
│ ├── account.js # 账单路由
│ └── users.js # 用户路由
├── views/ # 模板目录
│ ├── index.ejs # 主页模板
│ ├── account/ # 账单模板目录
│ │ ├── index.ejs # 账单列表
│ │ ├── create.ejs # 添加账单
│ │ └── success.ejs # 成功页面
│ └── error.ejs # 错误页面
├── public/ # 静态资源目录
│ ├── css/ # 样式文件
│ ├── js/ # JavaScript文件
│ └── images/ # 图片资源
└── dbs/ # 数据库目录
└── db.json # 数据存储文件
6.4 路由设计
RESTful风格的路由设计:
| HTTP方法 | 路径 | 功能 | 说明 |
|---|---|---|---|
| GET | / | 重定向 | 重定向到/account |
| GET | /account | 账单列表 | 显示所有账单记录 |
| GET | /account/create | 添加表单 | 显示添加账单表单 |
| POST | /account/create | 执行添加 | 处理表单提交,添加新账单 |
| GET | /account/delete/:id | 删除账单 | 删除指定ID的账单 |
6.5 数据库设计
使用LowDB进行数据存储:
json
{
"accounts": [
{
"id": "rXy7Zk2L",
"title": "工资收入",
"time": "2023-10-15",
"type": "1",
"account": "8000",
"remarks": "十月份工资"
}
]
}
数据字段说明:
id:唯一标识符,使用ShortID生成title:账单标题time:发生时间type:账单类型(1为收入,-1为支出)account:金额remarks:备注说明
6.6 项目实现
6.6.1 应用入口文件
javascript
// app.js
var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');
var indexRouter = require('./routes/index');
var accountRouter = require('./routes/account');
var usersRouter = require('./routes/users');
var app = express();
// 模板引擎设置
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
// 中间件配置
app.use(logger('dev')); // 日志中间件
app.use(express.json()); // JSON解析
app.use(express.urlencoded({ extended: false })); // URL编码解析
app.use(cookieParser()); // Cookie解析
app.use(express.static(path.join(__dirname, 'public'))); // 静态文件服务
// 路由挂载
app.use('/', indexRouter);
app.use('/account', accountRouter);
app.use('/users', usersRouter);
// 404错误处理
app.use(function(req, res, next) {
next(createError(404));
});
// 错误处理中间件
app.use(function(err, req, res, next) {
// 设置错误信息,仅在开发环境显示详细错误
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};
// 渲染错误页面
res.status(err.status || 500);
res.render('error');
});
module.exports = app;
【代码注释】
- 04-项目目录生成器 产出结构:
app.js只配置中间件与app.use('/account', accountRouter),不listen。 - 启动用
npm start→ 执行bin/www创建 HTTP 服务并监听端口;直接node app.js不会起服务。 express.json/urlencoded/cookieParser/morgan顺序已在生成器模板中排好。res.locals供模板访问;错误中间件根据NODE_ENV决定是否暴露err.stack。
6.6.2 账单路由实现
javascript
// routes/account.js
var express = require('express');
const path = require('path');
var router = express.Router();
// LowDB相关
const low = require('lowdb');
const FileSync = require('lowdb/adapters/FileSync');
const adapter = new FileSync(path.resolve(__dirname, '../dbs', 'db.json'));
const db = low(adapter);
// 生成唯一ID的模块
const shortid = require('shortid');
// 账单列表页面
router.get('/', (req, res) => {
// 从LowDB中取出数据
const data = db.get('accounts').value();
// 渲染模板并发送数据
res.render('account/index', {data});
});
// 添加账单表单页面
router.get('/create', (req, res) => {
// 渲染表单模板
res.render('account/create');
});
// 执行添加账单
router.post('/create', (req, res) => {
// 创建唯一ID
const id = shortid.generate();
// 向LowDB添加数据
db.get('accounts').unshift({
id,
...req.body
}).write();
// 渲染成功页面
res.render('account/success', {
title: '账单添加成功~~~',
url: '/account'
});
});
// 删除账单
router.get('/delete/:id', (req, res) => {
// 获取ID参数
const id = req.params.id;
// 从LowDB中删除指定数据
db.get('accounts').remove({id}).write();
// 渲染成功页面
res.render('account/success', {
title: '账单删除成功~~~',
url: '/account'
});
});
module.exports = router;
【代码注释】
- 06-记账本项目 路由表:
GET /account列表、GET /create表单、POST /create提交、GET /delete/:id删除。 FileSync适配器将dbs/db.json作为文件数据库;low(adapter)返回链式 API。db.get('accounts').value()读取数组;unshift({ id, ...req.body }).write()插入并同步写盘。shortid.generate()生成短唯一 id,避免手写自增冲突;remove({ id }).write()按 id 删除对象。req.body字段须与表单name一致:title、time、type、account、remarks。- 删除用 GET 仅为课堂演示;生产应
DELETE+ 确认或 POST,防 CSRF。
代码详解:
low(adapter):创建LowDB实例db.get('accounts').value():获取所有账单数据db.get('accounts').unshift().write():添加新账单到开头db.get('accounts').remove().write():删除指定账单req.params.id:获取URL路径参数req.body:获取表单提交的数据...req.body:展开运算符,合并表单数据
6.6.3 账单列表模板
ejs
<!-- views/account/index.ejs -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>记账本</title>
<link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/3.3.7/css/bootstrap.css" rel="stylesheet" />
<style>
label {
font-weight: normal;
}
.panel-body .glyphicon-remove {
display: none;
}
.panel-body:hover .glyphicon-remove {
display: inline-block;
}
.page-title {
padding-top: 30px;
padding-bottom: 10px;
}
.page-title h2 {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<div class="container">
<div class="row">
<div class="col-xs-12 col-lg-8 col-lg-offset-2 page-title">
<h2 class="pull-left">记账本</h2>
<a class="pull-right btn btn-success" href="/account/create">添加记录</a>
</div>
</div>
<div class="row">
<div class="col-xs-12 col-lg-8 col-lg-offset-2">
<hr>
</div>
</div>
<div class="row">
<div class="col-xs-12 col-lg-8 col-lg-offset-2">
<div class="accounts">
<% data.forEach(item => { %>
<div class="panel <%= item.type > 0 ? 'panel-success' : 'panel-danger' %>">
<div class="panel-heading"><%= item.time %></div>
<div class="panel-body">
<div class="col-xs-2"><%= item.title %></div>
<div class="col-xs-4"><%= item.remarks %></div>
<div class="col-xs-2 text-center">
<% if (item.type > 0) { %>
<span class="label label-success">收入</span>
<% } else { %>
<span class="label label-danger">支出</span>
<% } %>
</div>
<div class="col-xs-2 text-right"><%= item.account %> 元</div>
<div class="col-xs-2 text-right">
<a href="/account/delete/<%= item.id %>">
<span class="glyphicon glyphicon-remove"></span>
</a>
</div>
</div>
</div>
<% })%>
</div>
</div>
</div>
</div>
</body>
</html>
<!-- 【代码注释】账单列表页面模板,展示所有账单记录,支持删除操作 -->
模板特性:
- 条件样式:根据账单类型显示不同的面板颜色
- 数据循环:使用forEach遍历所有账单
- 条件渲染:根据类型显示收入或支出标签
- 交互设计:鼠标悬停时显示删除按钮
6.6.4 添加账单表单
ejs
<!-- views/account/create.ejs -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>添加记录</title>
<link href="/css/bootstrap.css" rel="stylesheet" />
<link href="/css/bootstrap-datepicker.css" rel="stylesheet">
</head>
<body>
<div class="container">
<div class="row">
<div class="col-xs-12 col-lg-8 col-lg-offset-2">
<h2>添加记录</h2>
<hr />
<form method="post" action="/account/create">
<div class="form-group">
<label for="item">事项</label>
<input type="text" class="form-control" id="item" name="title" />
</div>
<div class="form-group">
<label for="time">发生时间</label>
<input type="text" class="form-control" id="time" name="time" />
</div>
<div class="form-group">
<label for="type">类型</label>
<select class="form-control" id="type" name="type">
<option value="-1">支出</option>
<option value="1">收入</option>
</select>
</div>
<div class="form-group">
<label for="account">金额</label>
<input type="text" class="form-control" id="account" name="account" />
</div>
<div class="form-group">
<label for="remarks">备注</label>
<textarea class="form-control" id="remarks" name="remarks"></textarea>
</div>
<hr>
<button type="submit" class="btn btn-primary btn-block">添加</button>
</form>
</div>
</div>
</div>
<script src="/js/jquery.min.js"></script>
<script src="/js/bootstrap.min.js"></script>
<script src="/js/bootstrap-datepicker.min.js"></script>
<script src="/js/bootstrap-datepicker.zh-CN.min.js"></script>
<script src="/js/main.js"></script>
</body>
</html>
<!-- 【代码注释】添加账单表单,包含所有必要字段和日期选择器 -->
表单设计要点:
- 表单属性:设置method和action
- 字段命名:使用name属性与数据库字段对应
- 输入类型:根据数据类型选择合适的input类型
- 用户体验:集成日期选择器提升用户体验
6.6.5 成功页面
ejs
<!-- views/account/success.ejs -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %></title>
<link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/3.3.7/css/bootstrap.css" rel="stylesheet" />
<style>
.h-50 {
height: 50px;
}
</style>
</head>
<body>
<div class="container">
<div class="h-50"></div>
<div class="alert alert-success" role="alert">
<h1>:) <%= title %></h1>
<p><a href="<%- url %>">点击跳转</a></p>
</div>
</div>
</body>
</html>
<!-- 【代码注释】操作成功页面,显示成功消息并提供跳转链接 -->
6.7 项目部署流程
开发环境设置:
bash
# 1. 创建项目目录
mkdir account-book
cd account-book
# 2. 初始化项目
npm init -y
# 3. 安装依赖
npm install express ejs lowdb shortid
# 4. 启动开发服务器
npm start
生产环境部署:
- 环境变量配置:设置生产环境变量
- 静态资源优化:压缩CSS和JavaScript文件
- 错误监控:配置错误日志和监控
- 性能优化:启用Gzip压缩
- 安全加固:配置HTTPS和安全头
5.8 Express项目生成器深度使用
Express Generator是官方提供的项目脚手架工具,可以快速创建标准化的项目结构。
安装与使用:
bash
# 全局安装express-generator
npm install -g express-generator
# 创建项目(默认使用Jade模板引擎)
express myapp
# 指定模板引擎创建项目
express --view=ejs myapp
# 指定项目样式引擎
express --css=sass myapp
# 进入项目目录
cd myapp
# 安装依赖
npm install
# 启动项目(使用npm start)
# Windows: SET DEBUG=myapp:* & npm start
# macOS/Linux: DEBUG=myapp:* npm start
# 【命令注释】使用express-generator快速创建标准项目结构的完整流程
生成的项目结构:
myapp/
├── bin/
│ └── www # 应用启动脚本
├── public/ # 静态资源目录
│ ├── images/
│ ├── javascripts/
│ └── stylesheets/
│ └── style.css
├── routes/ # 路由目录
│ ├── index.js # 主页路由
│ └── users.js # 用户路由
├── views/ # 模板目录
│ ├── error.ejs # 错误页面
│ └── index.ejs # 主页模板
├── app.js # 应用主文件
└── package.json # 项目配置文件
项目文件详解:
1. app.js - 应用配置文件
javascript
var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');
var indexRouter = require('./routes/index');
var usersRouter = require('./routes/users');
var app = express();
// 模板引擎设置
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
// 中间件配置
app.use(logger('dev')); // 日志记录
app.use(express.json()); // JSON解析
app.use(express.urlencoded({ extended: false })); // URL编码解析
app.use(cookieParser()); // Cookie解析
app.use(express.static(path.join(__dirname, 'public'))); // 静态文件服务
// 路由配置
app.use('/', indexRouter);
app.use('/users', usersRouter);
// 404错误处理
app.use(function(req, res, next) {
next(createError(404));
});
// 全局错误处理
app.use(function(err, req, res, next) {
// 设置本地变量,仅在开发环境提供错误信息
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};
// 渲染错误页面
res.status(err.status || 500);
res.render('error');
});
module.exports = app;
【代码注释】
express-generator生成:app.js只导出app,不负责监听端口。- 已含
morgan、json、urlencoded、cookieParser、express.static、各routes/*挂载。 - 404:
createError(404)+next(err)交给错误中间件;开发环境res.render('error')可看堆栈。 - 记账本在
routes/account.js挂载为app.use('/account', accountRouter),勿直接node app.js启动。
2. bin/www - 应用启动脚本
javascript
#!/usr/bin/env node
/**
* Module dependencies.
*/
var app = require('../app');
var debug = require('debug')('myapp:server');
var http = require('http');
/**
* Get port from environment and store in Express.
*/
var port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
/**
* Create HTTP server.
*/
var server = http.createServer(app);
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort(val) {
var port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
}
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
var bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
var addr = server.address();
var bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
debug('Listening on ' + bind);
}
【代码注释】
npm start实际执行此文件:http.createServer(app)将 Express 应用挂到原生 HTTP 服务器。process.env.PORT || '3000'可通过环境变量改端口;EADDRINUSE表示端口被占用。normalizePort支持数字端口或命名管道;课堂访问http://127.0.0.1:3000/account。debug模块需DEBUG=myapp:server npm start才在控制台看到 listening 日志。- 与手写
app.listen(8080)等价,生成器拆文件是为分离「配置」与「启动」。
代码详解:
#!/usr/bin/env node:Shebang行,指定使用Node.js执行normalizePort():规范化端口号,支持数字、字符串onError():处理服务器启动错误onListening():服务器启动成功后的回调
5.9 LowDB轻量级数据库详解
LowDB是一个简单的JSON文件数据库,非常适合小型项目和原型开发。
LowDB特点:
- 轻量级:无需安装数据库服务器
- 简单易用:API直观,学习成本低
- 适合小型项目:适合原型开发和小型应用
- JSON格式:数据以JSON格式存储,易于理解和修改
安装与配置:
bash
# 安装lowdb
npm install lowdb
# 安装适配器(lowdb 1.x版本)
npm install lowdb/adapters/FileSync
# 【命令注释】安装LowDB及其文件同步适配器
基础使用示例:
javascript
const low = require('lowdb');
const FileSync = require('lowdb/adapters/FileSync');
// 创建数据库实例
const adapter = new FileSync('db.json');
const db = low(adapter);
// 初始化数据库
db.defaults({
posts: [],
user: {},
count: 0
}).write();
【代码注释】
- 05-lowdb使用演示 :
FileSync('db.json')读写同一 JSON 文件;多进程同时写可能损坏,仅适合学习/小工具。 db.defaults({ ... }).write()若文件不存在或键缺失则写入默认结构,避免get报错。- 链式调用以
.write()结尾才真正落盘;忘记write()则内存改了文件未更新。 - 记账本键名
accounts与路由中db.get('accounts')必须一致。
CRUD操作详解:
1. 创建数据(Create)
javascript
// 向数组末尾添加数据
db.get('posts')
.push({ id: 1, title: 'lowdb is awesome' })
.write();
// 向数组开头添加数据
db.get('posts')
.unshift({ id: 2, title: 'First post' })
.write();
// 设置单个值
db.set('user.name', 'typicode').write();
db.set('user.age', 30).write();
【代码注释】
push追加到数组末尾,unshift插入开头(记账本新账单用unshift便于列表顶部展示)。db.set('user.name', 'x')点路径设置嵌套属性,等价于操作对象树。- 每次变更链末尾必须
.write();id等字段需自行保证唯一(配合shortid)。
实际应用场景:
- 用户注册功能
- 文章发布系统
- 评论系统
2. 读取数据(Read)
javascript
// 获取整个集合
const posts = db.get('posts').value();
console.log(posts);
// 获取单个元素
const firstPost = db.get('posts').first().value();
console.log(firstPost);
// 根据条件查找
const specificPost = db.get('posts')
.find({ id: 1 })
.value();
// 获取数组长度
const postCount = db.get('posts').size().value();
console.log(`总共有 ${postCount} 篇文章`);
// 获取嵌套属性
const userName = db.get('user.name').value();
console.log(`用户名: ${userName}`);
【代码注释】
.value()取当前链节点快照(数组或对象);无.write()的读操作不写盘。.find({ id: 1 })返回第一个匹配项;.first()取数组首元素。.size()等价长度;列表页db.get('accounts').value()即全部账单供 EJS 遍历。
实际应用场景:
- 用户信息查询
- 文章列表展示
- 数据统计和分析
3. 更新数据(Update)
javascript
// 更新特定元素
db.get('posts')
.find({ id: 1 })
.assign({ title: 'Updated title' })
.write();
// 更新嵌套属性
db.set('user.age', 31).write();
// 批量更新
db.get('posts')
.forEach(item => {
item.views = (item.views || 0) + 1;
})
.write();
【代码注释】
.assign({ title: '...' })浅合并更新匹配对象字段,须.write()持久化。.forEach遍历修改每项后.write()适合批量改浏览量等;注意勿在循环中多次write()影响性能。- 记账本课堂未做编辑页,扩展时可
find({ id }).assign(req.body).write()。
实际应用场景:
- 用户信息修改
- 文章编辑功能
- 浏览次数统计
4. 删除数据(Delete)
javascript
// 删除特定元素
db.get('posts')
.remove({ id: 1 })
.write();
// 删除第一个元素
db.get('posts')
.removeFirst()
.write();
// 清空整个集合
db.set('posts', []).write();
// 删除属性
db.unset('user.age').write();
【代码注释】
.remove({ id: 1 })删除数组中所有 匹配对象(记账本remove({ id: req.params.id }))。removeFirst()删首项;db.set('posts', [])清空集合。.unset('user.age')删除对象属性;删除后务必.write()。
实际应用场景:
- 用户注销功能
- 文章删除功能
- 数据清理
完整的LowDB使用示例:
javascript
const low = require('lowdb');
const FileSync = require('lowdb/adapters/FileSync');
// 创建数据库实例
const adapter = new FileSync('db.json');
const db = low(adapter);
// 初始化数据库
db.defaults({
posts: [],
user: {},
count: 0
}).write();
// 写入数据
db.get('posts').push({ id: 100, title: 'lowdb is awesome'}).write();
db.get('posts').unshift({ id: 101, title: 'First post'}).write();
// 修改数据
db.set('posts[1].title', '高小乐').write();
// 读取数据
console.log(db.get('posts').value());
// 删除数据
db.get('posts').remove({id:100}).write();
console.log('操作后的数据:', db.get('posts').value());
【代码注释】
- 串联 增
push/unshift、改set/assign、查value、删remove的完整流程,与 MongoDB 链式 API 思想类似。 db.set('posts[1].title', '高小乐')用下标路径改数组元素,适合已知索引的更新。- 实验后打开
db.json肉眼核对结构,理解write()前后文件变化。
数据存储格式:
json
{
"posts": [
{
"id": 101,
"title": "First post"
},
{
"id": 100,
"title": "lowdb is awesome"
}
],
"user": {
"name": "typicode",
"age": 30
},
"count": 0
}
5.10 项目实战案例:完整的记账本应用
记账本应用是本文的核心实战案例,综合运用了Express的各种特性。
应用功能清单:
- ✅ 账单列表展示
- ✅ 添加新账单
- ✅ 删除账单
- ✅ 收支分类显示
- ✅ 数据持久化
- ✅ 响应式设计
- ✅ 表单验证
- ✅ 用户友好提示
完整项目实现:
1. 账单路由实现(完整版)
javascript
// routes/account.js
var express = require('express');
const path = require('path');
var router = express.Router();
// LowDB相关
const low = require('lowdb');
const FileSync = require('lowdb/adapters/FileSync');
const adapter = new FileSync(path.resolve(__dirname, '../dbs', 'db.json'));
const db = low(adapter);
// 生成唯一ID的模块
const shortid = require('shortid');
// 数据初始化(确保集合存在)
db.defaults({ accounts: [] }).write();
// 账单列表页面
router.get('/', (req, res) => {
try {
// 从LowDB中取出所有账单数据
const accounts = db.get('accounts').value();
// 按时间倒序排列
const sortedAccounts = accounts.sort((a, b) => {
return new Date(b.time) - new Date(a.time);
});
// 渲染模板并发送数据
res.render('account/index', {
data: sortedAccounts,
totalCount: sortedAccounts.length,
totalIncome: sortedAccounts
.filter(item => item.type > 0)
.reduce((sum, item) => sum + parseFloat(item.account), 0),
totalExpense: sortedAccounts
.filter(item => item.type < 0)
.reduce((sum, item) => sum + parseFloat(item.account), 0)
});
} catch (error) {
console.error('获取账单列表失败:', error);
res.status(500).render('error', {
message: '获取账单列表失败,请稍后重试'
});
}
});
// 添加账单表单页面
router.get('/create', (req, res) => {
try {
// 渲染表单模板
res.render('account/create', {
currentDate: new Date().toISOString().split('T')[0]
});
} catch (error) {
console.error('加载表单失败:', error);
res.status(500).render('error', {
message: '加载表单失败,请稍后重试'
});
}
});
// 执行添加账单
router.post('/create', (req, res) => {
try {
// 验证必填字段
const { title, time, type, account, remarks } = req.body;
if (!title || !time || !type || !account) {
return res.render('account/fail', {
title: '添加失败',
message: '请填写所有必填字段',
url: '/account/create'
});
}
// 验证金额格式
const accountValue = parseFloat(account);
if (isNaN(accountValue) || accountValue <= 0) {
return res.render('account/fail', {
title: '添加失败',
message: '请输入有效的金额',
url: '/account/create'
});
}
// 创建唯一ID
const id = shortid.generate();
// 准备账单数据
const newAccount = {
id,
title: title.trim(),
time,
type: parseInt(type),
account: accountValue.toFixed(2),
remarks: remarks ? remarks.trim() : ''
};
// 向LowDB添加数据到开头
db.get('accounts').unshift(newAccount).write();
// 渲染成功页面
res.render('account/success', {
title: '账单添加成功~~~',
message: `成功添加${newAccount.type > 0 ? '收入' : '支出'}记录`,
url: '/account'
});
} catch (error) {
console.error('添加账单失败:', error);
res.render('account/fail', {
title: '添加失败',
message: '系统错误,请稍后重试',
url: '/account/create'
});
}
});
// 删除账单
router.get('/delete/:id', (req, res) => {
try {
// 获取ID参数
const id = req.params.id;
// 检查账单是否存在
const account = db.get('accounts').find({ id }).value();
if (!account) {
return res.render('account/fail', {
title: '删除失败',
message: '账单记录不存在',
url: '/account'
});
}
// 从LowDB中删除指定数据
db.get('accounts').remove({ id }).write();
// 渲染成功页面
res.render('account/success', {
title: '账单删除成功~~~',
message: `成功删除账单:${account.title}`,
url: '/account'
});
} catch (error) {
console.error('删除账单失败:', error);
res.render('account/fail', {
title: '删除失败',
message: '系统错误,请稍后重试',
url: '/account'
});
}
});
module.exports = router;
【代码注释】
- 完整版在精简路由上增加:
try/catch、account/fail失败页、服务端字段与金额校验。 - 列表页计算
totalIncome/totalExpense:type > 0收入、< 0支出,parseFloat(account)求和。 type表单为字符串,parseInt(type)入库;EJS 用item.type > 0切换 Bootstrap 面板样式。trim()、toFixed(2)规范标题与金额显示;删除前find取标题用于成功提示。- 初始化
db.defaults({ accounts: [] }).write()防止空文件;与 §6.6.2 路由表一致,适合作为项目终稿参考。
代码详解:
db.defaults().write():初始化数据库结构db.get('accounts').value():获取所有账单数据db.get('accounts').unshift().write():添加新账单到数组开头db.get('accounts').remove().write():删除指定账单shortid.generate():生成唯一ID- 完整的输入验证和错误处理
- 数据统计功能(总记录数、总收入、总支出)
2. 账单列表模板(完整版)
ejs
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>记账本 - 个人财务管理</title>
<link href="/css/bootstrap.min.css" rel="stylesheet">
<link href="/css/bootstrap-datepicker.min.css" rel="stylesheet">
<style>
body {
padding-top: 20px;
background-color: #f8f9fa;
}
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 10px;
margin-bottom: 30px;
}
.page-header h1 {
margin: 0;
font-size: 2.5em;
}
.stats-panel {
margin-bottom: 20px;
}
.account-item {
transition: all 0.3s ease;
cursor: pointer;
}
.account-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.account-item .delete-btn {
opacity: 0;
transition: opacity 0.3s ease;
}
.account-item:hover .delete-btn {
opacity: 1;
}
.income-badge {
background-color: #28a745;
}
.expense-badge {
background-color: #dc3545;
}
.empty-state {
text-align: center;
padding: 60px 20px;
}
.empty-state i {
font-size: 64px;
color: #6c757d;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container">
<!-- 页面头部 -->
<div class="page-header">
<div class="row">
<div class="col-md-8">
<h1>💰 记账本</h1>
<p class="mb-0">轻松管理您的个人财务</p>
</div>
<div class="col-md-4 text-right">
<a href="/account/create" class="btn btn-light btn-lg">
<i class="glyphicon glyphicon-plus"></i> 添加记录
</a>
</div>
</div>
</div>
<!-- 统计面板 -->
<% if (data && data.length > 0) { %>
<div class="row stats-panel">
<div class="col-md-4">
<div class="panel panel-primary">
<div class="panel-heading text-center">
<h4>总记录数</h4>
</div>
<div class="panel-body text-center">
<h2><%= totalCount %> 条</h2>
</div>
</div>
</div>
<div class="col-md-4">
<div class="panel panel-success">
<div class="panel-heading text-center">
<h4>总收入</h4>
</div>
<div class="panel-body text-center">
<h2 class="text-success">¥ <%= totalIncome.toFixed(2) %></h2>
</div>
</div>
</div>
<div class="col-md-4">
<div class="panel panel-danger">
<div class="panel-heading text-center">
<h4>总支出</h4>
</div>
<div class="panel-body text-center">
<h2 class="text-danger">¥ <%= totalExpense.toFixed(2) %></h2>
</div>
</div>
</div>
</div>
<% } %>
<!-- 账单列表 -->
<div class="row">
<div class="col-md-8 col-md-offset-2">
<% if (data && data.length > 0) { %>
<% data.forEach(item => { %>
<div class="panel <%= item.type > 0 ? 'panel-success' : 'panel-danger' %> account-item">
<div class="panel-heading">
<strong><%= item.time %></strong>
</div>
<div class="panel-body">
<div class="row">
<div class="col-xs-3">
<h4><%= item.title %></h4>
<small class="text-muted"><%= item.remarks || '无备注' %></small>
</div>
<div class="col-xs-3 text-center">
<% if (item.type > 0) { %>
<span class="label label-success income-badge">收入</span>
<% } else { %>
<span class="label label-danger expense-badge">支出</span>
<% } %>
</div>
<div class="col-xs-3 text-right">
<h3 class="<%= item.type > 0 ? 'text-success' : 'text-danger' %>">
<%= item.type > 0 ? '+' : '' %>¥<%= item.account %>
</h3>
</div>
<div class="col-xs-3 text-right">
<a href="/account/delete/<%= item.id %>"
class="btn btn-sm btn-danger delete-btn"
onclick="return confirm('确定要删除这条记录吗?')">
<i class="glyphicon glyphicon-trash"></i> 删除
</a>
</div>
</div>
</div>
</div>
<% }) %>
<% } else { %>
<!-- 空状态 -->
<div class="empty-state">
<i class="glyphicon glyphicon-inbox"></i>
<h3>还没有任何记录</h3>
<p>点击上方"添加记录"按钮开始记账</p>
<a href="/account/create" class="btn btn-primary btn-lg">
<i class="glyphicon glyphicon-plus"></i> 添加第一条记录
</a>
</div>
<% } %>
</div>
</div>
</div>
<script src="/js/jquery.min.js"></script>
<script src="/js/bootstrap.min.js"></script>
</body>
</html>
<!-- 【代码注释】完整的账单列表页面,包含统计面板、账单列表、空状态处理和响应式设计 -->
模板特性:
- 统计面板:显示总记录数、总收入、总支出
- 数据排序:按时间倒序排列
- 条件样式:收入/支出使用不同的颜色
- 交互动效:鼠标悬停效果
- 空状态处理:没有数据时显示友好提示
- 确认对话框:删除前需要用户确认
3. 添加账单表单(完整版)
ejs
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>添加记录 - 记账本</title>
<link href="/css/bootstrap.min.css" rel="stylesheet">
<link href="/css/bootstrap-datepicker.min.css" rel="stylesheet">
<style>
body {
padding-top: 20px;
background-color: #f8f9fa;
}
.form-container {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.page-header {
margin-bottom: 30px;
padding-bottom: 15px;
border-bottom: 2px solid #667eea;
}
.form-group label {
font-weight: 600;
color: #333;
}
.required-field:after {
content: " *";
color: red;
}
.type-selector {
margin: 20px 0;
}
.radio-inline {
margin: 0 20px;
}
.btn-submit {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
padding: 12px 30px;
font-size: 16px;
}
.btn-submit:hover {
opacity: 0.9;
}
</style>
</head>
<body>
<div class="container">
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="form-container">
<div class="page-header">
<h2>📝 添加账单记录</h2>
<p class="text-muted">请填写以下信息来添加新的账单记录</p>
</div>
<form method="post" action="/account/create" id="accountForm" novalidate>
<!-- 账单标题 -->
<div class="form-group">
<label for="title" class="required-field">账单标题</label>
<input type="text"
class="form-control"
id="title"
name="title"
placeholder="例如:工资、餐费、交通费等"
required
maxlength="50">
<small class="form-text text-muted">最多50个字符</small>
</div>
<!-- 发生时间 -->
<div class="form-group">
<label for="time" class="required-field">发生时间</label>
<input type="text"
class="form-control datepicker"
id="time"
name="time"
placeholder="选择日期"
value="<%= currentDate || '' %>"
required
readonly>
</div>
<!-- 收支类型 -->
<div class="form-group type-selector">
<label class="required-field">收支类型</label>
<div>
<label class="radio-inline">
<input type="radio"
name="type"
id="type-expense"
value="-1"
checked>
<span class="text-danger">支出</span>
</label>
<label class="radio-inline">
<input type="radio"
name="type"
id="type-income"
value="1">
<span class="text-success">收入</span>
</label>
</div>
</div>
<!-- 金额 -->
<div class="form-group">
<label for="account" class="required-field">金额</label>
<div class="input-group">
<span class="input-group-addon">¥</span>
<input type="number"
class="form-control"
id="account"
name="account"
placeholder="0.00"
required
min="0.01"
step="0.01"
maxlength="10">
</div>
<small class="form-text text-muted">请输入大于0的金额,最多两位小数</small>
</div>
<!-- 备注 -->
<div class="form-group">
<label for="remarks">备注</label>
<textarea class="form-control"
id="remarks"
name="remarks"
rows="3"
placeholder="添加备注信息(可选)"
maxlength="200"></textarea>
<small class="form-text text-muted">最多200个字符</small>
</div>
<!-- 提交按钮 -->
<hr>
<div class="form-group text-center">
<button type="submit" class="btn btn-primary btn-lg btn-submit">
<i class="glyphicon glyphicon-ok"></i> 添加记录
</button>
<a href="/account" class="btn btn-default btn-lg">
<i class="glyphicon glyphicon-remove"></i> 取消
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<script src="/js/jquery.min.js"></script>
<script src="/js/bootstrap.min.js"></script>
<script src="/js/bootstrap-datepicker.min.js"></script>
<script src="/js/bootstrap-datepicker.zh-CN.min.js"></script>
<script>
$(document).ready(function() {
// 初始化日期选择器
$('.datepicker').datepicker({
format: 'yyyy-mm-dd',
language: 'zh-CN',
autoclose: true,
todayHighlight: true,
todayBtn: 'linked'
});
// 表单验证
$('#accountForm').on('submit', function(e) {
let isValid = true;
// 验证标题
if ($('#title').val().trim() === '') {
alert('请输入账单标题');
$('#title').focus();
isValid = false;
}
// 验证日期
if ($('#time').val() === '') {
alert('请选择发生时间');
$('#time').focus();
isValid = false;
}
// 验证金额
const account = parseFloat($('#account').val());
if (isNaN(account) || account <= 0) {
alert('请输入有效的金额');
$('#account').focus();
isValid = false;
}
return isValid;
});
// 根据收支类型改变样式
$('input[name="type"]').on('change', function() {
if ($(this).val() === '1') {
$('.btn-submit').removeClass('btn-danger').addClass('btn-success');
} else {
$('.btn-submit').removeClass('btn-success').addClass('btn-danger');
}
}).trigger('change');
});
</script>
</body>
</html>
<!-- 【代码注释】完整的添加账单表单,包含表单验证、日期选择器、实时样式切换和用户友好的交互体验 -->
表单特性:
- 表单验证:前后端双重验证
- 日期选择器:集成Bootstrap Datepicker
- 实时反馈:根据收支类型改变按钮颜色
- 用户引导:placeholder和帮助文本
- 输入限制:字符长度、数值范围限制
- 必填标识:使用红色星号标识必填字段
4. 成功页面模板
ejs
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %></title>
<link href="/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
padding-top: 100px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.success-container {
background: white;
padding: 50px;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
text-align: center;
}
.success-icon {
font-size: 64px;
color: #28a745;
margin-bottom: 20px;
}
.success-title {
font-size: 32px;
color: #333;
margin-bottom: 20px;
}
.success-message {
font-size: 18px;
color: #666;
margin-bottom: 30px;
}
.btn-continue {
padding: 15px 40px;
font-size: 18px;
border-radius: 25px;
}
.countdown {
margin-top: 20px;
font-size: 14px;
color: #999;
}
</style>
</head>
<body>
<div class="container">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<div class="success-container">
<div class="success-icon">
<i class="glyphicon glyphicon-ok-circle"></i>
</div>
<h1 class="success-title"><%= title %></h1>
<% if (typeof message !== 'undefined') { %>
<p class="success-message"><%= message %></p>
<% } %>
<p>
<a href="<%- url %>" class="btn btn-success btn-continue">
<i class="glyphicon glyphicon-arrow-left"></i> 返回列表
</a>
</p>
<p class="countdown">页面将在 <span id="countdown">3</span> 秒后自动跳转</p>
</div>
</div>
</div>
</div>
<script src="/js/jquery.min.js"></script>
<script src="/js/bootstrap.min.js"></script>
<script>
// 倒计时自动跳转
let count = 3;
const countdownElement = document.getElementById('countdown');
const timer = setInterval(function() {
count--;
countdownElement.textContent = count;
if (count <= 0) {
clearInterval(timer);
window.location.href = '<%- url %>';
}
}, 1000);
</script>
</body>
</html>
<!-- 【代码注释】成功页面模板,包含友好的成功提示和自动跳转功能 -->
成功页面特性:
- 视觉反馈:成功图标和渐变背景
- 自动跳转:3秒后自动返回列表
- 消息显示:显示具体的成功消息
- 美观设计:现代化的UI设计
7. 最佳实践与性能优化
7.1 项目结构最佳实践
推荐的Express项目结构:
express-project/
├── app.js # 应用入口
├── config/ # 配置文件
│ ├── database.js # 数据库配置
│ └── passport.js # 认证配置
├── controllers/ # 控制器
│ ├── userController.js
│ └── accountController.js
├── models/ # 数据模型
│ ├── User.js
│ └── Account.js
├── routes/ # 路由定义
│ ├── index.js
│ ├── users.js
│ └── accounts.js
├── middleware/ # 自定义中间件
│ ├── auth.js
│ ├── errorHandler.js
│ └── validation.js
├── services/ # 业务逻辑层
│ └── userService.js
├── utils/ # 工具函数
│ └── helpers.js
├── public/ # 静态资源
├── views/ # 模板文件
├── tests/ # 测试文件
└── logs/ # 日志文件
7.2 错误处理最佳实践
分层错误处理:
javascript
// 1. 自定义错误类
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
// 2. 异步错误处理包装器
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// 3. 使用示例
router.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
throw new AppError('用户不存在', 404);
}
res.json(user);
}));
// 4. 全局错误处理中间件
app.use((err, req, res, next) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || 'error';
res.status(err.statusCode).json({
status: err.status,
message: err.message
});
});
【代码注释】
AppError继承Error并带statusCode,业务里throw new AppError('...', 400)统一格式。catchAsync(fn)包装 async 路由,将reject转为next(err),避免遗漏 try/catch。- 全局四参数中间件返回 JSON 错误体;生产勿暴露
stack(生成器用res.render('error')同理)。 - 404 应在路由之后用
next(new AppError(..., 404))或app.all('*', ...)触发。
7.3 安全最佳实践
关键安全措施:
javascript
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const xss = require('xss-clean');
const hpp = require('hpp');
// 1. 安全HTTP头
app.use(helmet());
// 2. 速率限制
const limiter = rateLimit({
max: 100, // 限制每个IP 100个请求
windowMs: 60 * 60 * 1000, // 1小时
message: '请求过多,请稍后再试'
});
app.use('/api', limiter);
// 3. 数据清理
app.use(xss()); // 防止XSS攻击
app.use(hpp()); // 防止HTTP参数污染
// 4. 输入验证
const { body, validationResult } = require('express-validator');
app.post('/users', [
body('email').isEmail(),
body('password').isLength({ min: 6 })
], (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// 处理逻辑
});
【代码注释】
helmet+rateLimit+xss-clean+hpp为生产常见组合,课堂记账本可选装。express-validator在路由数组中声明规则,validationResult(req)统一取错误,优于手写 if。- 限流挂在
/api前缀,避免静态资源请求被误伤;登录接口可单独更严限额。
7.4 性能优化技巧
1. 响应压缩:
javascript
const compression = require('compression');
app.use(compression()); // 启用Gzip压缩
【代码注释】
compression()对文本响应 gzip,降低 HTML/JSON 体积;图片等已压缩格式收益有限。express.static的maxAge设置浏览器缓存;etag/lastModified支持 304,减轻重复传输。- 生产环境
app.set('view cache', true)缓存编译后的 EJS,开发关闭便于改模板即生效。 - LowDB 适合演示;正式项目换 MySQL + 连接池、Redis 缓存(下文扩展示例)。
2. 静态资源优化:
javascript
// 静态资源缓存
app.use(express.static('public', {
maxAge: '1d', // 缓存1天
etag: true, // 启用ETag
lastModified: true // 启用Last-Modified
}));
// 【代码注释】静态资源缓存配置,减少服务器负载
3. 数据库连接池:
javascript
const pool = mysql.createPool({
connectionLimit: 10,
host: 'localhost',
user: 'root',
password: 'password',
database: 'test'
});
// 【代码注释】创建数据库连接池,提高数据库访问性能
4. 视图缓存:
javascript
// 生产环境启用视图缓存
if (process.env.NODE_ENV === 'production') {
app.set('view cache', true);
}
// 【代码注释】生产环境启用模板缓存,提高渲染性能
7.5 日志管理
使用Morgan进行请求日志:
javascript
const morgan = require('morgan');
const fs = require('fs');
const path = require('path');
// 创建日志流
const accessLogStream = fs.createWriteStream(
path.join(__dirname, 'logs/access.log'),
{ flags: 'a' }
);
// 开发环境日志格式
if (process.env.NODE_ENV === 'development') {
app.use(morgan('dev'));
}
// 生产环境日志到文件
if (process.env.NODE_ENV === 'production') {
app.use(morgan('combined', { stream: accessLogStream }));
}
// 【代码注释】根据环境配置不同的日志记录方式
7.6 环境配置管理
javascript
// 使用dotenv管理环境变量
require('dotenv').config();
const config = {
development: {
port: process.env.DEV_PORT || 3000,
database: process.env.DEV_DB
},
production: {
port: process.env.PROD_PORT || 80,
database: process.env.PROD_DB
}
};
module.exports = config[process.env.NODE_ENV || 'development'];
// 【代码注释】环境配置管理,根据不同环境使用不同配置
7.7 数据库连接优化
连接池配置示例:
javascript
// MySQL连接池配置
const mysql = require('mysql');
const pool = mysql.createPool({
connectionLimit: 10,
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
queueLimit: 0
});
// 使用连接池执行查询
pool.query('SELECT * FROM users WHERE id = ?', [userId], (error, results) => {
if (error) throw error;
console.log(results);
});
// 【代码注释】MySQL连接池配置,提高数据库访问性能
7.8 缓存策略实现
Redis缓存集成:
javascript
const redis = require('redis');
const client = redis.createClient();
// 缓存中间件
const cache = (duration) => {
return async (req, res, next) => {
const key = req.originalUrl;
// 检查缓存
client.get(key, async (err, data) => {
if (data) {
return res.send(JSON.parse(data));
}
// 包装res.send以缓存响应
res.sendResponse = res.send;
res.send = (body) => {
client.setex(key, duration, JSON.stringify(body));
res.sendResponse(body);
};
next();
});
};
};
// 使用缓存
app.get('/api/users', cache(60), async (req, res) => {
const users = await User.findAll();
res.json(users);
});
// 【代码注释】Redis缓存中间件实现,显著提高数据访问速度
7.9 请求验证最佳实践
使用express-validator进行输入验证:
javascript
const { body, param, query, validationResult } = require('express-validator');
// 用户注册验证规则
const registerValidation = [
body('username')
.isLength({ min: 3, max: 20 })
.withMessage('用户名长度必须在3-20个字符之间')
.matches(/^[a-zA-Z0-9_]+$/)
.withMessage('用户名只能包含字母、数字和下划线'),
body('email')
.isEmail()
.withMessage('请输入有效的邮箱地址')
.normalizeEmail(),
body('password')
.isLength({ min: 6 })
.withMessage('密码长度至少6个字符')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('密码必须包含大小写字母和数字'),
body('age')
.optional()
.isInt({ min: 18, max: 120 })
.withMessage('年龄必须在18-120之间')
];
// 应用验证规则
app.post('/api/users/register', registerValidation, (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// 处理注册逻辑
User.create(req.body).then(user => {
res.json({ success: true, user });
});
});
// 【代码注释】完整的输入验证实现,包括用户注册的各种验证规则
7.10 文件上传处理
使用Multer处理文件上传:
javascript
const multer = require('multer');
const path = require('path');
// 配置存储
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, 'uploads/');
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
}
});
// 文件过滤器
const fileFilter = (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('不支持的文件类型'), false);
}
};
// 创建multer实例
const upload = multer({
storage: storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB限制
fileFilter: fileFilter
});
// 单文件上传
app.post('/upload/single', upload.single('avatar'), (req, res) => {
if (!req.file) {
return res.status(400).send('请选择文件');
}
res.json({
success: true,
file: req.file
});
});
// 多文件上传
app.post('/upload/multiple', upload.array('photos', 12), (req, res) => {
if (!req.files || req.files.length === 0) {
return res.status(400).send('请选择文件');
}
res.json({
success: true,
files: req.files
});
});
// 混合上传
app.post('/upload/mixed', upload.fields([
{ name: 'avatar', maxCount: 1 },
{ name: 'gallery', maxCount: 8 }
]), (req, res) => {
res.json({
success: true,
files: req.files
});
});
// 【代码注释】完整的文件上传处理,包括单文件、多文件和混合上传
7.11 实时通信集成
Socket.IO集成示例:
javascript
const http = require('http');
const socketIo = require('socket.io');
const app = express();
const server = http.createServer(app);
const io = socketIo(server);
// Socket.IO连接处理
io.on('connection', (socket) => {
console.log('用户连接:', socket.id);
// 加入房间
socket.on('join-room', (room) => {
socket.join(room);
socket.to(room).emit('user-joined', `用户${socket.id}加入房间`);
});
// 发送消息
socket.on('send-message', (data) => {
io.to(data.room).emit('new-message', {
user: socket.id,
message: data.message,
time: new Date()
});
});
// 断开连接
socket.on('disconnect', () => {
console.log('用户断开连接:', socket.id);
});
});
// API路由
app.post('/api/notify', (req, res) => {
const { room, message } = req.body;
io.to(room).emit('notification', message);
res.json({ success: true });
});
server.listen(3000);
// 【代码注释】Socket.IO实时通信集成,实现房间、消息通知等功能
8. 高级主题与架构设计
8.1 微服务架构设计
微服务拆分示例:
javascript
// 用户服务
const userService = express();
userService.get('/users/:id', getUserHandler);
userService.post('/users', createUserHandler);
// 订单服务
const orderService = express();
orderService.get('/orders/:id', getOrderHandler);
orderService.post('/orders', createOrderHandler);
// API网关
const gateway = express();
gateway.use('/users', proxy('http://user-service:3001'));
gateway.use('/orders', proxy('http://order-service:3002'));
// 服务发现
const services = {
users: 'http://user-service:3001',
orders: 'http://order-service:3002',
products: 'http://product-service:3003'
};
// 动态代理中间件
const serviceProxy = (serviceName) => {
return proxy(services[serviceName]);
};
gateway.use('/api/users/:path(*)', serviceProxy('users'));
gateway.use('/api/orders/:path(*)', serviceProxy('orders'));
// 【代码注释】微服务架构基础实现,包括服务拆分和API网关
8.2 消息队列集成
使用Bull处理异步任务:
javascript
const Queue = require('bull');
const redisConfig = {
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT
};
// 创建队列
const emailQueue = new Queue('email-sending', { redis: redisConfig });
const imageQueue = new Queue('image-processing', { redis: redisConfig });
// 邮件发送处理器
emailQueue.process(async (job) => {
const { to, subject, content } = job.data;
// 发送邮件逻辑
await sendEmail({ to, subject, content });
return { success: true };
});
// 图片处理处理器
imageQueue.process(async (job) => {
const { imagePath } = job.data;
// 图片处理逻辑
const processedPath = await processImage(imagePath);
return { processedPath };
});
// 添加任务到队列
app.post('/api/send-email', async (req, res) => {
const { to, subject, content } = req.body;
// 添加到队列
await emailQueue.add({ to, subject, content });
res.json({ success: true, message: '邮件已加入发送队列' });
});
app.post('/api/upload-image', upload.single('image'), async (req, res) => {
// 添加到图片处理队列
await imageQueue.add({ imagePath: req.file.path });
res.json({ success: true, message: '图片正在处理中' });
});
// 【代码注释】消息队列集成,实现异步任务处理和系统解耦
8.3 认证授权系统
JWT认证实现:
javascript
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
// 生成JWT令牌
const generateToken = (user) => {
return jwt.sign(
{ id: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
};
// 认证中间件
const auth = async (req, res, next) => {
try {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: '请先登录' });
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findById(decoded.id);
if (!user) {
return res.status(401).json({ error: '用户不存在' });
}
req.user = user;
next();
} catch (error) {
res.status(401).json({ error: '认证失败' });
}
};
// 登录路由
app.post('/api/login', async (req, res) => {
try {
const { email, password } = req.body;
// 查找用户
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ error: '邮箱或密码错误' });
}
// 验证密码
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(401).json({ error: '邮箱或密码错误' });
}
// 生成令牌
const token = generateToken(user);
res.json({
success: true,
token,
user: {
id: user.id,
email: user.email,
name: user.name
}
});
} catch (error) {
res.status(500).json({ error: '登录失败' });
}
});
// 受保护的路由
app.get('/api/profile', auth, (req, res) => {
res.json(req.user);
});
// 【代码注释】完整的JWT认证系统,包括登录、令牌生成和认证中间件
8.3.1 Cookie/Session 与 JWT 认证深度对比
两种方案解决相同问题(HTTP 无状态 → 识别用户身份),但各有适用场景。
核心差异对比表:
| 维度 | Cookie / Session | JWT(JSON Web Token) |
|---|---|---|
| 状态存储 | 服务端(内存 / Redis / DB) | 客户端(Token 本身携带信息) |
| 服务扩展性 | 需共享 Session 存储(如 Redis) | 天然无状态,可任意横向扩展 |
| 注销控制 | 直接删除服务端 Session,立即生效 | 需维护 Token 黑名单,或缩短有效期 |
| 跨域支持 | Cookie 受同源策略限制 | Bearer Token 放 Header,无跨域问题 |
| 信息承载 | Session ID 无信息,每次需查存储 | Payload 内含 userId/role,避免查库 |
| 安全风险 | CSRF 攻击(需 CSRF Token 防御) | XSS(Token 若存 localStorage 可被读取) |
| 典型场景 | 传统 MVC 服务端渲染应用 | SPA、移动 APP、微服务间调用 |
Cookie/Session 完整登录流程:
javascript
const session = require('express-session');
const bcrypt = require('bcryptjs'); // 密码哈希对比
// 1. 挂载 Session 中间件(app.js 最前面)
app.use(session({
secret: process.env.SESSION_SECRET || 'change_me_in_production',
resave: false, // 未修改的 Session 不重复保存,减少 I/O
saveUninitialized: false, // 未初始化 Session 不写存储,节省空间
cookie: {
httpOnly: true, // 禁止 JS 通过 document.cookie 读取,防 XSS
secure: process.env.NODE_ENV === 'production', // 生产环境强制 HTTPS
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 天过期(毫秒)
}
}));
// 2. 登录路由:验证成功后将用户标识写入 Session
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// 查找用户(实际应查数据库)
const user = await User.findOne({ username });
if (!user) {
return res.render('login', { error: '用户名或密码错误' });
}
// 使用 bcrypt 安全对比密码(明文 vs 哈希值)
const isMatch = await bcrypt.compare(password, user.passwordHash);
if (!isMatch) {
return res.render('login', { error: '用户名或密码错误' });
}
// 成功:写入 Session,设置标识
req.session.userId = user.id;
req.session.username = user.username;
// 跳回登录前的页面(若存在),否则去首页
const returnTo = req.session.returnTo || '/dashboard';
delete req.session.returnTo;
res.redirect(returnTo);
});
// 3. 注销路由:彻底清除 Session
app.get('/logout', (req, res) => {
req.session.destroy(err => {
if (err) return next(err);
res.clearCookie('connect.sid'); // 同时清除客户端 Cookie
res.redirect('/login');
});
});
JWT 认证的注销难题与解决方案:
javascript
// JWT 黑名单方案(Redis 存储已注销的 Token)
const redis = require('redis');
const client = redis.createClient();
// 注销接口:将 Token 加入黑名单,直到过期
app.post('/api/logout', auth, async (req, res) => {
const token = req.header('Authorization').replace('Bearer ', '');
const decoded = jwt.decode(token);
// 将 Token 存入 Redis,TTL = Token 剩余有效期
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await client.setEx(`blacklist:${token}`, ttl, '1');
}
res.json({ success: true, message: '已退出登录' });
});
// 在认证中间件中检查黑名单
const auth = async (req, res, next) => {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: '请先登录' });
// 检查是否在黑名单
const isBlacklisted = await client.get(`blacklist:${token}`);
if (isBlacklisted) return res.status(401).json({ error: 'Token 已失效,请重新登录' });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch {
res.status(401).json({ error: '认证失败' });
}
};
【代码注释】
- 记账本课堂实践 :传统 MVC 服务端渲染用 Cookie/Session 更合适,配合
express-session即可; - 前后端分离 API 用 JWT ,前端存
localStorage(注意 XSS 风险)或httpOnly Cookie(兼顾安全); bcrypt.compare是常数时间对比,防止时序攻击;切勿直接用===比对密码;- 生产环境 Session 不应存内存(进程重启丢失),应配置
connect-redis存 Redis。
选型速查:
服务端渲染(EJS/Pug)? → Cookie/Session
前后端分离 SPA? → JWT
微服务间鉴权? → JWT(服务间无 Cookie 共享)
需要"踢人下线"功能? → Cookie/Session(或 JWT + Redis 黑名单)
8.4 测试策略
单元测试示例:
javascript
const request = require('supertest');
const app = require('../app');
describe('User API Tests', () => {
describe('POST /api/users', () => {
it('应该创建新用户', async () => {
const userData = {
username: 'testuser',
email: 'test@example.com',
password: 'password123'
};
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(201);
expect(response.body).toHaveProperty('id');
expect(response.body.username).toBe(userData.username);
});
it('应该拒绝无效的用户数据', async () => {
const invalidData = {
username: 't',
email: 'invalid-email'
};
const response = await request(app)
.post('/api/users')
.send(invalidData)
.expect(400);
expect(response.body).toHaveProperty('errors');
});
});
describe('GET /api/users/:id', () => {
it('应该返回指定用户', async () => {
const response = await request(app)
.get('/api/users/1')
.expect(200);
expect(response.body).toHaveProperty('id', 1);
});
it('应该返回404当用户不存在', async () => {
const response = await request(app)
.get('/api/users/9999')
.expect(404);
expect(response.body).toHaveProperty('error');
});
});
});
// 【代码注释】使用Jest和supertest进行API测试
8.5 部署与运维
Docker化Express应用:
dockerfile
# Dockerfile
FROM node:16-alpine
# 设置工作目录
WORKDIR /usr/src/app
# 复制package文件
COPY package*.json ./
# 安装依赖
RUN npm ci --only=production
# 复制应用代码
COPY . .
# 暴露端口
EXPOSE 3000
# 设置环境变量
ENV NODE_ENV=production
# 启动应用
CMD ["node", "bin/www"]
Docker Compose配置:
yaml
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DB_HOST=db
- REDIS_HOST=redis
depends_on:
- db
- redis
restart: unless-stopped
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: myapp
volumes:
- db_data:/var/lib/mysql
restart: unless-stopped
redis:
image: redis:alpine
restart: unless-stopped
volumes:
db_data:
Nginx反向代理配置:
nginx
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /static {
alias /usr/src/app/public;
expires 1y;
add_header Cache-Control "public, immutable";
}
}
8.6 Express 与 Koa 中间件模型深度对比
Node.js 社区两大主流框架的核心差异就在于中间件模型。理解这个差异不仅帮助选型,也能让你真正看懂 Express 中间件的执行机制。
Express:线性(单向)中间件链
请求 ──→ 中间件A ──→ 中间件B ──→ 路由处理器 ──→ 响应
↑
next() 单向向下传递,不自动"回来"
Express 中间件是单向传递 的。调用 next() 把控制权向下交出,但后续中间件/路由执行完毕后不会 自动回到当前中间件的 next() 之后继续执行。
javascript
// Express 中间件:演示单向传递
app.use((req, res, next) => {
console.log('A: 进入'); // ① 最先打印
next(); // 把控制权交给下一个中间件
console.log('A: next 后'); // ③ next() 返回后执行,但此时响应可能已发送
});
app.use((req, res, next) => {
console.log('B: 进入'); // ② 打印
res.send('Hello'); // 发送响应,结束请求
});
// 实际输出顺序:A: 进入 → B: 进入 → A: next 后
// 注意:A 中 next() 之后的代码在 B 执行后才会运行,
// 但此时 res 已 send(),若再操作 res 会报 ERR_HTTP_HEADERS_SENT
Express 中想在路由执行后处理响应的正确方式:
javascript
// 用 res.on('finish') 而非 next() 之后的代码
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
// finish 事件在响应发送完毕后触发,此时可安全记录状态码、耗时等
console.log(`${req.method} ${req.url} - ${res.statusCode} - ${Date.now() - start}ms`);
});
next();
});
Koa2:洋葱圈(双向)中间件模型
请求
↓
┌─── A 前半段 ───┐
│ ┌─ B 前半段 ─┤
│ │ 路由逻辑 │
│ └─ B 后半段 ─┤ ← await next() 之后继续执行
└─── A 后半段 ───┘ ← await next() 之后继续执行
↓
响应
Koa 使用 async/await + await next() 实现双向穿透 ------中间件可在 await next() 之后拿到响应已写入的 ctx 状态。
javascript
// Koa 中间件:洋葱圈双向穿透
const Koa = require('koa');
const app = new Koa();
// 中间件 A:响应时间记录
app.use(async (ctx, next) => {
console.log('A: 进入');
const start = Date.now();
await next(); // 等待所有后续中间件(含路由)执行完毕
// next() resolve 后,响应已准备好,可直接操作 ctx
const duration = Date.now() - start;
ctx.set('X-Response-Time', `${duration}ms`); // 给响应头追加耗时
console.log(`A: 完成,耗时 ${duration}ms`);
});
// 中间件 B:路由逻辑
app.use(async (ctx, next) => {
console.log('B: 进入');
ctx.body = { message: 'Hello Koa' };
await next();
console.log('B: 完成');
});
// 实际输出:A进入 → B进入 → B完成 → A完成(含耗时)
// X-Response-Time 头已自动添加到响应
两种模型对比表
| 维度 | Express 线性模型 | Koa 洋葱圈模型 |
|---|---|---|
| 控制流 | 单向,next() 后不自动回来 |
双向,await next() 后继续执行 |
| 响应后操作 | 需监听 res.on('finish') |
直接在 await next() 之后写 |
| 异步支持 | 回调 / Promise(需手动 catch) | 原生 async/await,错误自动捕获 |
| 错误处理 | 需四参数错误中间件 (err,req,res,next) |
try/catch 包裹 await next() |
| 学习曲线 | 低,概念直观 | 稍高,需理解洋葱模型 |
| 中间件生态 | 极丰富(数万个 npm 包) | 较丰富,部分需 koa-compat |
| 典型用途 | 教学、中小型 MVC 应用 | 高性能 API、微服务、大型项目 |
Koa 错误处理优势演示:
javascript
// Koa:一个 try/catch 捕获整条中间件链的错误
app.use(async (ctx, next) => {
try {
await next(); // 任何后续中间件抛出的错误都会被捕获
} catch (err) {
ctx.status = err.status || 500;
ctx.body = { error: err.message };
ctx.app.emit('error', err, ctx); // 触发应用级错误事件用于日志
}
});
// Express 等价写法需要四参数中间件 + 每个 async 路由都 try/catch + next(err)
选型建议
课堂学习 / 中小型 MVC 应用? → Express(生态最丰富,文档最多)
需要中间件后操作响应头/Body? → Koa(洋葱圈天然支持)
高性能 API / 微服务? → Koa 或 Fastify
团队已有 Express 项目? → 继续 Express,用 res.on('finish') 补充
核心记忆点: Express 中间件是"进站票"(单程),Koa 中间件是"往返票"(
await next()后还能回来)。理解这个差异,面试和实战都能游刃有余。
9. 总结与进阶学习
9.1 核心概念总结
Express框架核心要素:
#mermaid-svg-uj2Jb6951VjLGuHs{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uj2Jb6951VjLGuHs .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uj2Jb6951VjLGuHs .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uj2Jb6951VjLGuHs .error-icon{fill:#552222;}#mermaid-svg-uj2Jb6951VjLGuHs .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uj2Jb6951VjLGuHs .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uj2Jb6951VjLGuHs .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uj2Jb6951VjLGuHs .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uj2Jb6951VjLGuHs .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uj2Jb6951VjLGuHs .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uj2Jb6951VjLGuHs .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uj2Jb6951VjLGuHs .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uj2Jb6951VjLGuHs .marker.cross{stroke:#333333;}#mermaid-svg-uj2Jb6951VjLGuHs svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uj2Jb6951VjLGuHs p{margin:0;}#mermaid-svg-uj2Jb6951VjLGuHs .edge{stroke-width:3;}#mermaid-svg-uj2Jb6951VjLGuHs .section--1 rect,#mermaid-svg-uj2Jb6951VjLGuHs .section--1 path,#mermaid-svg-uj2Jb6951VjLGuHs .section--1 circle,#mermaid-svg-uj2Jb6951VjLGuHs .section--1 polygon,#mermaid-svg-uj2Jb6951VjLGuHs .section--1 path{fill:hsl(240, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .section--1 text{fill:#ffffff;}#mermaid-svg-uj2Jb6951VjLGuHs .node-icon--1{font-size:40px;color:#ffffff;}#mermaid-svg-uj2Jb6951VjLGuHs .section-edge--1{stroke:hsl(240, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .edge-depth--1{stroke-width:17;}#mermaid-svg-uj2Jb6951VjLGuHs .section--1 line{stroke:hsl(60, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled,#mermaid-svg-uj2Jb6951VjLGuHs .disabled circle,#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:lightgray;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:#efefef;}#mermaid-svg-uj2Jb6951VjLGuHs .section-0 rect,#mermaid-svg-uj2Jb6951VjLGuHs .section-0 path,#mermaid-svg-uj2Jb6951VjLGuHs .section-0 circle,#mermaid-svg-uj2Jb6951VjLGuHs .section-0 polygon,#mermaid-svg-uj2Jb6951VjLGuHs .section-0 path{fill:hsl(60, 100%, 73.5294117647%);}#mermaid-svg-uj2Jb6951VjLGuHs .section-0 text{fill:black;}#mermaid-svg-uj2Jb6951VjLGuHs .node-icon-0{font-size:40px;color:black;}#mermaid-svg-uj2Jb6951VjLGuHs .section-edge-0{stroke:hsl(60, 100%, 73.5294117647%);}#mermaid-svg-uj2Jb6951VjLGuHs .edge-depth-0{stroke-width:14;}#mermaid-svg-uj2Jb6951VjLGuHs .section-0 line{stroke:hsl(240, 100%, 83.5294117647%);stroke-width:3;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled,#mermaid-svg-uj2Jb6951VjLGuHs .disabled circle,#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:lightgray;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:#efefef;}#mermaid-svg-uj2Jb6951VjLGuHs .section-1 rect,#mermaid-svg-uj2Jb6951VjLGuHs .section-1 path,#mermaid-svg-uj2Jb6951VjLGuHs .section-1 circle,#mermaid-svg-uj2Jb6951VjLGuHs .section-1 polygon,#mermaid-svg-uj2Jb6951VjLGuHs .section-1 path{fill:hsl(80, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .section-1 text{fill:black;}#mermaid-svg-uj2Jb6951VjLGuHs .node-icon-1{font-size:40px;color:black;}#mermaid-svg-uj2Jb6951VjLGuHs .section-edge-1{stroke:hsl(80, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .edge-depth-1{stroke-width:11;}#mermaid-svg-uj2Jb6951VjLGuHs .section-1 line{stroke:hsl(260, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled,#mermaid-svg-uj2Jb6951VjLGuHs .disabled circle,#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:lightgray;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:#efefef;}#mermaid-svg-uj2Jb6951VjLGuHs .section-2 rect,#mermaid-svg-uj2Jb6951VjLGuHs .section-2 path,#mermaid-svg-uj2Jb6951VjLGuHs .section-2 circle,#mermaid-svg-uj2Jb6951VjLGuHs .section-2 polygon,#mermaid-svg-uj2Jb6951VjLGuHs .section-2 path{fill:hsl(270, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .section-2 text{fill:#ffffff;}#mermaid-svg-uj2Jb6951VjLGuHs .node-icon-2{font-size:40px;color:#ffffff;}#mermaid-svg-uj2Jb6951VjLGuHs .section-edge-2{stroke:hsl(270, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .edge-depth-2{stroke-width:8;}#mermaid-svg-uj2Jb6951VjLGuHs .section-2 line{stroke:hsl(90, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled,#mermaid-svg-uj2Jb6951VjLGuHs .disabled circle,#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:lightgray;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:#efefef;}#mermaid-svg-uj2Jb6951VjLGuHs .section-3 rect,#mermaid-svg-uj2Jb6951VjLGuHs .section-3 path,#mermaid-svg-uj2Jb6951VjLGuHs .section-3 circle,#mermaid-svg-uj2Jb6951VjLGuHs .section-3 polygon,#mermaid-svg-uj2Jb6951VjLGuHs .section-3 path{fill:hsl(300, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .section-3 text{fill:black;}#mermaid-svg-uj2Jb6951VjLGuHs .node-icon-3{font-size:40px;color:black;}#mermaid-svg-uj2Jb6951VjLGuHs .section-edge-3{stroke:hsl(300, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .edge-depth-3{stroke-width:5;}#mermaid-svg-uj2Jb6951VjLGuHs .section-3 line{stroke:hsl(120, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled,#mermaid-svg-uj2Jb6951VjLGuHs .disabled circle,#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:lightgray;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:#efefef;}#mermaid-svg-uj2Jb6951VjLGuHs .section-4 rect,#mermaid-svg-uj2Jb6951VjLGuHs .section-4 path,#mermaid-svg-uj2Jb6951VjLGuHs .section-4 circle,#mermaid-svg-uj2Jb6951VjLGuHs .section-4 polygon,#mermaid-svg-uj2Jb6951VjLGuHs .section-4 path{fill:hsl(330, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .section-4 text{fill:black;}#mermaid-svg-uj2Jb6951VjLGuHs .node-icon-4{font-size:40px;color:black;}#mermaid-svg-uj2Jb6951VjLGuHs .section-edge-4{stroke:hsl(330, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .edge-depth-4{stroke-width:2;}#mermaid-svg-uj2Jb6951VjLGuHs .section-4 line{stroke:hsl(150, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled,#mermaid-svg-uj2Jb6951VjLGuHs .disabled circle,#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:lightgray;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:#efefef;}#mermaid-svg-uj2Jb6951VjLGuHs .section-5 rect,#mermaid-svg-uj2Jb6951VjLGuHs .section-5 path,#mermaid-svg-uj2Jb6951VjLGuHs .section-5 circle,#mermaid-svg-uj2Jb6951VjLGuHs .section-5 polygon,#mermaid-svg-uj2Jb6951VjLGuHs .section-5 path{fill:hsl(0, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .section-5 text{fill:black;}#mermaid-svg-uj2Jb6951VjLGuHs .node-icon-5{font-size:40px;color:black;}#mermaid-svg-uj2Jb6951VjLGuHs .section-edge-5{stroke:hsl(0, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .edge-depth-5{stroke-width:-1;}#mermaid-svg-uj2Jb6951VjLGuHs .section-5 line{stroke:hsl(180, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled,#mermaid-svg-uj2Jb6951VjLGuHs .disabled circle,#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:lightgray;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:#efefef;}#mermaid-svg-uj2Jb6951VjLGuHs .section-6 rect,#mermaid-svg-uj2Jb6951VjLGuHs .section-6 path,#mermaid-svg-uj2Jb6951VjLGuHs .section-6 circle,#mermaid-svg-uj2Jb6951VjLGuHs .section-6 polygon,#mermaid-svg-uj2Jb6951VjLGuHs .section-6 path{fill:hsl(30, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .section-6 text{fill:black;}#mermaid-svg-uj2Jb6951VjLGuHs .node-icon-6{font-size:40px;color:black;}#mermaid-svg-uj2Jb6951VjLGuHs .section-edge-6{stroke:hsl(30, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .edge-depth-6{stroke-width:-4;}#mermaid-svg-uj2Jb6951VjLGuHs .section-6 line{stroke:hsl(210, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled,#mermaid-svg-uj2Jb6951VjLGuHs .disabled circle,#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:lightgray;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:#efefef;}#mermaid-svg-uj2Jb6951VjLGuHs .section-7 rect,#mermaid-svg-uj2Jb6951VjLGuHs .section-7 path,#mermaid-svg-uj2Jb6951VjLGuHs .section-7 circle,#mermaid-svg-uj2Jb6951VjLGuHs .section-7 polygon,#mermaid-svg-uj2Jb6951VjLGuHs .section-7 path{fill:hsl(90, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .section-7 text{fill:black;}#mermaid-svg-uj2Jb6951VjLGuHs .node-icon-7{font-size:40px;color:black;}#mermaid-svg-uj2Jb6951VjLGuHs .section-edge-7{stroke:hsl(90, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .edge-depth-7{stroke-width:-7;}#mermaid-svg-uj2Jb6951VjLGuHs .section-7 line{stroke:hsl(270, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled,#mermaid-svg-uj2Jb6951VjLGuHs .disabled circle,#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:lightgray;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:#efefef;}#mermaid-svg-uj2Jb6951VjLGuHs .section-8 rect,#mermaid-svg-uj2Jb6951VjLGuHs .section-8 path,#mermaid-svg-uj2Jb6951VjLGuHs .section-8 circle,#mermaid-svg-uj2Jb6951VjLGuHs .section-8 polygon,#mermaid-svg-uj2Jb6951VjLGuHs .section-8 path{fill:hsl(150, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .section-8 text{fill:black;}#mermaid-svg-uj2Jb6951VjLGuHs .node-icon-8{font-size:40px;color:black;}#mermaid-svg-uj2Jb6951VjLGuHs .section-edge-8{stroke:hsl(150, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .edge-depth-8{stroke-width:-10;}#mermaid-svg-uj2Jb6951VjLGuHs .section-8 line{stroke:hsl(330, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled,#mermaid-svg-uj2Jb6951VjLGuHs .disabled circle,#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:lightgray;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:#efefef;}#mermaid-svg-uj2Jb6951VjLGuHs .section-9 rect,#mermaid-svg-uj2Jb6951VjLGuHs .section-9 path,#mermaid-svg-uj2Jb6951VjLGuHs .section-9 circle,#mermaid-svg-uj2Jb6951VjLGuHs .section-9 polygon,#mermaid-svg-uj2Jb6951VjLGuHs .section-9 path{fill:hsl(180, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .section-9 text{fill:black;}#mermaid-svg-uj2Jb6951VjLGuHs .node-icon-9{font-size:40px;color:black;}#mermaid-svg-uj2Jb6951VjLGuHs .section-edge-9{stroke:hsl(180, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .edge-depth-9{stroke-width:-13;}#mermaid-svg-uj2Jb6951VjLGuHs .section-9 line{stroke:hsl(0, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled,#mermaid-svg-uj2Jb6951VjLGuHs .disabled circle,#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:lightgray;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:#efefef;}#mermaid-svg-uj2Jb6951VjLGuHs .section-10 rect,#mermaid-svg-uj2Jb6951VjLGuHs .section-10 path,#mermaid-svg-uj2Jb6951VjLGuHs .section-10 circle,#mermaid-svg-uj2Jb6951VjLGuHs .section-10 polygon,#mermaid-svg-uj2Jb6951VjLGuHs .section-10 path{fill:hsl(210, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .section-10 text{fill:black;}#mermaid-svg-uj2Jb6951VjLGuHs .node-icon-10{font-size:40px;color:black;}#mermaid-svg-uj2Jb6951VjLGuHs .section-edge-10{stroke:hsl(210, 100%, 76.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .edge-depth-10{stroke-width:-16;}#mermaid-svg-uj2Jb6951VjLGuHs .section-10 line{stroke:hsl(30, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled,#mermaid-svg-uj2Jb6951VjLGuHs .disabled circle,#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:lightgray;}#mermaid-svg-uj2Jb6951VjLGuHs .disabled text{fill:#efefef;}#mermaid-svg-uj2Jb6951VjLGuHs .section-root rect,#mermaid-svg-uj2Jb6951VjLGuHs .section-root path,#mermaid-svg-uj2Jb6951VjLGuHs .section-root circle,#mermaid-svg-uj2Jb6951VjLGuHs .section-root polygon{fill:hsl(240, 100%, 46.2745098039%);}#mermaid-svg-uj2Jb6951VjLGuHs .section-root text{fill:#ffffff;}#mermaid-svg-uj2Jb6951VjLGuHs .section-root span{color:#ffffff;}#mermaid-svg-uj2Jb6951VjLGuHs .section-2 span{color:#ffffff;}#mermaid-svg-uj2Jb6951VjLGuHs .icon-container{height:100%;display:flex;justify-content:center;align-items:center;}#mermaid-svg-uj2Jb6951VjLGuHs .edge{fill:none;}#mermaid-svg-uj2Jb6951VjLGuHs .mindmap-node-label{dy:1em;alignment-baseline:middle;text-anchor:middle;dominant-baseline:middle;text-align:center;}#mermaid-svg-uj2Jb6951VjLGuHs :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Express框架
中间件系统
应用级中间件
路由级中间件
错误处理中间件
内置中间件
第三方中间件
路由系统
基本路由
路由参数
RESTful设计
路由模块化
模板引擎
EJS语法
视图渲染
数据传递
模板继承
静态资源管理
数据存储
LowDB
文件系统
数据库集成
缓存机制
项目架构
MVC模式
分层架构
模块化设计
项目生成器
开发工具
调试技巧
测试框架
部署方案
性能优化
9.2 学习路径建议
初级阶段:
- 掌握Node.js基础知识
- 理解HTTP协议基本概念
- 学习Express基本用法
- 实现简单的CRUD应用
中级阶段:
- 深入理解中间件机制
- 掌握模板引擎使用
- 学习数据库集成
- 实现用户认证系统
- 掌握项目架构设计
高级阶段:
- 学习架构设计模式
- 掌握性能优化技巧
- 了解安全最佳实践
- 学习微服务架构
- 掌握部署和运维技能
9.3 技术栈演进路径
#mermaid-svg-AxOdLt972T1Fy34w{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-AxOdLt972T1Fy34w .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-AxOdLt972T1Fy34w .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-AxOdLt972T1Fy34w .error-icon{fill:#552222;}#mermaid-svg-AxOdLt972T1Fy34w .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-AxOdLt972T1Fy34w .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-AxOdLt972T1Fy34w .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-AxOdLt972T1Fy34w .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-AxOdLt972T1Fy34w .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-AxOdLt972T1Fy34w .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-AxOdLt972T1Fy34w .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-AxOdLt972T1Fy34w .marker{fill:#333333;stroke:#333333;}#mermaid-svg-AxOdLt972T1Fy34w .marker.cross{stroke:#333333;}#mermaid-svg-AxOdLt972T1Fy34w svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-AxOdLt972T1Fy34w p{margin:0;}#mermaid-svg-AxOdLt972T1Fy34w .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-AxOdLt972T1Fy34w .cluster-label text{fill:#333;}#mermaid-svg-AxOdLt972T1Fy34w .cluster-label span{color:#333;}#mermaid-svg-AxOdLt972T1Fy34w .cluster-label span p{background-color:transparent;}#mermaid-svg-AxOdLt972T1Fy34w .label text,#mermaid-svg-AxOdLt972T1Fy34w span{fill:#333;color:#333;}#mermaid-svg-AxOdLt972T1Fy34w .node rect,#mermaid-svg-AxOdLt972T1Fy34w .node circle,#mermaid-svg-AxOdLt972T1Fy34w .node ellipse,#mermaid-svg-AxOdLt972T1Fy34w .node polygon,#mermaid-svg-AxOdLt972T1Fy34w .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-AxOdLt972T1Fy34w .rough-node .label text,#mermaid-svg-AxOdLt972T1Fy34w .node .label text,#mermaid-svg-AxOdLt972T1Fy34w .image-shape .label,#mermaid-svg-AxOdLt972T1Fy34w .icon-shape .label{text-anchor:middle;}#mermaid-svg-AxOdLt972T1Fy34w .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-AxOdLt972T1Fy34w .rough-node .label,#mermaid-svg-AxOdLt972T1Fy34w .node .label,#mermaid-svg-AxOdLt972T1Fy34w .image-shape .label,#mermaid-svg-AxOdLt972T1Fy34w .icon-shape .label{text-align:center;}#mermaid-svg-AxOdLt972T1Fy34w .node.clickable{cursor:pointer;}#mermaid-svg-AxOdLt972T1Fy34w .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-AxOdLt972T1Fy34w .arrowheadPath{fill:#333333;}#mermaid-svg-AxOdLt972T1Fy34w .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-AxOdLt972T1Fy34w .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-AxOdLt972T1Fy34w .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AxOdLt972T1Fy34w .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-AxOdLt972T1Fy34w .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AxOdLt972T1Fy34w .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-AxOdLt972T1Fy34w .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-AxOdLt972T1Fy34w .cluster text{fill:#333;}#mermaid-svg-AxOdLt972T1Fy34w .cluster span{color:#333;}#mermaid-svg-AxOdLt972T1Fy34w div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-AxOdLt972T1Fy34w .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-AxOdLt972T1Fy34w rect.text{fill:none;stroke-width:0;}#mermaid-svg-AxOdLt972T1Fy34w .icon-shape,#mermaid-svg-AxOdLt972T1Fy34w .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AxOdLt972T1Fy34w .icon-shape p,#mermaid-svg-AxOdLt972T1Fy34w .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-AxOdLt972T1Fy34w .icon-shape .label rect,#mermaid-svg-AxOdLt972T1Fy34w .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AxOdLt972T1Fy34w .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-AxOdLt972T1Fy34w .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-AxOdLt972T1Fy34w :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 初级开发者
中级开发者
高级开发者
全栈开发者
架构师
Node.js基础
Express基础
简单CRUD
中间件深入
数据库集成
认证授权
性能优化
安全加固
微服务架构
前端框架
全栈开发
DevOps
系统设计
技术选型
团队管理
9.4 常见问题解决手册
问题1:跨域问题
javascript
const cors = require('cors');
// 简单配置
app.use(cors());
// 高级配置
app.use(cors({
origin: ['https://example.com', 'https://app.example.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 3600,
optionsSuccessStatus: 204
}));
// 【代码注释】CORS配置,解决前后端分离项目的跨域问题
问题2:Session管理
javascript
const session = require('express-session');
const RedisStore = require('connect-redis')(session);
app.use(session({
store: new RedisStore({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT
}),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24小时
}
}));
// 【代码注释】Session配置,使用Redis存储提高性能和可靠性
问题3:文件上传大文件处理
javascript
const multer = require('multer');
const crypto = require('crypto');
const path = require('path');
// 大文件处理配置
const upload = multer({
storage: multer.diskStorage({
destination: function (req, file, cb) {
cb(null, 'uploads/')
},
filename: function (req, file, cb) {
crypto.pseudoRandomBytes(16, function (err, raw) {
cb(null, raw.toString('hex') + path.extname(file.originalname))
})
}
}),
limits: { fileSize: 100 * 1024 * 1024 } // 100MB
});
app.post('/upload-large', upload.single('file'), async (req, res) => {
try {
// 处理大文件
const result = await processLargeFile(req.file);
res.json({ success: true, result });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 【代码注释】大文件上传处理,使用随机文件名和文件大小限制
问题4:并发请求处理
javascript
const rateLimit = require('express-rate-limit');
// 基本限流
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
max: 100, // 限制100个请求
message: '请求过多,请稍后再试'
});
app.use('/api/', limiter);
// 严格的API限流
const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1分钟
max: 20,
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api/sensitive/', apiLimiter);
// 【代码注释】请求限流配置,防止API滥用和DDoS攻击
问题5:日志管理最佳实践
javascript
const winston = require('winston');
const { combine, timestamp, printf } = winston.format;
// 自定义日志格式
const logFormat = printf(({ level, message, timestamp }) => {
return `${timestamp} [${level}]: ${message}`;
});
// 创建logger
const logger = winston.createLogger({
level: 'info',
format: combine(
timestamp(),
logFormat
),
transports: [
// 错误日志
new winston.transports.File({
filename: 'logs/error.log',
level: 'error'
}),
// 所有日志
new winston.transports.File({
filename: 'logs/combined.log'
})
]
});
// 开发环境添加控制台输出
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.simple()
}));
}
// Express日志中间件
app.use((req, res, next) => {
logger.info(`${req.method} ${req.url}`);
next();
});
// 【代码注释】使用Winston进行专业级日志管理
9.5 性能优化检查清单
应用层优化:
- 启用Gzip压缩
- 实现响应缓存
- 优化数据库查询
- 使用连接池
- 实现请求限流
- 优化静态资源服务
- 启用HTTP/2
- 实现CDN集成
代码层优化:
- 避免同步操作
- 优化中间件顺序
- 使用流处理大文件
- 实现懒加载
- 优化正则表达式
- 减少内存泄漏
部署层优化:
- 使用PM2集群模式
- 配置Nginx反向代理
- 启用HTTP缓存
- 实现负载均衡
- 监控应用性能
- 设置错误告警
9.6 安全检查清单
基础安全措施:
- 使用Helmet设置安全头
- 实现CORS策略
- 输入验证和清理
- 参数化SQL查询
- 使用HTTPS
- 实现CSRF保护
- 设置速率限制
- 安全的Session管理
高级安全措施:
- 实现内容安全策略
- 定期依赖更新
- 安全日志记录
- 入侵检测系统
- 数据加密
- 安全审计
- 渗透测试
9.7 监控和调试
应用监控配置:
javascript
const Prometheus = require('prom-client');
// 创建指标收集器
const httpRequestDuration = new Prometheus.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code']
});
// 中间件收集指标
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = (Date.now() - start) / 1000;
httpRequestDuration
.labels(req.method, req.route?.path || req.path, res.statusCode)
.observe(duration);
});
next();
});
// 指标端点
app.get('/metrics', (req, res) => {
res.set('Content-Type', Prometheus.register.contentType);
res.end(Prometheus.register.metrics());
});
// 【代码注释】使用Prometheus进行应用性能监控
9.8 真实世界案例研究
案例1:电商平台架构
javascript
// 产品服务
const productService = express();
productService.get('/products', async (req, res) => {
// 实现缓存、分页、过滤
const cacheKey = `products:${JSON.stringify(req.query)}`;
// 检查缓存
const cached = await redis.get(cacheKey);
if (cached) return res.json(JSON.parse(cached));
// 查询数据库
const products = await Product.find(req.query)
.limit(req.query.limit || 10)
.skip((req.query.page || 1) * 10 - 10);
// 缓存结果
await redis.setex(cacheKey, 300, JSON.stringify(products));
res.json(products);
});
productService.post('/products/:id/reviews', auth, async (req, res) => {
// 添加评论,更新产品统计
const review = await Review.create({
product: req.params.id,
user: req.user.id,
...req.body
});
// 异步更新产品统计
updateProductStats.queue(req.params.id);
res.status(201).json(review);
});
// 【代码注释】电商平台的产品服务实现,展示缓存和异步处理
案例2:社交平台实时功能
javascript
const socialService = express();
const http = require('http');
const server = http.createServer(socialService);
const io = require('socket.io')(server);
// 实时通知系统
io.on('connection', (socket) => {
socket.on('follow-user', async (data) => {
// 处理关注逻辑
await User.follow(socket.userId, data.targetUserId);
// 发送实时通知
io.to(`user-${data.targetUserId}`).emit('new-follower', {
follower: socket.userId,
timestamp: new Date()
});
});
socket.on('send-message', async (data) => {
// 保存消息
const message = await Message.create({
from: socket.userId,
to: data.to,
content: data.content
});
// 实时发送
io.to(`user-${data.to}`).emit('new-message', message);
});
});
// 推送通知
socialService.post('/api/notify', auth, async (req, res) => {
const { userIds, message } = req.body;
userIds.forEach(userId => {
io.to(`user-${userId}`).emit('notification', message);
});
res.json({ success: true });
});
// 【代码注释】社交平台的实时功能实现,包括关注和消息功能
9.9 面试准备重点
核心技术概念:
-
中间件机制
- 中间件的执行顺序
- next()函数的作用
- 错误处理中间件的4个参数
- 应用级vs路由级中间件
-
路由系统
- RESTful设计原则
- 路由参数处理
- 路由模块化
- 动态路由vs静态路由
-
异步处理
- 回调函数
- Promise
- async/await
- 错误处理
-
安全与性能
- 常见安全漏洞
- 性能优化技巧
- 缓存策略
- 负载均衡
实战经验要点:
- 项目架构设计经验
- 性能优化案例
- 故障排查经验
- 团队协作经验
- 技术选型决策
9.10 社区与资源
官方资源:
学习资源:
- GitHub开源项目
- Stack Overflow技术问答
- Medium技术博客
- 在线技术专栏与开源示例项目
社区参与:
- GitHub贡献代码
- Stack Overflow回答问题
- 技术博客写作
- 开源项目维护
10. 结语
通过本文的深入学习,我们已经全面掌握了Express框架的核心概念、实际应用和高级技巧。从基础的中间件机制到复杂的微服务架构,从简单的CRUD应用到高性能的实时系统,Express为我们提供了构建现代Web应用所需的一切工具。
关键技术回顾:
- 中间件系统:Express的核心,理解了中间件的执行流程和最佳实践
- 路由设计:掌握了RESTfulAPI设计和路由模块化
- 模板引擎:学会了使用EJS进行服务端渲染
- 数据存储:熟悉了从LowDB到专业数据库的使用
- 项目架构:了解了如何设计可扩展的应用架构
- 安全与性能:掌握了保护应用和优化性能的关键技术
持续学习的重要性:
Web开发技术日新月异,Express生态系统也在不断演进。保持持续学习的习惯,关注最新的技术趋势,积极参与开源社区,这些都是成长为优秀开发者的重要因素。
实践建议:
- 动手实践:理论知识需要通过实际项目来巩固
- 代码重构:不断优化和改进自己的代码
- 技术分享:通过写作和演讲来加深理解
- 社区参与:为开源项目贡献代码和想法
- 问题解决:在真实项目中锻炼问题解决能力
未来展望:
随着Node.js生态的不断发展,Express框架也在持续演进。新兴的技术如Serverless架构、GraphQL、微前端等都在影响着Web开发的未来方向。作为开发者,我们需要保持开放的心态,积极学习和适应这些新技术。
最终寄语:
Express框架简单而强大,它为无数开发者提供了构建Web应用的基础。希望本文能够为你的学习之旅提供有价值的指导。记住,最好的学习方式就是实践,最好的老师就是经验。
祝愿每一位读者都能在Web开发的道路上不断进步,创造出优秀的产品,为技术社区做出自己的贡献。
让我们用代码改变世界,用技术创造价值!
11. 核心案例速查与知识点归纳
11.1 课堂案例学习路线
| 步骤 | 目录 | 核心点 |
|---|---|---|
| ① | 01-Express中间件 | accesslog、catchError、next() |
| ② | 02-Express路由模块化 | routes/index、routes/login 挂载 |
| ③ | 03-Express模板引擎 | app.set('view engine')、res.render |
| ④ | 04-项目目录生成器 | express --view=ejs |
| ⑤ | 05-lowdb使用演示 | lowdb + FileSync |
| ⑥ | 06-记账本项目 | 完整 CRUD + EJS + Bootstrap |
bash
npm install
npm start # 记账本项目请用生成器启动,勿直接 node app.js
11.2 中间件执行顺序(必背)
catchError 路由 accessLog 客户端 catchError 路由 accessLog 客户端 #mermaid-svg-cBN7XXwegdZ5i5OU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-cBN7XXwegdZ5i5OU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-cBN7XXwegdZ5i5OU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-cBN7XXwegdZ5i5OU .error-icon{fill:#552222;}#mermaid-svg-cBN7XXwegdZ5i5OU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-cBN7XXwegdZ5i5OU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-cBN7XXwegdZ5i5OU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-cBN7XXwegdZ5i5OU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-cBN7XXwegdZ5i5OU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-cBN7XXwegdZ5i5OU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-cBN7XXwegdZ5i5OU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-cBN7XXwegdZ5i5OU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-cBN7XXwegdZ5i5OU .marker.cross{stroke:#333333;}#mermaid-svg-cBN7XXwegdZ5i5OU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-cBN7XXwegdZ5i5OU p{margin:0;}#mermaid-svg-cBN7XXwegdZ5i5OU .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-cBN7XXwegdZ5i5OU text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-cBN7XXwegdZ5i5OU .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-cBN7XXwegdZ5i5OU .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-cBN7XXwegdZ5i5OU .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-cBN7XXwegdZ5i5OU .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-cBN7XXwegdZ5i5OU #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-cBN7XXwegdZ5i5OU .sequenceNumber{fill:white;}#mermaid-svg-cBN7XXwegdZ5i5OU #sequencenumber{fill:#333;}#mermaid-svg-cBN7XXwegdZ5i5OU #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-cBN7XXwegdZ5i5OU .messageText{fill:#333;stroke:none;}#mermaid-svg-cBN7XXwegdZ5i5OU .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-cBN7XXwegdZ5i5OU .labelText,#mermaid-svg-cBN7XXwegdZ5i5OU .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-cBN7XXwegdZ5i5OU .loopText,#mermaid-svg-cBN7XXwegdZ5i5OU .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-cBN7XXwegdZ5i5OU .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-cBN7XXwegdZ5i5OU .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-cBN7XXwegdZ5i5OU .noteText,#mermaid-svg-cBN7XXwegdZ5i5OU .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-cBN7XXwegdZ5i5OU .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-cBN7XXwegdZ5i5OU .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-cBN7XXwegdZ5i5OU .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-cBN7XXwegdZ5i5OU .actorPopupMenu{position:absolute;}#mermaid-svg-cBN7XXwegdZ5i5OU .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-cBN7XXwegdZ5i5OU .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-cBN7XXwegdZ5i5OU .actor-man circle,#mermaid-svg-cBN7XXwegdZ5i5OU line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-cBN7XXwegdZ5i5OU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 正常 抛错 请求 next() 响应 进入 err 中间件 500
| 规则 | 说明 |
|---|---|
未 res.end / send |
必须 next(),否则请求挂起 |
| 错误中间件 | (err, req, res, next) 四个参数,挂在所有路由之后 |
| 路由回调 | 本身就是中间件,可写多个回调 + next() |
11.3 访问日志中间件(精简可运行)
javascript
const express = require('express');
const moment = require('moment');
const fs = require('fs');
const path = require('path');
const accessLog = (req, res, next) => {
const ip = (req.ip || '').replace('::ffff:', '');
const line = `${ip} ${moment().format('YYYY-MM-DD HH:mm:ss')} ${req.method} ${req.url}\n`;
fs.appendFile(path.join(__dirname, 'logs/access.log'), line, err => {
if (err) throw err;
next();
});
};
const app = express();
app.use(accessLog);
app.get('/', (req, res) => res.send('<h1>OK</h1>'));
app.listen(8080);
【代码注释】
accessLog在app.use最前,对所有请求记录 IP、时间、方法、URL。appendFile回调里if (err) throw err会交给四参数错误中间件;也可next(err)。- 成功写入后再
next(),避免日志丢失仍进入业务路由;高并发可改 Winston 或消息队列。 req.ip.replace('::ffff:', '')与课堂slice(7)目的一致,处理 IPv6 映射地址。
11.4 路由模块化挂载
javascript
const indexRouter = require('./routes/index');
const loginRouter = require('./routes/login');
app.use(indexRouter);
app.use('/login', loginRouter);
【代码注释】
app.use(indexRouter):模块内get('/')→ 浏览器访问/;get('/index')→/index。app.use('/login', loginRouter):模块内get('/')→ 对外GET /login(部分环境尾随斜杠/login/亦可达)。- 表单
action="/login"须与挂载前缀一致;POST /login由loginRouter.post('/')处理。 - 主文件先
use全局中间件,再挂路由,最后错误处理。
11.5 记账本六步实施清单
| 步 | 任务 | 关键 API |
|---|---|---|
| 1 | express --view=ejs + npm install |
生成器 |
| 2 | 设计路由表 | GET/POST 见 §6.4 |
| 3 | views/account/*.ejs + public 静态资源 |
res.render |
| 4 | POST 添加 | shortid + db.get('accounts').unshift().write() |
| 5 | GET 列表 | db.get('accounts').value() + EJS 遍历 |
| 6 | GET 删除 | /delete/:id + remove({id}) |
account 路由核心(与课堂一致):
javascript
const express = require('express');
const path = require('path');
const low = require('lowdb');
const FileSync = require('lowdb/adapters/FileSync');
const shortid = require('shortid');
const router = express.Router();
const db = low(new FileSync(path.join(__dirname, '../dbs/db.json')));
router.get('/', (req, res) => {
res.render('account/index', { data: db.get('accounts').value() });
});
router.get('/create', (req, res) => res.render('account/create'));
router.post('/create', (req, res) => {
const id = shortid.generate();
db.get('accounts').unshift({ id, ...req.body }).write();
res.render('account/success', { title: '添加成功', url: '/account' });
});
router.get('/delete/:id', (req, res) => {
db.get('accounts').remove({ id: req.params.id }).write();
res.render('account/success', { title: '删除成功', url: '/account' });
});
module.exports = router;
【代码注释】
- 挂载
app.use('/account', router)后,上表路由对应/account、/account/create、/account/delete/:id。 req.body依赖app.use(express.urlencoded({ extended: true }))(生成器已配置)。shortid.generate()生成短 id;...req.body展开表单字段进对象,键名与<input name="...">一致。- 每次
unshift/remove后.write()同步dbs/db.json;列表页读.value()渲染 EJS。 - 成功页
res.render('account/success', { title, url })提供返回列表链接。
11.6 可运行 HTML:记账表单(对接 POST)
保存为 create-account.html,action 指向已启动的 Express 服务(需已配置 urlencoded 解析):
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>添加账单</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@3.4.1/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="container" style="margin-top:40px;max-width:480px;">
<h1>添加账单</h1>
<form method="post" action="http://127.0.0.1:3000/account/create">
<div class="form-group">
<label>标题</label>
<input class="form-control" name="title" required>
</div>
<div class="form-group">
<label>时间</label>
<input class="form-control" type="date" name="time" required>
</div>
<div class="form-group">
<label>类型</label>
<select class="form-control" name="type">
<option value="1">收入</option>
<option value="0">支出</option>
</select>
</div>
<div class="form-group">
<label>金额</label>
<input class="form-control" name="account" required>
</div>
<div class="form-group">
<label>备注</label>
<input class="form-control" name="remarks">
</div>
<button type="submit" class="btn btn-primary">提交</button>
</form>
<p class="text-muted" style="margin-top:16px;">
也可直接访问服务端渲染页:<a href="http://127.0.0.1:3000/account/create">/account/create</a>
</p>
</body>
</html>
【代码注释】
- 表单
name属性(title、time、type、account、remarks)须与路由...req.body及db.json字段一致。 action端口与npm start监听一致(常见 3000);跨机测试将127.0.0.1改为本机 IP。- 独立 HTML 文件 POST 到 Express 无跨域问题;若用 AJAX 跨端口需 CORS。
- 推荐优先使用服务端
/account/create渲染的 EJS 表单,样式与校验更完整。
11.7 常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
| 请求一直转圈 | 未调用 next() |
中间件末尾 next() |
| 错误中间件不生效 | 只有 3 个参数 | 必须 (err, req, res, next) |
req.body 为空 |
未挂解析中间件 | express.urlencoded |
| 模板找不到 | views 路径错误 |
app.set('views', ...) |
直接 node app.js 异常 |
生成器项目 | 使用 npm start |
| async 路由报错不走错误中间件 | Promise 拒绝未 next(err) |
用 asyncHandler 包装或 try/catch |
| Flash 消息不显示 | 未配置 Session 或未注入 res.locals |
Session 在 flash 之前挂载 |
| 删除/修改后刷新重复提交 | 未用 PRG 模式 | POST 后 res.redirect 而非 res.render |
11.8 async 路由错误处理速查
Express 4.x 不自动捕获 async 函数抛出的错误,必须手动处理,否则错误会变成未捕获的 Promise 拒绝。
方式一:每个路由 try/catch(繁琐但直观)
javascript
router.get('/', async (req, res, next) => {
try {
const data = await fetchData(); // 可能抛错的异步操作
res.render('index', { data });
} catch (err) {
next(err); // 转交给四参数错误中间件
}
});
方式二:asyncHandler 包装器(推荐,消除重复 try/catch)
javascript
// utils/asyncHandler.js
// 包装 async 路由函数,自动将 Promise 拒绝转为 next(err)
const asyncHandler = fn => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
module.exports = asyncHandler;
// 使用:路由完全无需 try/catch
const asyncHandler = require('../utils/asyncHandler');
router.get('/', asyncHandler(async (req, res) => {
const data = await fetchData(); // 抛错自动进入错误中间件
res.render('index', { data });
}));
router.post('/create', asyncHandler(async (req, res) => {
const id = shortid.generate();
await db.get('accounts').unshift({ id, ...req.body }).write();
req.flash('success', '添加成功');
res.redirect('/account');
}));
【代码注释】
Promise.resolve(fn(...)).catch(next)原理:把 async 函数返回的 Promise 的 rejected 状态转给next(err),从而进入四参数错误中间件。- Express 5.x(目前 beta)已原生支持 async 路由自动捕获,但课堂 Express 4.x 仍需此模式。
asyncHandler本身是高阶函数,接受fn返回新的中间件函数,符合"职责单一"原则。- 记账本路由加上此模式后,
LowDB写盘异常、shortid异常都能被统一的catchError中间件捕获并记录日志。
错误处理完整链路示意:
async 路由抛错
↓
asyncHandler.catch(next)
↓ next(err)
四参数错误中间件 (err, req, res, next)
↓
写 error.log + res.status(500).render('error')
附录:快速参考指南
常用命令速查:
bash
# 项目初始化
npm init -y
npm install express
# 项目生成器
npm install -g express-generator
express --view=ejs myapp
# 启动项目
npm install
npm start
# 开发模式
DEBUG=myapp:* npm start
常用中间件清单:
- body-parser:请求体解析
- cookie-parser:Cookie解析
- express-session:会话管理
- morgan:日志记录
- helmet:安全头设置
- cors:跨域处理
- compression:响应压缩
调试技巧:
javascript
// 启用调试模式
DEBUG=express:* node app.js
// 使用console.table查看数据
console.table(db.get('accounts').value());
// 使用debug模块
const debug = require('debug')('app:server');
debug('Server started on port 3000');
性能监控:
javascript
// 简单的响应时间监控
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.url} - ${res.statusCode} - ${duration}ms`);
});
next();
});
希望这份完整的Express框架指南能够帮助你在Web开发的道路上走得更远!
版权声明: 本文内容仅供学习交流使用,请勿用于商业用途。
更新日志:
- 初始版本:2024年
- 包含完整的Express框架知识体系
- 实战项目案例详解
- 最佳实践和性能优化指导
反馈与建议: 如有问题或建议,欢迎交流讨论。
相关技术: Node.js、Express.js、EJS、LowDB、RESTful API、MVC架构、微服务
适合人群:
- Node.js初学者
- Express框架学习者
- 全栈开发者
- 后端工程师
- Web开发爱好者
学习时间: 建议学习周期2-4周,结合实际项目练习。
先决条件:
- JavaScript基础
- Node.js基础知识
- HTTP协议了解
- 前端开发基础
后续学习:
- Koa.js框架
- Nest.js框架
- TypeScript集成
- GraphQL API
- Serverless架构
感谢您的阅读!祝学习愉快!🚀
初级阶段:
- 掌握Node.js基础知识
- 理解HTTP协议基本概念
- 学习Express基本用法
- 实现简单的CRUD应用
中级阶段:
- 深入理解中间件机制
- 掌握模板引擎使用
- 学习数据库集成
- 实现用户认证系统
高级阶段:
- 学习架构设计模式
- 掌握性能优化技巧
- 了解安全最佳实践
- 学习微服务架构
8.3 常见问题解决
问题1:跨域问题
javascript
const cors = require('cors');
app.use(cors()); // 允许所有来源的跨域请求
// 或配置特定来源
app.use(cors({
origin: 'https://example.com',
methods: ['GET', 'POST'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// 【代码注释】CORS配置,解决跨域问题
问题2:文件上传
javascript
const multer = require('multer');
const upload = multer({ dest: 'uploads/' });
app.post('/upload', upload.single('file'), (req, res) => {
res.json({ file: req.file });
});
// 【代码注释】使用multer处理文件上传
问题3:Session管理
javascript
const session = require('express-session');
app.use(session({
secret: 'secret-key',
resave: false,
saveUninitialized: true,
cookie: { secure: false }
}));
// 【代码注释】配置Session管理
8.4 进阶学习资源
推荐资源:
-
官方文档
- Express.js官方文档
- Node.js官方文档
- MDN Web文档
-
经典项目
- 实战项目开发
- 开源项目贡献
- 代码重构练习
-
技术社区
- Stack Overflow
- GitHub
- 掘金/CSDN
8.5 实战项目建议
项目难度递进:
-
个人博客系统
- 文章发布和编辑
- 评论系统
- 标签分类
-
在线商城
- 商品管理
- 购物车功能
- 订单系统
-
社交平台
- 用户关系
- 内容分享
- 实时通讯
-
企业管理系统
- 权限管理
- 数据统计
- 报表生成
结语
Express作为Node.js生态系统中最流行的Web框架,为开发者提供了强大而灵活的工具来构建各种类型的Web应用。通过本文的学习,你应该掌握了:
- Express中间件机制的核心原理
- 路由模块化的最佳实践
- 模板引擎的使用技巧
- 完整的项目开发流程
- 性能优化和安全加固方法
持续学习建议:
- 关注Express官方更新
- 参与开源项目
- 实践新技术
- 分享学习经验
Web开发技术更新迅速,保持学习的热情和持续实践的能力是成为一名优秀开发者的关键。希望本文能为你的Express学习之旅提供坚实的基础。
下一步行动:
- 选择一个感兴趣的项目主题
- 应用所学知识开发完整应用
- 部署到云服务器
- 收集用户反馈并持续改进
祝你在Web开发的道路上越走越远!
参考资料:
- Express.js官方文档
- Node.js官方文档
- EJS模板引擎文档
- LowDB轻量级数据库
- MDN Web开发文档
相关文章:
- Node.js异步编程深度解析
- RESTful API设计最佳实践
- Web应用安全加固指南
- 前后端分离架构设计