掌握 Express 框架:从零到 MVC 博客系统

摘要 :使用原生 http 模块构建复杂应用太过繁琐,路由、静态资源、中间件等都需要手动处理。Express 是 Node.js 最流行的 Web 框架,以其极简、灵活著称。本文将带你一步一步 掌握 Express 的项目搭建、路由、中间件机制、模板引擎和错误处理,最后搭建一个功能完备的 MVC 示例博客系统。


一、为什么需要 Express?

在上一篇原生 Node.js 教程中,我们看到原生方式写路由和静态文件非常啰嗦。例如,原生处理不同 URL 需要大量 if-else 判断,设置 MIME 类型、处理静态文件也很麻烦。

Express 解决的核心问题:

原生 Node.js 痛点 Express 解决方案
手动解析请求 URL 和方法 提供 app.get()、app.post() 等直观路由
难以管理多个中间件(日志、解析 body、权限) 中间件链式调用,use 任意扩展
静态文件服务需自己读取文件并设置响应头 express.static() 一行搞定
模板引擎集成繁琐 app.set('view engine', 'ejs') 一行配置
错误处理不统一 统一错误处理中间件

简言之:Express 让你专注于业务逻辑,而不是 HTTP 协议的细节。


二、创建第一个 Express 应用

2.1 环境准备

确保已安装 Node.js(建议 v14 以上)。

2.2 新建项目

打开终端,依次执行:

javascript 复制代码
mkdir express-demo
cd express-demo
npm init -y          # 快速生成 package.json
npm install express

2.3 编写入口文件 app.js

javascript 复制代码
// 1. 引入 express
const express = require('express');
// 2. 创建应用实例
const app = express();
// 3. 定义端口
const port = 3000;
// 4. 定义路由:当用户 GET 访问根路径 '/' 时
app.get('/', (req, res) => {
  // res.send 自动处理 Content-Type 和状态码
  res.send('<h1>Hello Express!</h1>');
});
// 5. 启动服务器
app.listen(port, () => {
  console.log(`Express 服务器启动在 http://localhost:${port}`);
});

2.4 运行与验证

javascript 复制代码
node app.js

打开浏览器访问 http://localhost:3000,看到大标题即成功。

核心理解 :app 对象是一个由中间件 和路由构成的请求处理管道。每个请求按顺序流经这些函数,直到有人发送响应。


三、路由详解

路由是指根据 HTTP 方法和 URL 路径,映射到对应的处理函数。

3.1 基本路由方法

javascript 复制代码
// GET 请求 ------ 获取资源
app.get('/users', (req, res) => {
  res.json([{ name: 'Alice' }, { name: 'Bob' }]);
});
​
// POST 请求 ------ 创建资源
app.post('/users', (req, res) => {
  // 201 Created 是创建成功的标准状态码
  res.status(201).send('用户已创建');
});
​
// PUT 请求 ------ 完全替换资源
app.put('/users/:id', (req, res) => {
  // :id 是路由参数,下面会解释
  res.send(`更新用户 ${req.params.id}`);
});
​
// DELETE 请求 ------ 删除资源
app.delete('/users/:id', (req, res) => {
  res.send(`删除用户 ${req.params.id}`);
});

3.2 路由路径语法

Express 支持字符串模式、正则表达式、以及参数占位符。

javascript 复制代码
// 路由参数 (params) ------ 从 URL 中提取
app.get('/articles/:category/:id', (req, res) => {
  console.log(req.params);   // 例如:{ category: 'tech', id: '42' }
  
  // 查询参数 (query) ------ 从 ? 后面提取
  console.log(req.query);    // 例如:访问 /articles/tech/42?page=2&sort=desc
                             // 输出:{ page: '2', sort: 'desc' }
  res.send('文章信息');
});

路径匹配示例:

路径模式 匹配示例 说明
/about /about 完全匹配
/ab?cd /acd, /abcd ? 表示前面字符可选
/ab*cd /ab123cd, /abxyzcd * 表示任意字符
/user/:uid /user/5 :uid 作为参数保存在 req.params.uid

3.3 模块化路由 ------ Express.Router

当应用越来越大,把所有路由写在 app.js 会非常混乱。使用 Router 可以把相关路由组织成独立模块。

步骤1:创建 routes/users.js
javascript 复制代码
const express = require('express');
const router = express.Router();
// 所有路径都会自动加上前缀 /users
router.get('/', (req, res) => {
  res.send('用户列表');
});
router.get('/:id', (req, res) => {
  res.send(`用户ID: ${req.params.id}`);
});
router.post('/', (req, res) => {
  res.status(201).send('创建用户');
});
module.exports = router;
步骤2:在 app.js 中挂载
javascript 复制代码
const usersRouter = require('./routes/users');
// 第一个参数 '/users' 是挂载点,即路由前缀
app.use('/users', usersRouter);

