SSR原理与价值深度解析
一句话概括
服务端渲染(Server-Side Rendering, SSR)是一种在服务端将前端组件或页面渲染为完整 HTML 字符串后发送给浏览器的技术方案。与传统客户端渲染(CSR)不同,SSR 的核心价值在于:用户收到的不是空壳 HTML + JavaScript 脚本,而是包含真实内容的完整页面。这直接解决了 CSR 在 SEO 中的”蜘蛛爬空”问题和低速网络下的白屏等待问题。Vue 的 vue-server-renderer、React 的 renderToString 以及 Next.js/Nuxt.js 等框架都是 SSR 的典型实践,理解 SSR 的原理与价值,是进阶现代前端工程化的必修课。
背景与意义
为什么 SSR 如此重要?
在单页应用(SPA)大行其道的年代,前端开发者习惯了这样的开发模式:服务端返回一个几乎空的 HTML 文件,连上一个巨大的 bundle.js,由浏览器下载并执行 JavaScript 来构建整个 DOM 树。这在桌面端、高速网络环境下体验尚可,但一旦进入移动端或弱网环境,问题就暴露无遗:
- 白屏时间过长:用户看到空白页面数秒之久,直到 JS 下载、解析、执行完毕才能看到内容。
- SEO 灾难:搜索引擎爬虫(尤其是百度、Google 的部分爬虫)不执行 JavaScript,它们看到的是一个空页面,关键词密度为 0,自然排名惨不忍睹。
- 社交分享失效:Facebook、Twitter、微信等平台的链接预览抓取器同样不执行 JS,分享链接时的标题、描述、图片预览全部缺失。
SSR 正是为解决这些问题而生。在面试场景中,SSR 几乎是大厂前端面试的必考内容。一个考察候选人”对前端架构理解深度”的问题,通常以”聊聊你对 SSR 的理解”开场,然后层层深入到底层原理、性能优化、流式渲染等。
实际业务价值
| 指标 | CSR | SSR | 提升幅度 |
|---|---|---|---|
| 首次内容渲染(FCP) | 2-5s | 0.5-1.5s | 60-80% |
| 最大内容渲染(LCP) | 3-8s | 1-2s | 50-75% |
| SEO 收录率 | 30-50% | 95%+ | 2-3倍 |
| 首屏可交互时间(TTI) | 3-6s | 1.5-3s | 40-50% |
在电商、内容平台、企业官网等对 SEO 和首屏加载速度有刚性需求的场景中,SSR 已经是不二之选。以淘宝为例,早在 2015 年就开始在核心链路上实践 Node.js SSR,将首屏加载时间缩短了 60% 以上。
概念与定义
什么是 SSR?
服务端渲染(SSR)是指在 Web 服务器上执行前端框架(如 React、Vue、Angular)的组件渲染逻辑,生成完整的 HTML 字符串,然后将其作为 HTTP 响应发送给客户端。客户端接收到 HTML 后,先展示内容,再下载 JavaScript 进行”激活”(Hydration),使得页面可交互。
与 CSR、SSG 的区别
为了清晰理解 SSR 的定位,我们将其与客户端渲染(CSR)和静态站点生成(SSG)放在一起对比:
客户端渲染(CSR):
- 渲染时机:浏览器端
- HTML 内容:空壳
<div id="root"></div> - 数据获取:客户端发起 AJAX 请求
- 首屏速度:慢(需要等 JS 下载 + 执行 + 数据请求)
- SEO:差(爬虫看不到内容)
服务端渲染(SSR):
- 渲染时机:请求时在服务端执行
- HTML 内容:完整的页面 HTML
- 数据获取:服务端请求数据并注入到 HTML
- 首屏速度:快(浏览器直接拿到完整 HTML)
- SEO:好(爬虫直接读取内容)
静态站点生成(SSG):
- 渲染时机:构建时预先生成
- HTML 内容:完整的静态 HTML 文件
- 数据获取:构建时确定
- 首屏速度:极快(静态文件 CDN 直达)
- SEO:极好
- 适用场景:内容不频繁变化的网站
同构渲染(Isomorphic Rendering)
SSR 背后有一个更底层的概念——同构。所谓同构,指的是同一套组件代码既能在服务端运行(输出 HTML),也能在客户端运行(绑定事件和状态)。同构是 SSR 能够实现的技术前提。没有同构,SSR 就只能做简单的模板拼接,无法实现复杂交互。
核心知识点拆解
1. SSR 的核心执行流程
SSR 的完整生命周期可以概括为以下步骤:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 以一个 React SSR 的简化实现为例
// step 1: 服务端接收到页面请求
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import App from './App';
const server = express();
server.get('*', (req, res) => {
// step 2: 在服务端获取页面所需数据
const fetchData = async () => {
const response = await fetch('https://api.example.com/posts');
return response.json();
};
fetchData().then((initialData) => {
// step 3: 将数据作为 props 传给根组件,调用 renderToString 生成 HTML
const appHtml = renderToString(
React.createElement(App, { posts: initialData })
);
// step 4: 将初始数据序列化后注入 HTML(数据注水)
const serializedData = JSON.stringify(initialData).replace(/</g, '\\u003c');
// step 5: 拼接完整的 HTML 响应
const fullHtml = `
<!DOCTYPE html>
<html>
<head>
<title>SSR Demo</title>
</head>
<body>
<div id="root">${appHtml}</div>
<script>
window.__INITIAL_STATE__ = ${serializedData};
</script>
<script src="/static/client.bundle.js"></script>
</body>
</html>
`;
res.send(fullHtml);
});
});
server.listen(3000, () => {
console.log('SSR server running on http://localhost:3000');
});
关键步骤解读:
renderToString是 SSR 的核心 API,它在服务端将 React 组件树生成 HTML 字符串。- 数据库/API 调用必须在服务端完成,然后将数据传入组件。
window.__INITIAL_STATE__是”数据注水”的关键,它将服务端获取的数据序列化后嵌入 HTML,避免客户端重复请求。
2. 客户端激活(Hydration)机制
服务端生成 HTML 并发送给浏览器后,浏览器展示的是静态内容。要让页面变得可交互——点击按钮有反应、输入框能输入——需要客户端的”激活”过程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 客户端入口文件 client.js
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import App from './App';
// hydrateRoot 替代 createRoot
// 它不会重新创建 DOM,而是复用服务端生成的 DOM 节点并绑定事件
const root = hydrateRoot(
document.getElementById('root'),
React.createElement(App, {
// 使用服务端注水的数据作为初始状态
posts: window.__INITIAL_STATE__.posts
})
);
// 激活完成后清除全局状态,释放内存
delete window.__INITIAL_STATE__;
hydration 的关键细节:
hydrateRoot在 React 18 中替代了旧的ReactDOM.hydrate。- 激活时,React 会遍历服务端生成的 DOM 树与虚拟 DOM 树进行比较。
- 如果发现不一致(mismatch),React 会丢弃服务端生成的 DOM 节点并重新在客户端渲染——这会造成性能损失,因此确保服务端和客户端渲染结果一致至关重要。
- 激活后的 DOM 节点会绑定事件处理器(onClick、onChange 等),从静态 HTML 变为可交互的组件。
3. 数据预取与状态同步
SSR 中最容易出错的就是数据管理。服务端和客户端必须共享同一份初始数据,否则激活时会报 mismatch 错误。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 一个更完整的数据预取模式
// 定义页面级别的数据预取函数
// App.jsx
import React, { useEffect, useState } from 'react';
// 静态方法模式:将数据预取函数作为组件的静态属性
function PostList({ posts }) {
return (
<div className="post-list">
{posts.map(post => (
<div key={post.id} className="post-card">
<h2>{post.title}</h2>
<p>{post.summary}</p>
<span className="date">{post.createdAt}</span>
</div>
))}
</div>
);
}
// 服务端会调用这个静态方法来获取数据
PostList.fetchData = async (params) => {
const res = await fetch(`https://api.example.com/posts?page=${params.page}`);
return { posts: await res.json() };
};
// 路由配置中注册数据预取
// routes.js
const routes = [
{
path: '/posts',
component: PostList,
// 路由知道该调用哪个组件的 fetchData
fetchData: PostList.fetchData
}
];
// 服务端渲染时,匹配路由并收集所有需要的数据
// server.js - 路由匹配与数据收集
async function handleRequest(req, res) {
// 1. 匹配路由
const matchedRoute = routes.find(route => req.path === route.path);
if (matchedRoute) {
// 2. 调用数据预取函数
const data = await matchedRoute.fetchData({ page: req.query.page || 1 });
// 3. 渲染并注水
const appHtml = renderToString(
React.createElement(matchedRoute.component, data)
);
// 4. 返回完整 HTML
const html = buildFullHtml(appHtml, data);
res.send(html);
}
}
4. 流式渲染(Streaming SSR)
React 18 引入了流式 SSR,允许服务端一边渲染一边向客户端发送 HTML,而不是等到全部渲染完才发送。这对大页面和慢速 API 场景有巨大价值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// 使用 React 18 的流式 SSR
import { renderToPipeableStream } from 'react-dom/server';
import { Suspense } from 'react';
function Document({ children }) {
return (
<html>
<head>
<title>流式 SSR 示例</title>
</head>
<body>
<div id="root">{children}</div>
</body>
</html>
);
}
function App() {
return (
<Document>
{/* 头部内容可以立即发送 */}
<Header />
{/* 文章列表需要等 API 响应,用 Suspense 包裹 */}
<Suspense fallback={<div>加载文章中...</div>}>
<ArticleList />
</Suspense>
{/* 侧边栏同样可以延迟 */}
<Suspense fallback={<div>加载推荐内容...</div>}>
<Sidebar />
</Suspense>
</Document>
);
}
// 服务端使用流式接口
import express from 'express';
const app = express();
app.get('*', (req, res) => {
const { pipe, abort } = renderToPipeableStream(
React.createElement(App),
{
// Shell 就绪时立即开始发送
onShellReady() {
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onShellError(error) {
res.status(500).send('Server Error');
},
onAllReady() {
// 所有内容都渲染完成
console.log('全部内容已就绪');
}
}
);
});
流式渲染的优势:
- TTFB(首字节时间)大幅降低,浏览器可以更早开始解析和渲染。
- 配合 Suspense,可以实现渐进式内容加载。
- 用户可以在 1-2 秒内看到骨架屏或部分内容,而不是等待所有数据就绪。
实战案例
完整 SSR 应用:博客系统
让我们构建一个完整的 SSR 博客系统,涵盖从服务端到客户端的完整链路。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
// === server/index.js - 服务端入口 ===
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import path from 'path';
const app = express();
const PORT = 3000;
// 模拟数据源
const blogData = {
posts: [
{
id: 1,
title: '理解 JavaScript 闭包',
content: '<p>闭包是 JavaScript 最强大的特性之一...</p>',
author: '张三',
createdAt: '2026-08-15',
tags: ['JavaScript', '基础']
},
{
id: 2,
title: 'React 18 新特性解析',
content: '<p>React 18 引入了并发渲染机制...</p>',
author: '李四',
createdAt: '2026-08-16',
tags: ['React', '前端框架']
},
{
id: 3,
title: 'CSS Grid 完整指南',
content: '<p>CSS Grid 布局是二维布局系统...</p>',
author: '王五',
createdAt: '2026-08-17',
tags: ['CSS', '布局']
}
]
};
// === 服务端渲染函数 ===
async function renderBlogPage(postId) {
// 1. 获取数据
const posts = blogData.posts;
const currentPost = postId
? posts.find(p => p.id === parseInt(postId))
: posts[0];
// 2. 构建服务端 HTML(这里我们使用模板字符串模拟组件渲染)
const headerHtml = `
<header class="blog-header">
<h1>技术博客</h1>
<nav>
<a href="/">首页</a>
<a href="/about">关于</a>
</nav>
</header>
`;
const postListHtml = posts.map(post => `
<article class="post-item">
<h2><a href="/post/${post.id}">${post.title}</a></h2>
<div class="post-meta">
<span class="author">${post.author}</span>
<span class="date">${post.createdAt}</span>
</div>
<div class="post-tags">
${post.tags.map(tag => `<span class="tag">${tag}</span>`).join('')}
</div>
<p class="post-summary">${post.content.replace(/<[^>]+>/g, '').substring(0, 100)}...</p>
</article>
`).join('');
// 3. 数据注水 - 将所有数据序列化后嵌入 HTML
const serializedState = JSON.stringify({ posts })
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e');
// 4. 构建完整 HTML
return `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${currentPost ? currentPost.title : '技术博客'} - SSR Demo</title>
<meta name="description" content="${currentPost ? currentPost.content.replace(/<[^>]+>/g, '').substring(0, 200) : '一个关于前端技术的博客'}">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div id="root">
${headerHtml}
<main class="content">
<section class="post-list">
${postListHtml}
</section>
</main>
</div>
<!-- 数据注水:将服务端数据嵌入页面 -->
<script>
window.__INITIAL_STATE__ = ${serializedState};
</script>
<!-- 客户端激活脚本 -->
<script src="/static/client.js"></script>
</body>
</html>
`;
}
// 路由处理
app.get('/', async (req, res) => {
try {
const html = await renderBlogPage();
res.send(html);
} catch (error) {
console.error('SSR 渲染失败:', error);
res.status(500).send('服务端渲染错误');
}
});
app.get('/post/:id', async (req, res) => {
try {
const html = await renderBlogPage(req.params.id);
res.send(html);
} catch (error) {
res.status(500).send('服务端渲染错误');
}
});
// 静态资源
app.use('/static', express.static(path.join(__dirname, '../public')));
app.listen(PORT, () => {
console.log(`SSR 服务器已启动: http://localhost:${PORT}`);
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// === public/client.js - 客户端激活脚本 ===
(function() {
'use strict';
// 1. 读取服务端注水数据
const initialData = window.__INITIAL_STATE__;
// 2. 清理全局状态
delete window.__INITIAL_STATE__;
// 3. 客户端激活:为静态 HTML 绑定事件
function hydrateApp() {
// 为导航链接添加 SPA 式导航
document.querySelectorAll('nav a, .post-item a').forEach(link => {
link.addEventListener('click', async (e) => {
e.preventDefault();
const url = link.getAttribute('href');
// 显示加载状态
document.querySelector('.content').innerHTML = '<div class="loading">加载中...</div>';
// 实际项目中这里会调用客户端路由进行页面切换
// 这里简化处理——刷新页面演示服务端重复渲染
window.location.href = url;
});
});
// 为标签添加点击高亮效果
document.querySelectorAll('.tag').forEach(tag => {
tag.addEventListener('click', function() {
this.classList.toggle('tag-active');
});
});
}
// 4. DOM 就绪后执行激活
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', hydrateApp);
} else {
hydrateApp();
}
})();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
/* public/static/style.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.blog-header {
border-bottom: 2px solid #eee;
padding-bottom: 20px;
margin-bottom: 30px;
}
.blog-header h1 {
color: #2c3e50;
font-size: 2rem;
}
.blog-header nav {
margin-top: 10px;
}
.blog-header nav a {
margin-right: 15px;
color: #3498db;
text-decoration: none;
}
.post-item {
background: #f9f9f9;
padding: 20px;
margin-bottom: 20px;
border-radius: 8px;
transition: box-shadow 0.2s;
}
.post-item:hover {
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.post-item h2 a {
color: #2c3e50;
text-decoration: none;
}
.post-meta {
color: #888;
font-size: 0.9rem;
margin: 8px 0;
}
.post-meta span {
margin-right: 15px;
}
.tag {
display: inline-block;
background: #e8f4fd;
color: #3498db;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8rem;
margin-right: 5px;
cursor: pointer;
}
.tag-active {
background: #3498db;
color: white;
}
.loading {
text-align: center;
padding: 40px;
color: #888;
font-size: 1.2rem;
}
底层原理
renderToString 的实现机制
renderToString 是 SSR 的核心 API。理解它的底层实现,才能真正理解 SSR 的工作原理。
React 的 renderToString 工作流程可以简化为以下步骤:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Graph: renderToString 执行流程
┌─────────────────────────────────────────────────┐
│ renderToString(App) │
├─────────────────────────────────────────────────┤
│ 1. 创建 FiberRoot (服务端模式) │
│ - 没有 DOM 环境,使用 NoopRenderer │
│ - 渲染模式设置为 Legacy │
├─────────────────────────────────────────────────┤
│ 2. 构建 Fiber 树 │
│ - 从根组件开始递归遍历 │
│ - 为每个组件创建 Fiber Node │
│ - 收集 effect list │
├─────────────────────────────────────────────────┤
│ 3. 执行组件生命周期 │
│ - 调用 class 组件的 constructor │
│ - 调用 getDerivedStateFromProps │
│ - 调用 render 方法 │
│ - ⚠️ 不会执行 useEffect/componentDidMount │
├─────────────────────────────────────────────────┤
│ 4. 生成 HTML 字符串 │
│ - 遍历 complete work │
│ - 将 React Element 序列化为 HTML 标签 │
│ - 处理 props → HTML attributes │
│ - 处理 dangerouslySetInnerHTML │
│ - 子节点递归处理 │
├─────────────────────────────────────────────────┤
│ 5. 返回完整 HTML 字符串 │
└─────────────────────────────────────────────────┘
关键源码片段(简化自 React 源码):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
// ReactDOMServerRenderer 的简化实现
class ReactDOMServerRenderer {
constructor(children, options) {
// 创建服务端 Fiber Root
this._root = createServerRoot(children);
this._stack = [];
this._explicitChildren = [];
}
// 核心渲染方法
render() {
let didHaveException = true;
try {
// 遍历组件树,生成 HTML
let result = '';
let node = this._root;
// 深度优先遍历
while (node !== null) {
// 处理当前节点
if (node.isClassComponent) {
// 类组件:实例化并调用 render()
const instance = new node.type(node.props);
instance.componentWillMount?.();
const rendered = instance.render();
result += this.renderNode(rendered);
} else if (node.isFunctionComponent) {
// 函数组件:直接调用
const rendered = node.type(node.props);
result += this.renderNode(rendered);
} else if (node.isHostComponent) {
// 原生 DOM 元素:生成 HTML 标签
result += this.renderHostElement(node);
}
// 移动到下一个节点
node = this.getNextNode(node);
}
didHaveException = false;
return result;
} finally {
if (didHaveException) {
// 异常时清理
this._cleanup();
}
}
}
renderHostElement(node) {
const { type, props } = node;
let html = `<${type}`;
// 处理 props → HTML 属性
for (const [key, value] of Object.entries(props)) {
if (key === 'children') continue;
if (key === 'className') {
html += ` class="${this.escapeHtml(value)}"`;
} else if (key === 'style' && typeof value === 'object') {
const styleStr = this.styleObjectToCSS(value);
html += ` style="${styleStr}"`;
} else if (key.startsWith('on')) {
// 事件处理器在服务端忽略
continue;
} else if (key === 'dangerouslySetInnerHTML') {
continue; // 特殊处理
} else {
html += ` ${key}="${this.escapeHtml(String(value))}"`;
}
}
// 自闭合标签处理
if (selfClosingTags.has(type)) {
html += ' />';
return html;
}
html += '>';
// children 处理
const children = props.children;
if (children) {
if (typeof children === 'string' || typeof children === 'number') {
html += this.escapeHtml(String(children));
} else if (Array.isArray(children)) {
html += children.map(child => this.renderNode(child)).join('');
} else if (typeof children === 'object') {
html += this.renderNode(children);
}
}
html += `</${type}>`;
return html;
}
escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
styleObjectToCSS(style) {
return Object.entries(style)
.map(([key, value]) => {
const cssKey = key.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`);
return `${cssKey}:${value}`;
})
.join(';');
}
}
客户端激活的核心机制
激活过程的核心挑战是:如何将服务端已经生成的 DOM 树与客户端将要构建的虚拟 DOM 树对齐,而不重新创建 DOM?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 简化版 hydrateRoot 实现
function hydrateRoot(container, reactNode) {
// 1. 获取服务端已渲染的 DOM 节点
const existingDOM = container.firstChild;
if (!existingDOM) {
// 没有服务端渲染的内容,回退到客户端渲染
return createRoot(container).render(reactNode);
}
// 2. 创建 Fiber Root,标记为激活模式
const fiberRoot = createFiberRoot(container, {
hydrate: true, // 激活模式标记
hydrationOptions: {
onHydrated: () => {
console.log('激活完成');
},
onDeleted: (node) => {
console.warn('服务端多余节点被删除:', node);
}
}
});
// 3. 将现有 DOM 与 Fiber 树关联
// 这一步非常关键:Fiber 节点直接指向已有的 DOM 节点
fiberRoot.containerInfo = container;
const existingFiber = fiberRoot.current;
existingFiber.stateNode = container;
// 4. 运行工作循环(work loop)
// 与 CSR 不同,hydrate 模式的工作循环会:
// - 跳过 DOM 创建(复用已有 DOM)
// - 只绑定事件处理器
// - 检查 DOM 属性是否匹配
scheduleHydrationWork(fiberRoot);
return {
render(reactNode) {
// 更新时切换为正常渲染模式
scheduleUpdateOnFiber(fiberRoot, reactNode);
},
unmount() {
// 卸载
}
};
}
服务端与客户端的关键差异
SSR 中最容易踩坑的地方在于服务端和客户端环境的差异:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 常见 SSR 兼容性问题清单
// 问题1: window/document 不存在
// 服务端没有 window、document、navigator 等浏览器 API
function useWindowSize() {
// ❌ 服务端直接访问 window 会报错
// return window.innerWidth;
// ✅ 安全写法
const [size, setSize] = useState(
typeof window !== 'undefined'
? window.innerWidth
: 1024 // 服务端返回默认值
);
useEffect(() => {
const handler = () => setSize(window.innerWidth);
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
return size;
}
// 问题2: useEffect / componentDidMount 不在服务端执行
// 服务端只执行到 render() 为止,不会执行任何副作用
// 问题3: Date.now() / Math.random() 不一致
// 服务端和客户端调用同样的方法可能得到不同的值
function Timestamp({ time }) {
// ❌ 这样会导致 hydrate mismatch
// return <div>{Date.now()}</div>;
// ✅ 使用 props 传入确定的值
return <div>{time}</div>;
}
// 问题4: 数据获取时间不一致
// 服务端从数据库获取的数据在客户端请求时可能已经变化
// 解决方案:使用 cache 或 stale-while-revalidate 策略
高频面试题解析
面试题 1:SSR 与 CSR 相比,有哪些优势和劣势?
答案要点:
优势:
- 更快的首屏加载:用户直接收到包含内容的 HTML,无需等待 JS 执行完毕就能看到页面。
- 更好的 SEO:搜索引擎爬虫直接读取 HTML 中的内容,收录率和排名更高。
- 更佳的社交分享体验:社交平台的预览抓取器能获取完整的页面标题、描述和图片。
- 更好的弱网体验:在弱网环境下,即使 JS 加载缓慢,用户也能看到内容。
劣势:
- 更高的服务器负载:每次请求都需要服务端执行渲染,CPU 密集,需要更高的服务器成本。
- 更复杂的架构:需要同时管理服务端运行环境和客户端运行环境,处理环境差异。
- TTI 延迟:用户看到内容早,但可交互时间(TTI)可能滞后于 FCP,因为 JS 需要在 hydrate 后才能交互相应。
- 缓存策略复杂:动态内容的缓存策略比静态文件复杂得多。
面试题 2:什么是 Hydration(激活)?它的工作原理是什么?
答案要点:
Hydration 是 SSR 中客户端的关键步骤。服务端生成 HTML 后,浏览器将其渲染为静态的 DOM 树。Hydration 的过程就是让这个静态 DOM 树”活起来”:
- 客户端 JavaScript 加载并执行。
- React(或其他框架)读取服务端注入的
window.__INITIAL_STATE__数据。 - 使用
hydrateRoot(React 18)或hydrate(React 17)函数,遍历服务端生成的 DOM 树。 - 将虚拟 DOM 树与现有 DOM 树对齐,不重新创建 DOM 节点,而是直接绑定事件处理器。
- 如果服务端和客户端的渲染结果不一致,React 会发出 warning 并强制在客户端重新渲染。
类比:就像给一个已经打印好的画纸上色——画纸(HTML)已经有了轮廓(DOM 结构),上色(hydrate)就是为它添加颜色(事件交互能力)。
面试题 3:流式渲染(Streaming SSR)解决了什么问题?它是如何工作的?
答案要点:
解决的问题: 传统 SSR 需要等待所有数据就绪才能发送响应,如果某个 API 响应较慢,整个页面都会被阻塞。流式渲染允许页面”边渲染边发送”,首字节时间(TTFB)大幅降低。
工作原理:
- 服务端使用
renderToPipeableStream替代renderToString。 - 先渲染出页面的”壳”(Shell)——如头部、导航栏、骨架屏。
- 对于需要等待数据的部分,用
<Suspense>包裹。 - Shell 渲染完成后立即通过流发送给客户端。
- 被 Suspense 包裹的组件数据就绪后,服务端将其渲染为 HTML 并通过同一流发送。
- 客户端接收到流式内容后,渐进式地更新 DOM,插入新内容。
面试题 4:服务端渲染时如何处理数据获取和状态管理?
答案要点:
SSR 的数据处理分为三个阶段:
- 服务端数据预取:
- 在路由匹配时,收集当前页面所有组件需要的数据。
- 在渲染前一次性发起所有数据请求(可并行)。
- 将获取到的数据通过
renderToString或renderToPipeableStream传入组件。
- 数据注水(Data Serialization):
- 将服务端获取的数据序列化为 JSON,嵌入 HTML 中。
- 通常放在
<script>window.__INITIAL_STATE__ = ...</script>中。 - 注意转义特殊字符,防止 XSS 攻击。
- 客户端数据恢复:
- 客户端 JavaScript 读取
window.__INITIAL_STATE__。 - 将其作为 store 或 context 的初始状态。
- 恢复后清除全局变量以释放内存。
- 客户端 JavaScript 读取
在 React 生态中,常见的数据管理方案包括:
- Redux:服务端创建 store,渲染后注入 store 状态,客户端用同一状态初始化 store。
- React Query / SWR:服务端预取数据并缓存,客户端利用缓存数据直接渲染。
- Relay / Apollo(GraphQL):服务端执行查询,将结果序列化后传给客户端。
面试题 5:SSR 项目中如何避免常见的激活不匹配(Hydration Mismatch)问题?
答案要点:
激活不匹配是 SSR 中最常见也是最头疼的问题。以下是一些关键的解决方案:
常见原因和解决方案:
- 时间相关数据不一致:
- 问题:
new Date()或Math.random()在服务端和客户端产生不同值。 - 方案:使用服务端确定的时间,通过 props 传入;或在
useEffect中生成随机值。
- 问题:
- 浏览器特有 API:
- 问题:
window.innerWidth、localStorage等 API 在服务端不存在。 - 方案:用
typeof window !== 'undefined'做守卫;或使用动态导入(next/dynamic配合ssr: false)。
- 问题:
- 第三方库的客户端特有行为:
- 问题:某些库在客户端动态修改 DOM 结构。
- 方案:确保服务端和客户端使用相同的库版本;或对这类组件禁用 SSR。
- CSS-in-JS 处理不当:
- 问题:服务端未提取样式,客户端运行时注入样式导致 DOM 结构差异。
- 方案:正确配置服务端样式收集(如 styled-components 的
ServerStyleSheet)。
- 不正确的 HTML 结构:
- 问题:服务端生成的 HTML 不符合语义规范(如
<div>在<p>中)。 - 方案:检查 HTML 嵌套结构,确保符合规范。
- 问题:服务端生成的 HTML 不符合语义规范(如
关键原则:确保服务端和客户端渲染的第一个结果完全一致,差异只在客户端 hydrate 后通过交互产生。
总结与扩展
知识体系
SSR 的知识体系可以横向和纵向两个维度梳理:
横向对比:
- CSR vs SSR vs SSG vs ISR:四种渲染策略各有适用场景,不存在万能的方案。
- React SSR vs Vue SSR vs Angular Universal:核心原理相通,但具体 API 和生态各有特色。
纵深脉络:
- 基础层:HTTP 请求-响应模型、HTML 模板引擎、同构 JavaScript
- 框架层:renderToString、hydrateRoot、Suspense、Streaming SSR
- 工具层:Next.js、Nuxt.js、Remix、Astro、Razzle
- 优化层:缓存策略、组件级缓存、部分 hydration、边缘 SSR
- 架构层:微前端 + 微服务 + SSR、BFF 模式、Serverless SSR
延伸阅读
- React 官方文档:
<Suspense>和renderToPipeableStream - Next.js 官方文档 — App Router 的 SSR 策略
- The Principles of Server-Side Rendering
- Vue SSR 指南:vue-server-renderer 的使用和配置
- “Building Large Scale Web Apps” by Addy Osmani — 大型 Web 应用的性能优化策略
- Dan Abramov 的 “The Two Reacts” — 深入理解 React 的服务端和客户端概念模型
SSR 不仅是一项技术,更是一种”在正确的地方做正确的事情”的工程哲学。将渲染任务交给最适合的地方执行——服务端负责生成内容、客户端负责交互体验——这才是 SSR 背后的核心思想。