React SSR 水合(Hydration)详解
本文档详细解释了 React 服务端渲染 (SSR) 中水合(Hydration)的工作原理、实现方式以及常见问题和解决方案。
1. 水合的基本概念
水合(Hydration)是 React SSR 中的一个关键过程,它发生在客户端 JavaScript 接管服务器渲染的 HTML 时。这个过程使静态 HTML 变得具有交互性,同时保留了服务器渲染的内容。
水合与客户端渲染的区别
- 客户端渲染 (CSR):从空的 HTML 容器开始,完全由 JavaScript 构建 DOM 结构
- 水合 (Hydration):从服务器渲染的 HTML 开始,JavaScript 附加事件监听器并使现有 DOM 具有交互性
2. 水合的工作原理
水合过程
- 服务器渲染 :服务器使用
renderToString
或renderToStaticMarkup
将 React 组件渲染为 HTML 字符串 - HTML 传输:服务器将 HTML 发送到浏览器
- 初始渲染:浏览器显示 HTML,用户可以看到内容
- JavaScript 加载:客户端 JavaScript 包下载并执行
- 水合过程:React 将事件监听器附加到现有 DOM 节点,并使静态 HTML 具有交互性
- 接管应用:水合完成后,React 完全接管应用,后续更新通过正常的 React 渲染周期进行
水合的关键步骤
javascript
// 服务器端渲染
import { renderToString } from 'react-dom/server';
// 将 React 组件渲染为 HTML 字符串
const appHtml = renderToString(<App />);
// 将 HTML 注入到模板中
const html = `
<!DOCTYPE html>
<html>
<head><title>React SSR 应用</title></head>
<body>
<div id="root">${appHtml}</div>
<script src="/client.js"></script>
</body>
</html>
`;
// 发送到客户端
res.send(html);
javascript
// 客户端水合
import { hydrateRoot } from 'react-dom/client';
// 水合应用
hydrateRoot(
document.getElementById('root'),
<App />
);
3. 水合的详细实现
完整的水合流程示例
javascript
// server.js - 服务器端代码
import express from 'express';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import App from './src/App';
const app = express();
app.get('*', (req, res) => {
// 渲染应用为 HTML
const appHtml = renderToString(
<StaticRouter location={req.url}>
<App />
</StaticRouter>
);
// 发送 HTML 到客户端
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>React SSR 应用</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div id="root">${appHtml}</div>
<script src="/client.js"></script>
</body>
</html>
`);
});
app.listen(3000, () => {
console.log('服务器运行在 http://localhost:3000');
});
javascript
// client.js - 客户端入口
import { hydrateRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './src/App';
// 水合应用
hydrateRoot(
document.getElementById('root'),
<BrowserRouter>
<App />
</BrowserRouter>
);
状态传递与水合
在 SSR 中,通常需要将服务器端的状态传递到客户端,以便水合时能够恢复相同的状态:
javascript
// server.js - 服务器端状态准备
app.get('*', async (req, res) => {
// 获取初始数据
const initialData = await fetchInitialData(req.path);
// 渲染应用为 HTML
const appHtml = renderToString(
<StaticRouter location={req.url}>
<App initialData={initialData} />
</StaticRouter>
);
// 将初始数据注入到 HTML 中
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>React SSR 应用</title>
</head>
<body>
<div id="root">${appHtml}</div>
<script>
window.__INITIAL_DATA__ = ${JSON.stringify(initialData).replace(/</g, '\\u003c')}
</script>
<script src="/client.js"></script>
</body>
</html>
`);
});
javascript
// client.js - 客户端状态恢复
// 从服务器注入的全局变量中获取初始数据
const initialData = window.__INITIAL_DATA__ || {};
// 水合应用,传递相同的初始数据
hydrateRoot(
document.getElementById('root'),
<BrowserRouter>
<App initialData={initialData} />
</BrowserRouter>
);
4. 水合的挑战与解决方案
1. 不匹配问题
水合过程中最常见的问题是服务器渲染的 HTML 与客户端 React 尝试渲染的内容不匹配。这会导致 React 在控制台中显示警告,并可能导致应用行为异常。
不匹配的常见原因
- 依赖于浏览器 API 的代码 :在服务器上不可用的 API(如
window
、document
) - 依赖于时间的代码 :如
new Date()
、Math.random()
- 条件渲染:基于客户端特定条件的渲染逻辑
解决方案
javascript
// 检查代码运行环境
const isServer = typeof window === 'undefined';
// 条件性使用浏览器 API
function MyComponent() {
const [windowWidth, setWindowWidth] = useState(0);
useEffect(() => {
// 这段代码只在客户端执行
setWindowWidth(window.innerWidth);
const handleResize = () => {
setWindowWidth(window.innerWidth);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return (
<div>
{/* 在服务器上渲染一个占位符,在客户端更新为实际值 */}
<p>窗口宽度: {isServer ? '...' : windowWidth}px</p>
</div>
);
}
2. 使用 useEffect 处理仅客户端的逻辑
对于必须在客户端执行的代码,可以使用 useEffect
钩子:
javascript
import { useState, useEffect } from 'react';
function ClientOnlyComponent() {
const [mounted, setMounted] = useState(false);
const [data, setData] = useState(null);
useEffect(() => {
// 组件已挂载,可以安全地使用浏览器 API
setMounted(true);
// 获取数据
fetchData().then(result => {
setData(result);
});
}, []);
// 在服务器上渲染一个加载状态
if (!mounted) {
return <div>加载中...</div>;
}
// 在客户端渲染实际内容
return (
<div>
{data ? (
<ul>
{data.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
) : (
<p>加载数据...</p>
)}
</div>
);
}
3. 使用动态导入避免服务器端执行
对于完全不能在服务器上运行的代码,可以使用动态导入:
javascript
import { useState, useEffect } from 'react';
function ComponentWithBrowserAPI() {
const [BrowserComponent, setBrowserComponent] = useState(null);
useEffect(() => {
// 动态导入仅在客户端执行的组件
import('./BrowserOnlyComponent').then(module => {
setBrowserComponent(() => module.default);
});
}, []);
return (
<div>
<h1>我的组件</h1>
{BrowserComponent ? <BrowserComponent /> : <p>加载中...</p>}
</div>
);
}
5. 高级水合技术
1. 选择性水合
选择性水合允许应用程序的不同部分在不同时间进行水合,优先水合关键路径:
javascript
// 使用 React.lazy 和 Suspense 实现选择性水合
import { Suspense, lazy } from 'react';
// 懒加载非关键组件
const NonCriticalComponent = lazy(() => import('./NonCriticalComponent'));
function App() {
return (
<div>
<CriticalComponent />
<Suspense fallback={<div>加载中...</div>}>
<NonCriticalComponent />
</Suspense>
</div>
);
}
2. 渐进式水合
渐进式水合允许应用程序在 HTML 完全加载之前开始水合过程:
javascript
// 使用 React 18 的 createRoot 和 hydrateRoot 实现渐进式水合
import { hydrateRoot } from 'react-dom/client';
// 水合根组件
hydrateRoot(
document.getElementById('root'),
<App />,
{
// 启用渐进式水合
onRecoverableError: (error) => {
console.warn('水合恢复错误:', error);
}
}
);
3. 流式 SSR 与水合
React 18 引入了流式 SSR,允许服务器逐步发送 HTML,客户端可以在接收到 HTML 时开始水合:
javascript
// 服务器端流式渲染
import { renderToPipeableStream } from 'react-dom/server';
app.get('*', (req, res) => {
const { pipe, abort } = renderToPipeableStream(
<App />,
{
bootstrapScripts: ['/client.js'],
onShellReady() {
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onError(error) {
console.error('渲染错误:', error);
abort();
}
}
);
// 设置超时
setTimeout(() => {
abort();
}, 5000);
});
6. 水合性能优化
1. 减少水合不匹配
javascript
// 使用 useLayoutEffect 的替代方案
import { useEffect, useLayoutEffect } from 'react';
// 创建一个在服务器上使用 useEffect,在客户端使用 useLayoutEffect 的钩子
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
function Component() {
useIsomorphicLayoutEffect(() => {
// 这段代码在服务器上使用 useEffect,在客户端使用 useLayoutEffect
// 避免水合不匹配
}, []);
return <div>内容</div>;
}
2. 延迟非关键水合
javascript
// 使用 requestIdleCallback 延迟非关键水合
function App() {
useEffect(() => {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
// 在浏览器空闲时执行非关键水合
hydrateNonCriticalComponents();
});
} else {
// 回退到 setTimeout
setTimeout(hydrateNonCriticalComponents, 100);
}
}, []);
return (
<div>
<CriticalContent />
<div id="non-critical-root"></div>
</div>
);
}
function hydrateNonCriticalComponents() {
hydrateRoot(
document.getElementById('non-critical-root'),
<NonCriticalContent />
);
}
7. 调试水合问题
1. 检测水合不匹配
React 在开发模式下会在控制台中显示水合不匹配警告。在生产环境中,可以使用以下代码检测不匹配:
javascript
// 客户端入口
import { hydrateRoot } from 'react-dom/client';
// 创建一个包装器来检测水合不匹配
function HydrationWrapper({ children }) {
useEffect(() => {
// 水合完成后,检查 DOM 是否与 React 期望的匹配
const root = document.getElementById('root');
const reactRoot = root._reactRootContainer;
if (reactRoot && reactRoot._internalRoot) {
const fiber = reactRoot._internalRoot.current;
// 检查是否有不匹配
if (fiber.memoizedState && fiber.memoizedState.element) {
console.log('水合完成,检查不匹配...');
// 这里可以添加自定义的不匹配检测逻辑
}
}
}, []);
return children;
}
// 使用包装器进行水合
hydrateRoot(
document.getElementById('root'),
<HydrationWrapper>
<App />
</HydrationWrapper>
);
2. 使用 React DevTools 调试
React DevTools 可以帮助调试水合问题,特别是在检查组件树和状态时。
结论
水合是 React SSR 中的关键过程,它使服务器渲染的 HTML 变得具有交互性。理解水合的工作原理和挑战对于构建高质量的 SSR 应用至关重要。通过正确处理环境差异、状态传递和性能优化,可以创建流畅、高效的 SSR 应用,提供出色的用户体验。