重新运行app,然后访问 /users 会触发列表路由,访问 /users/123 会触发详情路由。


四、中间件:Express 的精髓

中间件函数 的签名是 function(req, res, next)。它可以:

  • 执行任意代码

  • 修改 req 或 res 对象

  • 结束请求-响应周期(调用 res.send() 等)

  • 调用 next() 将控制权传递给下一个中间件

如果不调用 next() 也不发送响应,请求会挂起!

4.1 应用级中间件

通过 app.use() 或 app.METHOD() 挂载。

4.1.1 内置中间件 express.static
javascript 复制代码
// 假设项目根目录下有一个 public 文件夹,里面放 style.css, logo.png 等
app.use(express.static('public'));
​
// 现在可以通过 http://localhost:3000/style.css 直接访问 public/style.css

多个静态目录可以多次调用 app.use(express.static(...)),按顺序查找。

4.1.2 自定义日志中间件
javascript 复制代码
// 这个中间件会记录每个请求的方法、URL 和时间
app.use((req, res, next) => {
  console.log(`${req.method} ${req.url} - ${new Date().toISOString()}`);
  next();   // 必须调用 next(),否则后续路由不会执行
});
app.get('/', (req, res) => {
  res.send('主页');
});

每访问一次主页,终端会输出类似 GET / - 2026-01-15T10:30:00.000Z。

4.1.3 身份验证中间件(示例)
javascript 复制代码
// 一个简单的 token 验证中间件
function auth(req, res, next) {
  // 假设要求请求头包含 Authorization: Bearer mysecrettoken
  if (req.headers.authorization === 'Bearer mysecrettoken') {
    next();   // 验证通过,继续
  } else {
    res.status(401).send('未授权');
  }
}
// 只对 /admin 路径应用此中间件
app.get('/admin', auth, (req, res) => {
  res.send('管理页面');
});

4.2 中间件执行顺序

中间件注册的顺序决定了执行顺序。例如:

javascript 复制代码
app.use((req, res, next) => {
  console.log('A');
  next();
  console.log('A 结束');
});
app.use((req, res, next) => {
  console.log('B');
  next();
  console.log('B 结束');
});
app.get('/', (req, res) => {
  console.log('路由处理');
  res.send('ok');
});

访问 / 时,控制台输出顺序:

复制代码

说明 next() 之后的代码会在后续中间件执行完后才执行(类似"洋葱模型")。

4.3 常用第三方中间件

安装:

javascript 复制代码
npm install morgan cors helmet

在 app.js 中引入并使用:

javascript 复制代码
const morgan = require('morgan');   // 日志
const cors = require('cors');       // 跨域
const helmet = require('helmet');   // 安全头
// 使用 morgan 的 dev 格式(简洁彩色日志)
app.use(morgan('dev'));
// 允许所有跨域请求(开发环境常用)
app.use(cors());
// 自动设置 X-Content-Type-Options, X-Frame-Options 等安全头
app.use(helmet());

4.4 解析请求体中间件

Express 内置了以下两个中间件来解析 POST/PUT 等请求中的数据:

javascript 复制代码
// 解析 application/x-www-form-urlencoded (传统表单提交)
app.use(express.urlencoded({ extended: true }));
// 解析 application/json (fetch/Axios 提交的 JSON)
app.use(express.json());

使用示例:

javascript 复制代码
app.post('/submit', (req, res) => {
  console.log(req.body);   // 表单字段或 JSON 对象
  res.send('收到数据');
});

extended: true 允许解析嵌套对象;false 则只支持简单键值对。


五、模板引擎与 MVC 结构

5.1 为什么需要模板引擎?

当需要动态生成 HTML 页面(比如显示数据库里的文章列表)时,直接拼接字符串很痛苦。模板引擎允许你在 HTML 中嵌入代码,并传入变量。

Express 支持 Pug、EJS、Handlebars 等。本文选择 EJS,因为它语法接近原生 HTML,学习成本低。

5.2 安装和配置 EJS

javascript 复制代码
npm install ejs

在 app.js 中添加:

javascript 复制代码
const path = require('path');
// 设置模板存放目录(默认是 views 文件夹)
app.set('views', path.join(__dirname, 'views'));
// 设置模板引擎为 ejs
app.set('view engine', 'ejs');

5.3 创建第一个模板

新建文件夹 views,在里面创建 index.ejs:

