说说 React 服务端渲染怎么做?原理是什么?
问题解析(面试官考察点)
面试官通过此问题主要考察:
- 对服务端渲染(SSR)概念和优势的理解
- 对 React SSR 实现方式的掌握
- 对同构应用原理的了解
- 对 Next.js 等 SSR 框架的熟悉程度
- 对 hydration(注水)过程的理解
核心概念(基础知识点)
什么是服务端渲染(SSR)
服务端渲染(Server-Side Rendering)指由服务端完成页面的 HTML 结构拼接,发送到浏览器,然后为其绑定状态与事件,成为完全可交互页面的过程。
SSR 解决的问题
- SEO 优化: 搜索引擎爬虫可以直接查看完全渲染的页面
- 首屏加载: 解决首屏白屏问题,提升用户体验
- 社交分享: 社交媒体可以正确抓取页面内容
核心概念
- 同构(Isomorphic/Universal): 同一套代码在服务端和客户端都能运行
- Hydration(注水): 将服务端渲染的静态 HTML 转换为可交互的 React 应用
- 脱水(Dehydration): 将客户端状态序列化传递给服务端
详细解答(代码示例)
手动搭建 SSR 框架
1. 基础服务端代码
// server.js
const express = require("express");
const app = express();
// 基础路由
app.get("/", (req, res) => {
res.send(`
<html>
<head>
<title>SSR Demo</title>
</head>
<body>
<div id="root">Hello SSR</div>
</body>
</html>
`);
});
app.listen(3000, () => {
console.log("Server listening on port 3000");
});
2. 服务端渲染 React 组件
// src/components/Home.js
import React from "react";
const Home = () => {
return (
<div>
<h1>首页</h1>
<p>这是一个服务端渲染的页面</p>
</div>
);
};
export default Home;
// server.js
import express from "express";
import React from "react";
import { renderToString } from "react-dom/server";
import Home from "./src/components/Home";
const app = express();
app.get("/", (req, res) => {
// 将 React 组件渲染为 HTML 字符串
const content = renderToString(<Home />);
res.send(`
<html>
<head>
<title>SSR Demo</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/client.js"></script>
</body>
</html>
`);
});
app.listen(3000);
3. 客户端 Hydration
// src/client.js
import React from "react";
import ReactDOM from "react-dom";
import Home from "./components/Home";
// 使用 hydrate 而不是 render
// hydrate 会复用服务端渲染的 HTML,只绑定事件
ReactDOM.hydrate(<Home />, document.getElementById("root"));
4. 同构数据获取
// src/components/UserList.js
import React, { useEffect, useState } from "react";
// 组件定义
const UserList = ({ users: initialUsers }) => {
const [users, setUsers] = useState(initialUsers || []);
// 客户端数据获取(仅在浏览器执行)
useEffect(() => {
if (!initialUsers) {
fetchUsers().then((data) => setUsers(data));
}
}, [initialUsers]);
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
};
// 数据获取方法(服务端和客户端都能调用)
export async function fetchUsers() {
const response = await fetch("https://api.example.com/users");
return response.json();
}
// 服务端数据获取方法
UserList.getInitialProps = async () => {
const users = await fetchUsers();
return { users };
};
export default UserList;
// server.js
app.get("/users", async (req, res) => {
const props = await UserList.getInitialProps();
const content = renderToString(<UserList {...props} />);
// 将数据注入页面,客户端可以获取
res.send(`
<html>
<head><title>用户列表</title></head>
<body>
<div id="root">${content}</div>
<script>
window.__INITIAL_DATA__ = ${JSON.stringify(props)};
</script>
<script src="/client.js"></script>
</body>
</html>
`);
});
// client.js
const initialData = window.__INITIAL_DATA__;
ReactDOM.hydrate(
<UserList {...initialData} />,
document.getElementById("root")
);
5. 路由同构
// src/routes.js
import React from "react";
import { Route } from "react-router-dom";
import Home from "./components/Home";
import About from "./components/About";
import UserList from "./components/UserList";
export default (
<div>
<Route exact path="/" component={Home} />
<Route path="/about" component={About} />
<Route path="/users" component={UserList} />
</div>
);
// server.js - 服务端路由
import { StaticRouter } from "react-router-dom";
import Routes from "./src/routes";
app.get("*", (req, res) => {
const context = {};
const content = renderToString(
<StaticRouter location={req.url} context={context}>
{Routes}
</StaticRouter>
);
// 处理重定向
if (context.url) {
return res.redirect(301, context.url);
}
// 处理 404
const status = context.status === 404 ? 404 : 200;
res.status(status).send(`
<html>
<head><title>SSR</title></head>
<body>
<div id="root">${content}</div>
<script src="/client.js"></script>
</body>
</html>
`);
});
// client.js - 客户端路由
import { BrowserRouter } from "react-router-dom";
import Routes from "./routes";
ReactDOM.hydrate(
<BrowserRouter>{Routes}</BrowserRouter>,
document.getElementById("root")
);
使用 Next.js 实现 SSR
// pages/index.js - 页面组件
import Head from "next/head";
// 服务端数据获取
export async function getServerSideProps() {
const res = await fetch("https://api.example.com/posts");
const posts = await res.json();
return {
props: {
posts
}
};
}
// 或静态生成
export async function getStaticProps() {
const res = await fetch("https://api.example.com/posts");
const posts = await res.json();
return {
props: {
posts
},
revalidate: 60 // ISR:每60秒重新生成
};
}
export default function Home({ posts }) {
return (
<div>
<Head>
<title>首页</title>
<meta name="description" content="SSR 首页" />
</Head>
<h1>文章列表</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
// pages/posts/[id].js - 动态路由
export async function getStaticPaths() {
// 预生成所有可能的路径
const posts = await fetchPosts();
const paths = posts.map((post) => ({
params: { id: post.id.toString() }
}));
return {
paths,
fallback: true // 允许增量静态生成
};
}
export async function getStaticProps({ params }) {
const post = await fetchPost(params.id);
return {
props: { post },
notFound: !post // 404 处理
};
}
export default function Post({ post }) {
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
深入理解(原理剖析)
SSR 执行流程
┌─────────────────────────────────────────────────────────────┐
│ SSR 执行流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 服务端: │
│ 1. 接收请求 │
│ │ │
│ 2. 获取数据(getServerSideProps / getInitialProps) │
│ │ │
│ 3. 渲染 React 组件(renderToString) │
│ │ │
│ 4. 生成完整 HTML(包含数据脱水) │
│ │ │
│ 5. 发送给客户端 │
│ │
│ 客户端: │
│ 1. 接收 HTML 并显示(首屏可见) │
│ │ │
│ 2. 加载 JavaScript │
│ │ │
│ 3. 执行 ReactDOM.hydrate │
│ │ │
│ 4. 复用服务端渲染的 DOM,绑定事件 │
│ │ │
│ 5. 应用可交互 │
│ │
└─────────────────────────────────────────────────────────────┘
renderToString vs renderToStaticMarkup
import { renderToString, renderToStaticMarkup } from "react-dom/server";
// renderToString: 包含 React 内部属性,用于 hydration
const htmlWithReactAttrs = renderToString(<Component />);
// 输出: <div data-reactroot="" data-reactid="1">...</div>
// renderToStaticMarkup: 纯净的 HTML,无 React 属性
const pureHtml = renderToStaticMarkup(<Component />);
// 输出: <div>...</div>
// 使用场景
// renderToString: 需要 hydration 的 SSR 页面
// renderToStaticMarkup: 邮件模板、静态页面生成
Hydration 过程
// Hydration 简化原理
function hydrate(element, container) {
// 1. 检查服务端渲染的 DOM 结构
const serverHTML = container.innerHTML;
// 2. 创建 Virtual DOM
const vdom = createVirtualDOM(element);
// 3. 对比服务端 DOM 和 Virtual DOM
// 如果结构一致,复用 DOM 节点
// 如果结构不一致,打印警告并重新渲染
// 4. 绑定事件监听器
attachEventListeners(container, vdom);
// 5. 执行 useEffect 等副作用
runEffects(vdom);
}
// Hydration 警告的常见原因
// 1. 服务端和客户端渲染的内容不一致
// 2. 使用了浏览器特有的 API(window, document)
// 3. 时区、语言等环境差异
// 4. 随机数、时间戳等动态值
数据脱水和注水
// 脱水(Dehydration)- 服务端
function renderWithData(Component, data) {
const html = renderToString(<Component {...data} />);
return `
<div id="root">${html}</div>
<script>
// 将数据注入全局变量
window.__INITIAL_STATE__ = ${JSON.stringify(data).replace(
/</g,
"\\u003c"
)};
</script>
`;
}
// 注水(Hydration)- 客户端
function hydrateWithData(Component) {
const data = window.__INITIAL_STATE__;
// 删除全局变量,避免污染
delete window.__INITIAL_STATE__;
ReactDOM.hydrate(
<Component {...data} />,
document.getElementById("root")
);
}
最佳实践
1. 处理客户端/服务端差异
// 使用 dynamic import 禁用 SSR
import dynamic from "next/dynamic";
const Chart = dynamic(() => import("../components/Chart"), {
ssr: false // 只在客户端渲染
});
// 或使用 useEffect 检测环境
function Component() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
if (!isClient) {
return <div>Loading...</div>;
}
return <div>{window.innerWidth}</div>;
}
2. 避免 Hydration 不匹配
// 不好的做法:服务端和客户端渲染不同内容
function BadComponent() {
const [count, setCount] = useState(0);
// 错误:服务端和客户端的随机数不同
const randomId = Math.random();
return <div id={randomId}>{count}</div>;
}
// 好的做法:确保服务端和客户端一致
function GoodComponent() {
const [count, setCount] = useState(0);
const [randomId, setRandomId] = useState("");
useEffect(() => {
// 只在客户端生成随机数
setRandomId(Math.random().toString());
}, []);
return <div id={randomId}>{count}</div>;
}
3. 性能优化
// 1. 使用 React.memo 避免重复渲染
const MemoizedComponent = React.memo(({ data }) => {
return <div>{data}</div>;
});
// 2. 使用流式渲染
import { renderToPipeableStream } from "react-dom/server";
app.get("/", (req, res) => {
const { pipe } = renderToPipeableStream(<App />,
{
onShellReady() {
res.statusCode = 200;
res.setHeader("Content-type", "text/html");
pipe(res);
}
}
);
});
// 3. 使用 SSG/ISR 减少服务端压力
// Next.js 的 getStaticProps + revalidate
4. 错误处理
// 错误边界
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// 记录错误日志
console.error("SSR Error:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return <h1>出错了,请刷新页面重试</h1>;
}
return this.props.children;
}
}
// 服务端错误处理
app.get("*", async (req, res) => {
try {
const html = await renderApp(req);
res.send(html);
} catch (error) {
console.error("Render error:", error);
// 降级到客户端渲染
res.send(`
<html>
<body>
<div id="root"></div>
<script src="/client.js"></script>
</body>
</html>
`);
}
});
面试要点
-
SSR 是什么: 服务端渲染 HTML,客户端 hydration 使页面可交互
-
SSR 的优势:
- SEO 友好
- 首屏加载快
- 社交分享友好
-
核心 API:
renderToString: 渲染为 HTML 字符串ReactDOM.hydrate: 客户端注水StaticRouter/BrowserRouter: 同构路由
-
注意事项:
- 避免 hydration 不匹配
- 处理客户端/服务端环境差异
- 数据脱水和注水
-
Next.js:
getServerSideProps: 服务端数据获取getStaticProps: 静态生成getStaticPaths: 动态路由静态生成- ISR: 增量静态再生
-
hydration 原理: 复用服务端渲染的 DOM,绑定事件,执行副作用