在前端框架大行其道的今天,我们不妨回溯一下 Web 开发的“本源”——纯后端渲染(Server-Side Rendering, SSR) 。本文将带你用 Node.js 原生 http 模块实现一个符合 MVC 架构 的简单用户列表页面,并深入解析其中的关键知识点,如 CommonJS 与 ESM、数据驱动、HTTP 响应头、状态码等。
一、什么是 MVC?
MVC(Model-View-Controller)是一种经典的软件架构模式,用于分离关注点:
- Model(模型) :负责数据逻辑,比如数据库操作。
- View(视图) :负责展示界面,通常是 HTML 模板。
- Controller(控制器) :协调 Model 和 View,处理业务逻辑。
在纯后端渲染中,服务器直接生成完整的 HTML 页面返回给浏览器,无需前端 JavaScript 渲染。
二、代码实现:从零搭建一个 MVC 风格的用户列表服务
💡 为契合掘金排版习惯,以下代码均标注语言类型,并采用模块化讲解。
1. 目录结构(理想化 MVC 分离)
虽然示例中所有代码写在一个文件里(便于演示),但在真实项目中应分目录:
text
编辑
project/
├── models/
│ └── User.js // Model
├── views/
│ └── users.html // View(模板)
├── controllers/
│ └── userController.js // Controller
└── server.js // HTTP 伺服入口
但为简化,我们先看单文件版本。
2. 完整代码(server.js)
javascript
编辑
// server.js - 使用 CommonJS 模块系统(Node.js 默认)
const http = require('http');
const url = require('url');
// ===== Model: 模拟数据库数据 =====
const users = [
{ id: 1, name: '舒老板', email: 'shubo@163.com' },
{ id: 2, name: '陈老板', email: '6666666' },
{ id: 3, name: '王老板', email: 'wang@.com' },
];
// ===== View: 模板函数(数据驱动 HTML 生成)=====
function generateUsersHtml(users) {
const userRows = users.map(user => `
<tr>
<td>${user.id}</td>
<td>${user.name}</td>
<td>${user.email}</td>
</tr>
`).join('');
return `
User List
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
th { background-color: #f4f4f4; }
<h1>Users</h1>
<table>
<thead>
<tr><th>ID</th><th>Name</th><th>Email</th></tr>
</thead>
<tbody>
${userRows}
</tbody>
</table>
`;
}
// ===== Controller: 处理路由与业务逻辑 =====
function handleRequest(req, res) {
const parsedUrl = url.parse(req.url, true);
if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/users') {
// 设置响应头:状态码 + 内容类型
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
// 调用 Model 获取数据,传入 View 渲染
const html = generateUsersHtml(users);
res.end(html); // 发送完整 HTML 响应
} else {
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end('Not Found');
}
}
// ===== HTTP 伺服器启动 =====
const server = http.createServer(handleRequest);
server.listen(1234, () => {
console.log('✅ Server is listening at http://localhost:1234');
});
运行命令:
bash
编辑
node server.js
访问 http://localhost:1234 即可看到用户表格。
三、核心知识点详解
1. 服务器回调函数:http.createServer()
http.createServer() 接收一个回调函数,每当客户端发起 HTTP 请求时,该函数就会被调用。其签名如下:
javascript
编辑
http.createServer((req, res) => {
// 处理请求
});
-
req(IncomingMessage 对象) :代表客户端的请求,包含:req.url:完整请求路径(如/users?name=xxx)req.method:HTTP 方法(如GET、POST)req.headers:请求头信息- 请求体(需监听
data事件读取,适用于 POST 等)
-
res(ServerResponse 对象) :用于构建并发送响应,常用方法包括:res.statusCode = 200:设置 HTTP 状态码res.setHeader(key, value):设置响应头res.write(chunk):写入响应体片段(可多次调用)res.end([data]):必须调用,结束响应并可选发送最后一段数据
⚠️ 注意:即使你只用
res.end(html)一次性发送内容,也不能省略res.end(),否则连接不会关闭,浏览器会一直等待。
2. URL 解析:url.parse(req.url, true)
Node.js 内置的 url 模块可解析 URL 字符串:
javascript
编辑
const parsedUrl = url.parse(req.url, true);
-
第二个参数
true表示自动解析查询字符串(query string)为对象。 -
返回对象的核心属性:
pathname:路径部分(不包含查询参数)。
例如:http://localhost:1234/users?id=1→pathname = '/users'query:查询参数对象(仅当第二个参数为true时存在)。
例如:?name=舒老板&age=30→query = { name: '舒老板', age: '30' }
✅ 在路由判断中,我们通常只关心
pathname,而query可用于过滤、分页等动态逻辑。
3. 响应头(Content-Type)详解
Content-Type 响应头告诉浏览器如何解释响应体内容:
| 值 | 说明 |
|---|---|
text/html;charset=utf-8 | 按 HTML 文档解析,使用 UTF-8 编码(解决中文乱码的关键! ) |
text/plain;charset=utf-8 | 按纯文本显示,适合错误提示或简单消息 |
application/json | 返回 JSON 数据,常用于 API 接口 |
🌐 如果不指定
charset=utf-8,中文可能显示为乱码(如å˜è€…),因为浏览器默认可能使用 ISO-8859-1 或其他编码。
4. res.end() 的本质:不只是“返回给浏览器”
很多人误以为 res.end() 是“把内容直接返回给浏览器”,其实这是对底层机制的误解。
✅ 正确认知:
res.end() 并不是 “直接返回给浏览器”,而是 Node.js 的 http 模块中 ServerResponse 对象的核心方法,其本质是:
结束 HTTP 响应流程,并将响应数据(若有)通过 TCP 连接传输给客户端(浏览器是最常见的客户端,但也可以是 Postman、curl、其他微服务等)。
🔧 核心逻辑拆解:响应的传输路径
当 res.end() 被调用后,Node.js 会完成以下步骤:
-
组装 HTTP 响应头
- 若你未显式设置某些头(如
Content-Length),Node.js 会自动计算并补充。
- 若你未显式设置某些头(如
-
通过 TCP 连接发送数据
- 将完整的 HTTP 响应(响应头 + 响应体)通过已建立的 TCP 连接发送给客户端。
-
关闭响应流
- 标记该响应已完成,后续不能再调用
res.write()或res.end(),否则会抛出错误。
- 标记该响应已完成,后续不能再调用
💬 简单说:
res.end()是 “收尾并发送” 的动作。浏览器最终接收到的是经过 TCP/IP 协议栈传输的 HTTP 响应数据包,而不是res.end()直接“返回”了什么。
⚙️ res.end() 的关键特性
| 特性 | 说明 |
|---|---|
| 必须调用 | HTTP 响应必须通过 res.end() 结束,否则客户端会一直等待(直到超时) |
| 可选参数 | 签名:res.end([data[, encoding]][, callback]) • data:要发送的响应体(字符串或 Buffer) • encoding:字符串编码(默认 utf8) • callback:发送完成后的回调 |
与 Express 的 res.send() 区别 | • 原生 Node.js 只有 res.end(),需手动设 Content-Type、编码等 • Express 的 res.send() 是封装方法,会自动推断类型、设置头、计算长度,最终仍调用 res.end() 完成发送 |
📌 示例:原生 Node.js 中 res.end() 的作用
javascript
编辑
const http = require('http');
const server = http.createServer((req, res) => {
// 1. 设置响应头(可选,但推荐)
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
// 2. 通过 res.end() 发送数据并结束响应
res.end('Hello 浏览器!'); // 数据会被传输到客户端(浏览器)
});
server.listen(3000, () => {
console.log('服务运行在 http://localhost:3000');
});
此时浏览器访问 http://localhost:3000,会接收到:
-
响应头:
text 编辑 Content-Type: text/plain; charset=utf-8 Content-Length: 17 // 自动计算字节数(含中文) Date: ... Connection: keep-alive -
响应体:
text 编辑 Hello 浏览器!
🔍 注意:
Content-Length是 Node.js 自动根据res.end()的参数计算得出的,确保客户端知道何时接收完全部数据。
🧠 总结:res.end() 的真正角色
-
直接作用:结束 Node.js 的 HTTP 响应流,触发数据向客户端的传输。
-
数据接收方:可以是浏览器、
curl、Postman、其他服务等任意 HTTP 客户端。 -
传输方式:通过 TCP 连接将组装好的 HTTP 响应包发送出去,客户端(如浏览器)解析后展示内容。
-
通俗理解:
res.end()是 Node.js 告诉客户端:“响应已完成,这是最后一批数据,连接可以关闭了。”
浏览器只是这个过程中最常见的接收端,而非唯一目标。
5. CommonJS vs ESM(模块系统)
| 特性 | CommonJS(Node.js 传统) | ESM(ES6 标准) |
|---|---|---|
| 导入 | require('module') | import ... from 'module' |
| 导出 | module.exports = ... | export default ... |
| 加载时机 | 运行时同步加载 | 编译时静态分析 |
| Node 支持 | 默认支持 | 需 .mjs 后缀或 package.json 中设 "type": "module" |
✅ 建议:新项目优先使用 ESM,但理解 CommonJS 对阅读旧代码至关重要。
6. 数据驱动(Data-Driven Rendering)
- 视图(HTML)不是写死的,而是通过模板字符串动态插入数据。
generateUsersHtml(users)函数接收数据,返回完整 HTML。- 这是 SSR 的核心思想:数据 → 模板 → HTML 字符串 → 浏览器渲染。
🔍 对比现代前端:React/Vue 是“客户端数据驱动”,而这里是“服务端数据驱动”。
7. HTTP 响应关键要素
📌 状态码(Status Code)
200:成功404:资源未找到500:服务器内部错误
📌 响应头(Headers)
-
Content-Type:告诉浏览器如何解析响应体。text/html:HTML 文档application/json:JSON 数据text/plain:纯文本
⚠️ 忘记设置
charset=utf-8可能导致中文乱码!
8. http.createServer 与伺服状态
http.createServer(callback)创建一个 HTTP 服务器实例。server.listen(port, callback)启动监听,进入“伺服状态”(即等待请求)。- 每次有 HTTP 请求,都会触发回调函数
(req, res) => {...}。
🔄 这是一个事件驱动、非阻塞 I/O 的典型 Node.js 模型。
四、拓展思考:这种方案的优劣?
✅ 优点
- 首屏加载快:HTML 直接返回,无需等待 JS 执行。
- SEO 友好:搜索引擎可直接抓取完整内容。
- 简单直接:适合小型应用或内部工具。
❌ 缺点
- 交互性差:每次操作都要刷新页面(除非结合 AJAX)。
- 服务器压力大:每个请求都要重新渲染 HTML。
- 开发体验落后:相比现代前端框架,缺乏组件化、状态管理等能力。
💡 适用场景:管理后台、报表系统、低交互内容页。
五、总结要点(方便复习)
| 概念 | 说明 |
|---|---|
| MVC | Model(数据)、View(模板)、Controller(逻辑)分离 |
| 服务器回调函数 | http.createServer((req, res) => {...}),req 为请求对象,res 为响应对象 |
| URL 解析 | url.parse(req.url, true) 获取 pathname(路径)和 query(查询参数) |
| Content-Type | 必须正确设置,如 text/html;charset=utf-8 避免中文乱码 |
| res.end() 本质 | 结束 HTTP 响应流,触发 TCP 传输;不是直接“返回给浏览器” |
| res.end() 必须调用 | 否则客户端会一直等待,导致超时 |
| res.end() vs res.send() | 原生只有 res.end();Express 的 res.send() 是高级封装,底层仍调用 res.end() |
| CommonJS | Node.js 默认模块系统,使用 require/module.exports |
| 数据驱动 | 用 JavaScript 动态生成 HTML,而非静态文件 |
| HTTP 响应 | 必须正确设置 statusCode 和 Content-Type |
| 伺服状态 | server.listen() 启动后,服务器持续监听请求 |
| SSR 本质 | 服务端拼接 HTML 字符串,一次性返回给浏览器 |