javascript 复制代码
<!DOCTYPE html>
<html>
<head>
  <title><%= title %></title>
  <link rel="stylesheet" href="/style.css">
</head>
<body>
  <h1><%= heading %></h1>
  <ul>
    <% items.forEach(item => { %>
      <li><%= item %></li>
    <% }); %>
  </ul>
</body>
</html>

<%= variable %> 会输出转义后的变量值 <% code %> 可以执行 JavaScript 代码,但不输出到页面

5.4 在路由中渲染模板

javascript 复制代码
app.get('/', (req, res) => {
  res.render('index', {      // 对应 views/index.ejs
    title: '我的网站',
    heading: '欢迎光临',
    items: ['Node.js', 'Express', 'EJS']
  });
});

访问 http://localhost:3000 即可看到动态生成的 HTML。

5.5 MVC 架构组织

MVC 将应用分为三层:

  • Model:数据和数据操作(如读取数据库、内存存储)

  • View:用户界面(EJS 模板)

  • Controller:业务逻辑,连接 Model 和 View

Express 本身不强约束,但我们可以按以下结构组织文件夹:

javascript 复制代码
blog-project/
├── models/          # 数据模型
├── views/           # EJS 模板
├── controllers/     # 控制器
├── routes/          # 路由定义
├── public/          # 静态资源 (CSS, JS, 图片)
├── app.js           # 应用入口
└── package.json

六、错误处理中间件

6.1 同步错误的捕获

Express 会自动捕获同步代码中的 throw,并传递给错误处理中间件。

javascript 复制代码
app.get('/error', (req, res) => {
  throw new Error('出错了!');
});

6.2 定义错误处理中间件

错误处理中间件有 4 个参数 ,必须声明 err 作为第一个参数。

javascript 复制代码
// 放在所有路由之后
// 处理 404:没有匹配任何路由
app.use((req, res, next) => {
  res.status(404).send('抱歉,页面未找到');
});
​
// 全局错误处理(捕获所有未被处理的错误)
app.use((err, req, res, next) => {
  console.error(err.stack);   // 打印错误栈
  res.status(500).send('服务器内部错误');
});

6.3 异步错误的处理

Express 4.x 不能 自动捕获异步函数(如 setTimeout、Promise、async/await)中抛出的错误。你必须手动调用 next(err)。

错误示例(错误不会被捕获):

javascript 复制代码
app.get('/async-bad', async (req, res) => {
  const data = await someAsyncFunction(); // 如果这里 reject,Express 无法捕获
  res.json(data);
});

正确方式1:使用 next

javascript 复制代码
app.get('/async-ok', async (req, res, next) => {
  try {
    const data = await someAsyncFunction();
    res.json(data);
  } catch (err) {
    next(err);   // 传递给错误处理中间件
  }
});

正确方式2:使用 express-async-errors 包

javascript 复制代码
npm install express-async-errors

在 app.js 最顶部(所有路由之前)引入:

javascript 复制代码
require('express-async-errors');

之后你就可以直接写 async 路由,任何 reject 或 throw 会自动进入错误处理中间件。

正确方式3:手动包装函数

javascript 复制代码
function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}
​
app.get('/data', asyncHandler(async (req, res) => {
  const data = await someAsyncFunction();
  res.json(data);
}));

七、完整实战:简易博客系统(MVC)

我们将构建一个功能完备的博客系统,包括:

  • 文章列表页

  • 文章详情页

  • 创建新文章(含表单)

  • 删除文章

暂不涉及真实数据库,使用内存数组存储。

7.1 初始化项目结构

javascript 复制代码
mkdir express-blog
cd express-blog
npm init -y
npm install express ejs

创建以下文件夹和文件(手动或使用命令):

javascript 复制代码
express-blog/
├── models/
│   └── post.js
├── views/
│   ├── posts.ejs
│   ├── post.ejs
│   └── create.ejs
├── controllers/
│   └── postController.js
├── routes/
│   └── post.js
├── public/               (空文件夹,留待放置 CSS)
├── app.js
└── package.json

7.2 编写模型 (Model) ------ models/post.js

模型负责数据存储和操作。这里用数组模拟。

javascript 复制代码
// 内存存储
let posts = [];
let idCounter = 1;
// 辅助函数:确保 id 为数字(用于比较)
function toNumber(id) {
  return typeof id === 'number' ? id : parseInt(id, 10);
}
module.exports = {
  // 获取所有文章(按创建时间倒序)
  getAll: () => [...posts].reverse(),
  // 根据 id 获取单篇文章
  getById: (id) => posts.find(p => p.id === toNumber(id)),
  // 创建新文章
  create: (title, content) => {
    const post = {
      id: idCounter++,
      title: title.trim(),
      content: content.trim(),
      createdAt: new Date()
    };
    posts.push(post);
    return post;
  },
  // 删除文章
  delete: (id) => {
    const numericId = toNumber(id);
    const index = posts.findIndex(p => p.id === numericId);
    if (index !== -1) {
      posts.splice(index, 1);
      return true;
    }
    return false;
  }
};

