在现代全栈开发的日常中,尤其是当我们着手构建大型 Web 应用或负责 C 端核心业务时,总会不可避免地撞上一座大山:首屏加载性能与 SEO 优化。
无论是早期的 jQuery 时代,还是如今由 React、Vue 主导的组件化时代,前端圈一直在围绕一个核心问题进行技术迭代:网页到底是谁组装的? 是用户的浏览器,还是远端的服务器?
本文将由浅入深,带你彻底厘清 CSR、SSR 的前世今生,探讨现代框架的“同构渲染”黑魔法,并最终从零手写一个基于 Vite + Express + React 的 SSR 服务,让你不仅知其然,更知其所以然。
一、 渲染模式的演进:CSR 与 SSR 的较量
要理解现代架构,我们必须先看懂历史。
1. CSR (Client Side Render) 客户端渲染
在单页应用(SPA)横行的今天,CSR 是我们最熟悉的模式。它的本质是:服务器先返回一个“空壳” HTML,所有的页面渲染、路由跳转、状态管理都在用户的浏览器端由 JavaScript 完成。
工作流程(四步走):
- 请求 HTML: 用户输入网址,浏览器向服务器发起请求。
- 返回空壳: 服务器仅返回一个极简的 HTML(通常只有一个
<div id="root"></div>)和一个打包好的庞大 JavaScript 文件(如bundle.js)。 - JS 解析(白屏期): 浏览器开始下载并执行 JS 文件。在这个过程中,用户只能看到大白屏或骨架屏。
- 数据请求与组装: JS 运行时向后端 API 请求数据,拿到 JSON 数据后,在本地动态生成 DOM 元素并插入页面,画面最终呈现。
优缺点剖析:
- 优势(体验与成本): 服务器压力极小,只负责吐静态资源和 API 数据。“炒菜组装”的算力消耗全部转移给了用户的设备。一旦首屏加载完成,后续交互(如路由切换、弹窗)均在本地计算,体验极其丝滑,媲美原生 APP。
- 致命劣势(性能与 SEO): 必须等待 JS 下载并执行完毕才能看到内容,网络稍差就会导致严重的首屏白屏。更致命的是,SEO(搜索引擎优化)极差。由于爬虫大多不会去执行复杂的 JS 代码,它们抓取到的永远只是那个没有实质内容的“空壳 HTML”,导致网站毫无自然流量。
适用场景:
后台管理系统、SaaS 工具、重交互的 Web 应用(如在线文档、可视化大屏)。这些内部系统无需考虑 SEO,开发体验和操作流畅度才是第一要务。
2. SSR (Server Side Render) 服务端渲染
为了解决 CSR 的痛点,业界把目光重新投向了服务器。利用 Node.js 环境能够运行 JS 的特性,在服务器端提前把 React/Vue 组件和数据拼接好,直接生成完整的 HTML 字符串返回给浏览器。
工作流程:
- 发起请求: 用户访问页面。
- 服务端组装(核心): 服务器接收请求后,在后端直接调用接口拉取数据,然后将数据和 React 模板拼接,生成包含完整内容的 HTML。
- 返回成品: 将这套完整的 HTML 发送给浏览器。
- 直接展示: 浏览器拿到的是现成的 HTML DOM 树,直接渲染展示,用户瞬间就能看到满屏内容。
优缺点剖析:
- 优势(性能与流量): 首屏加载极快,彻底告别白屏。SEO 完美,搜索引擎爬虫能直接抓取到丰富的页面文本,极其利于收录和排名。
- 劣势(成本与复杂度): 服务器压力剧增。每个用户的每次访问都需要服务器去实时“炒菜”,高并发场景下极易成为性能瓶颈。此外,开发复杂度变高,需要处理 Node.js 环境与浏览器环境的差异(例如在
useEffect触发前,服务端是没有window或document对象的)。
适用场景:
官网、新闻门户网站、电商商品详情页等高度依赖搜索引擎引流的 C 端页面。
二、 现代架构的答案:同构渲染 (Isomorphic Rendering)
非黑即白的时代已经过去。小孩子才做选择,现代前端全都要。为了结合 CSR 的极佳交互体验和 SSR 的首屏/SEO 优势,诞生了诸如 Next.js 和 Nuxt.js 这样的现代框架。它们的核心机制就是同构渲染。
同构渲染的口诀很简单:首次访问 SSR + 后续交互 CSR。
- SSR 阶段(极速首屏): 当你第一次打开网页时,服务器立刻将组装好的、带有完整内容的 HTML 返回。屏幕瞬间出现画面,爬虫也非常满意。
- 下载脚本: 浏览器在展示静态画面的同时,后台开始默默下载包含交互逻辑的 JS 文件。
- Hydration(水合/注水): JS 加载完成后,会在浏览器里重新执行一遍,并静默地“附着”到刚才那个静态的 HTML 页面上。它会对比当前的 DOM 树,不重建 DOM,只是给原本静态的按钮绑上事件,给表单注入状态。这个过程就像给干瘪的海绵注入水分,让它变得鲜活可交互。
- CSR 接管: “注水”完成后,页面彻底变成 SPA。后续点击链接只会请求数据,不再请求完整 HTML,体验恢复到极致丝滑。
三、 深水区实战:基于 Vite + Express 手写 SSR
纸上得来终觉浅,绝知此事要躬行。接下来,我们将抛开 Next.js 的封装,从底层原理出发,手写一个包含水合机制的 React SSR 应用。
1. 扫清工程化障碍:路径与 Vite 的角色
在编写 Node 服务端代码前,必须厘清两个概念:
A. 路径处理:path.resolve() vs path.join()
在处理静态资源时经常踩坑:
path.join():简单的字符串路径拼接。把/看作普通字符,结果可能是相对路径。path.resolve():将路径解析为绝对路径。它会从右向左解析,把/看作根目录并丢弃左侧路径。若参数不足以构成绝对路径,则默认拼接当前工作目录(CWD)。
注意:在 ESM 中,不支持 CommonJS 的 __dirname,可以使用 path.resolve()(不传参时即为 CWD)来替代。
B. Vite 在 SSR 中的角色:包工头
在普通的 CSR 中,浏览器负责解析 JS。但在 SSR 中,Node 不认识 .jsx 语法。我们需要通过 createViteServer 将 Vite 以中间件的形式嵌入到 Express 中,让 Vite 接管编译工作。
Vite 提供的三大绝招:
vite.middlewares:共享中间件,处理静态资源和热更新(HMR)。vite.transformIndexHtml:HTML 转换,将 HMR 客户端脚本注入原始模板。vite.ssrLoadModule:SSR 的灵魂 API。突破 Node 限制,在后台瞬时编译 React 组件,返回 Node 可直接运行的 ESM 模块对象。
2. 实战代码拆解
我们的目标是实现一套完整的同构渲染架构。
步骤一:HTML 骨架与挂载点 (index.html)
准备一个包含占位符的 HTML 文件。注意这里的 `` 标记,服务器等下会将渲染好的 React 组件代码替换到这里。同时引入客户端入口脚本。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSR 实战</title>
</head>
<body>
<div id="root"><!--app-html--></div>
<script type="module" src="/src/entry-client.jsx"></script>
</body>
</html>
步骤二:编写 React 组件 (App.jsx)
写一个极简的组件。加入 onClick 事件是为了验证后续的**水合(Hydration)**是否成功。如果只是单纯的静态 HTML 替换,点击是不会弹窗的。
export default function App() {
return <h1 onClick={() => alert('水合成功!Hello Vite SSR')}>Hello Vite SSR</h1>
}
步骤三:双端入口设计
同构应用需要两个入口:一个给服务器执行,一个给浏览器执行。
服务端入口 (entry-server.jsx):
职责:将 React 组件渲染成纯粹的 HTML 字符串。不涉及任何 DOM 操作,不执行生命周期(如 useEffect)和事件绑定。
import React from 'react';
// react-dom/server 提供将组件渲染为 HTML 字符串的能力
import { renderToString } from 'react-dom/server';
import App from './App';
export function render() {
console.log('Server is rendering the App...');
return renderToString(<App />);
}
客户端入口 (entry-client.jsx):
职责:Hydration(水合)。浏览器接收到 HTML 后,在此处把事件监听等前端逻辑“粘”上去。
console.log('Client script is running...');
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import App from './App';
// 水合渲染:让服务器端的 HTML 字符串变成可交互的页面
// React 在前端再执行一次,与现有 DOM 对比,注入事件和逻辑
hydrateRoot(document.getElementById('root'), <App />);
步骤四:编写 Express 核心调度服务 (server.js)
这是整个架构的枢纽。Express 扮演 Web 服务器(洋葱模型式的中间件处理请求),Vite 扮演实时编译器。
import fs from 'fs';
import path from 'path';
import express from 'express';
import { createServer as createViteServer } from 'vite';
// 获取当前目录的绝对路径
const __dirname = path.resolve();
const app = express();
async function start() {
console.log('SSR Server starting...');
// 1. 以中间件模式创建 Vite 服务器
const vite = await createViteServer({
server: { middlewareMode: true }, // 关键:告诉 Vite 不要自己启动 HTTP 服务
appType: 'custom' // 告诉 Vite 页面 HTML 的渲染由 Express 接管
});
// 2. 将 Vite 作为中间件注入到 Express
// 处理静态资源、热更新逻辑
app.use(vite.middlewares);
// 3. 拦截所有请求,手写 SSR 逻辑
app.use(async (req, res) => {
try {
// A. 同步读取原始的 index.html 模板
let template = fs.readFileSync(
path.resolve(__dirname, 'index.html'),
'utf-8'
);
// B. 让 Vite 接管 HTML 转换
// 这一步至关重要,它会注入 Vite 的 HMR 热更新脚本
template = await vite.transformIndexHtml(req.url, template);
// C. 加载服务器端入口文件
// vite.ssrLoadModule 突破 Node 限制,动态编译 jsx 并返回模块对象
const { render } = await vite.ssrLoadModule('/src/entry-server.jsx')
// D. 在服务端执行 render,将 React 组件渲染成完整的 HTML 字符串
const html = await render();
// E. 将渲染出的 HTML 字符串替换到模板的占位符中
template = template.replace('<!--app-html-->', html);
// F. 将组装好的完整 HTML 返回给浏览器
res.status(200).set({'Content-Type': 'text/html'}).end(template);
} catch (error) {
// 捕获编译错误,通过 Vite 修复堆栈追踪
vite.ssrFixStacktrace(error);
res.status(500).end(error.message);
}
})
}
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
start();
四、 总结
通过上述实践,我们走通了现代前端渲染的闭环:
- Express 接收到用户的 URL 请求。
- 读取本地
index.html,并通过 Vite 中间件 注入热更新代码。 - Vite 在后端即时编译
entry-server.jsx,调用renderToString,快速炒出一盘“没有灵魂”(无交互)的完整 HTML 页面,发给浏览器。这就是 SSR 阶段,爬虫狂喜,用户秒看首屏。 - 浏览器解析 HTML,遇到
<script src="/src/entry-client.jsx">开始下载前端逻辑。 - 前端逻辑加载完毕,调用
hydrateRoot进行水合,页面瞬间拥有了灵魂,点击事件生效。自此,页面由 CSR 接管。
理解了这些底层 API 的流转,再去审视像 Next.js 这种高度封装的生产级 SSR 框架时,便能做到知根知底。现代全栈不仅仅是 API 搬运,深入掌控应用的生命周期和渲染管线,才是构建高性能 Web 系统的基石。