掌握 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)。它可以:

  • 执行任意代码

  • 修改 reqres 对象

  • 结束请求-响应周期(调用 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 不能 自动捕获异步函数(如 setTimeoutPromiseasync/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 进一步练习

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

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

  • 添加用户认证(使用 express-sessionbcrypt


八、总结

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

Express 的安装与第一个应用

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

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

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

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

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

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


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

相关推荐
米丘2 小时前
HTTP 3xx 重定向类状态码
http·node.js
丑过三八线2 小时前
npm 私有仓库找不到包的解决方案
前端·npm·node.js
米丘4 小时前
HTTP 强缓存 和 协商缓存 (浏览器缓存)
http·node.js·浏览器
Geoking.8 小时前
SSH 一断 Node 服务就挂?排查与解决方案记录
运维·node.js·ssh
jike88ai8 小时前
Windows版Claude Code安装与API对接教程(附常见问题解决)
windows·gpt·node.js·claude·claudecode·88api
m0_535817559 小时前
Mac下Claude Code完整配置指南:API中转+环境变量设置一步到位
gpt·macos·node.js·api·claude·claudecode·88api
码农阿豪10 小时前
Node.js 连接金仓数据库踩坑记(上篇):环境搭建与基础操作
数据库·node.js
Hi~晴天大圣1 天前
npm使用介绍
前端·npm·node.js
m0_535817551 天前
macOS下Claude Code从0到1配置教程(附API密钥获取+常见报错修复)
gpt·macos·node.js·api·claude·claudecode·88api