7.3 编写控制器 (Controller) ------ controllers/postController.js

控制器负责处理请求、调用模型、决定渲染哪个视图。

javascript 复制代码
const Post = require('../models/post');
// 显示所有文章
exports.list = (req, res) => {
  const posts = Post.getAll();
  res.render('posts', { posts });
};
// 显示单篇文章详情
exports.show = (req, res) => {
  const id = parseInt(req.params.id, 10);
  const post = Post.getById(id);
  if (!post) {
    return res.status(404).send('文章不存在');
  }
  res.render('post', { post });
};
// 显示创建新文章的表单
exports.createForm = (req, res) => {
  res.render('create');
};
// 处理创建文章的 POST 请求
exports.create = (req, res) => {
  const { title, content } = req.body;
  if (!title || !content) {
    return res.status(400).send('标题和内容不能为空');
  }
  Post.create(title, content);
  res.redirect('/posts');   // 重定向到列表页
};
// 处理删除文章(通过 POST 请求,避免 GET 被爬虫误删)
exports.delete = (req, res) => {
  const id = parseInt(req.params.id, 10);
  Post.delete(id);
  res.redirect('/posts');
};

7.4 编写视图 (View) ------ EJS 模板

7.4.1 基础布局(可选)------ views/partials/header.ejs和footer.ejs

为了不重复写 HTML 结构,可以使用 EJS 的 <%- include() %>。我们创建两个布局文件:

html 复制代码
<!-- views/partials/header.ejs -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>简易博客 - <%= title %></title>
  <style>
    body { font-family: Arial; margin: 40px; line-height: 1.5; }
    .post { border-bottom: 1px solid #ccc; margin-bottom: 20px; }
    .post h2 { margin-bottom: 5px; }
    .actions { margin-top: 10px; }
    button { background: #c00; color: white; border: none; padding: 5px 10px; cursor: pointer; }
    form { display: inline; }
  </style>
</head>
<body>
  <h1><a href="/posts" style="text-decoration: none;">📝 我的博客</a></h1>
html 复制代码
<!-- views/partials/footer.ejs -->
</body>
</html>
7.4.2 文章列表页 ------ views/posts.ejs
javascript 复制代码
<% const title = '文章列表'; %>
<%- include('partials/header', { title: title }) %>
<a href="/posts/new">➕ 写新文章</a>
<% if (posts.length === 0) { %>
  <p>还没有任何文章,点击上方按钮创建第一篇!</p>
<% } else { %>
  <% posts.forEach(post => { %>
    <div class="post">
      <h2><a href="/posts/<%= post.id %>"><%= post.title %></a></h2>
      <small>发布于: <%= post.createdAt.toLocaleString() %></small>
      <div class="actions">
        <form method="POST" action="/posts/<%= post.id %>/delete" onsubmit="return confirm('确定删除?')">
          <button type="submit">删除</button>
        </form>
      </div>
    </div>
  <% }); %>
<% } %>
<%- include('partials/footer') %>
7.4.3 文章详情页 ------ views/post.ejs
javascript 复制代码
<% const title = post.title; %>
<%- include('partials/header', { title: title }) %>
  <div>
    <h2><%= post.title %></h2>
    <small><%= post.createdAt.toLocaleString() %></small>
    <div style="margin-top: 20px;"><%= post.content.replace(/\n/g, '<br>') %></div>
    <p><a href="/posts">← 返回列表</a></p>
  </div>
<%- include('partials/footer') %>
7.4.4 创建文章表单页 ------ views/create.ejs
javascript 复制代码
<% const title = '写新文章'; %>
<%- include('partials/header', { title: title }) %>
  <form method="POST" action="/posts">
    <div>
      <label>标题</label><br>
      <input type="text" name="title" style="width: 100%; padding: 8px;" required>
    </div>
    <div style="margin-top: 10px;">
      <label>内容</label><br>
      <textarea name="content" rows="10" style="width: 100%;" required></textarea>
    </div>
    <div style="margin-top: 10px;">
      <button type="submit">发布文章</button>
      <a href="/posts">取消</a>
    </div>
  </form>
<%- include('partials/footer') %>

7.5 编写路由 ------ routes/post.js

javascript 复制代码
const router = require('express').Router();
const controller = require('../controllers/postController');
// 注意顺序:具体的路径 (如 /new) 要放在动态路径 (/:id) 前面,否则 /new 会被当作 id 处理
router.get('/', controller.list);          // GET  /posts
router.get('/new', controller.createForm); // GET  /posts/new
router.get('/:id', controller.show);       // GET  /posts/123
router.post('/', controller.create);       // POST /posts
router.post('/:id/delete', controller.delete); // POST /posts/123/delete
module.exports = router;

7.6 入口文件 app.js

javascript 复制代码
const express = require('express');
const path = require('path');
const app = express();
const port = 3000;
// 1. 模板引擎配置
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// 2. 中间件配置
app.use(express.static('public'));          // 静态资源
app.use(express.urlencoded({ extended: true })); // 解析表单
app.use(express.json());                    // 解析 JSON(虽未用到但保留)
// 3. 挂载博客路由
const postRoutes = require('./routes/post');
app.use('/posts', postRoutes);
// 4. 根路径重定向到 /posts
app.get('/', (req, res) => {
  res.redirect('/posts');
});
// 5. 404 处理(放在所有路由之后)
app.use((req, res) => {
  res.status(404).send('404 - 页面未找到');
});
// 6. 全局错误处理(放在最后)
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).send('500 - 服务器内部错误');
});
// 7. 启动服务器
app.listen(port, () => {
  console.log(`博客系统运行在 http://localhost:${port}`);
});

7.7 运行并测试

javascript 复制代码
node app.js
  • 访问 http://localhost:3000 自动跳转到文章列表。
  • 点击「写新文章」,填写标题和内容后提交,会看到新文章出现在列表。
  • 点击标题进入详情页,点击删除按钮可删除。

恭喜!你已经拥有一个完全基于 MVC 模式、具备增删查功能的博客系统原型。

7.8 进一步练习

  • 添加编辑文章功能(提示:使用 PUT 或 POST 路由,再创建一个 edit.ejs 表单)

  • 将内存存储换成真实的数据库(如 SQLite、MongoDB)

  • 添加用户认证(使用 express-session 和 bcrypt)


八、总结

通过这篇超详细教程,你已经掌握了:

Express 的安装与第一个应用

路由系统:各种 HTTP 方法、参数、查询、Router 模块化

中间件机制:内置、自定义、第三方、执行顺序、请求体解析

模板引擎 EJS:动态渲染 HTML,并结合 MVC 架构

错误处理:同步/异步错误、404 和 500 中间件

完整项目:从零构建一个可运行的博客系统(增、查、删)

Express 让开发者专注于业务逻辑而非 HTTP 细节。你现在已经具备开发中型 Web 应用的能力。


如果这篇文章帮你解决了实操上的困惑,别忘记点击点赞、分享 ,也可以留言告诉我你遇到的其它问题,我会尽快回复。动手练习是掌握编程最快的方法,请务必亲手敲一遍本文的所有示例代码,并截图保存你的成果。你的关注是我坚持原创和细节共享的力量来源,谢谢大家。

相关推荐
濮水大叔6 小时前
NestJS Dynamic Providers 与 Cabloy/Vona Provider Scene:同接口多实现的两种装配哲学
typescript·node.js·nestjs
Wx-bishekaifayuan10 小时前
springboot桂味美食分享系统40540-计算机课程设计、毕业设计
spring boot·后端·python·django·课程设计·express·旅游
silvamux_13 小时前
Node.js 调用大模型 API 排错实录
node.js
Amumu1213817 小时前
NodeJs介绍
前端·node.js
孟陬17 小时前
利用 TypeScript `asserts` function 语法定制一个类型缩窄的 `assertIncludes`
typescript·node.js
damoluomu17 小时前
静态站点生成器怎么选:Node.js 生态里常用的五个(番外一)
javascript·css·typescript·node.js·html
濮水大叔18 小时前
Cabloy/Vona 如何避开 NestJS 的循环依赖问题:懒属性注入机制源码解析
typescript·node.js·nestjs
月走乂山1 天前
DSH 桌面版用 DeepSeek 报 REQUEST_EXTENSION 的根因定位与修复
electron·node.js·插件系统·deepseek·bug排查·dsh·deepseek harness
斯晨的AI笔记2 天前
在服务器上跑 npm ci 四十分钟没装完,我打包把 node_modules 扔了过去
服务器·node.js
cpolar技术支持2 天前
第三方平台回调总说收不到?把本机 Webhook 收单页跑通,用 cpolar 固定二级子域名给对面看实时请求
node.js·内网穿透·webhook·cpolar·回调对接