React useEffect 在服务端渲染中的执行行为
本文档详细解释了 React 中 useEffect
钩子在服务端渲染 (SSR) 环境中的执行行为、原理以及最佳实践。
1. useEffect 在 SSR 中的执行行为
useEffect
钩子在服务端渲染和客户端渲染中的行为是不同的:
服务端渲染时
useEffect
钩子不会执行- 服务端渲染只执行组件的渲染函数,生成 HTML
- 所有的副作用(包括
useEffect
中的代码)都会被跳过
客户端水合时
- 水合完成后,
useEffect
钩子会执行 - 这是 React 将事件监听器附加到 DOM 并接管应用的过程
后续客户端渲染时
- 每次组件重新渲染后,
useEffect
都会根据其依赖项决定是否执行
2. 代码示例
javascript
import { useState, useEffect } from 'react';
function ExampleComponent() {
const [count, setCount] = useState(0);
const [serverRendered, setServerRendered] = useState(true);
// 这个 useEffect 在服务端不会执行,只在客户端水合后执行一次
useEffect(() => {
console.log('这个 useEffect 只在客户端执行');
setServerRendered(false);
}, []);
return (
<div>
<p>计数: {count}</p>
<p>是否服务端渲染: {serverRendered ? '是' : '否'}</p>
<button onClick={() => setCount(count + 1)}>增加</button>
</div>
);
}
3. 为什么 useEffect 在服务端不执行?
React 设计 useEffect
不执行的原因有几个:
- 避免副作用:服务端渲染的目的是生成静态 HTML,不应该有副作用(如 DOM 操作、API 调用等)
- 避免不一致:如果服务端执行了副作用,可能会导致服务端和客户端渲染结果不一致
- 性能考虑:服务端渲染应该尽可能快,执行副作用会降低性能
4. 如何区分服务端和客户端代码
由于 useEffect
只在客户端执行,它常被用来处理仅客户端的逻辑:
javascript
import { useState, useEffect } from 'react';
function ClientOnlyComponent() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
// 这段代码只在客户端执行
setIsClient(true);
// 可以安全地使用浏览器 API
document.title = '客户端渲染的页面';
}, []);
// 在服务端渲染一个占位符
if (!isClient) {
return <div>加载中...</div>;
}
// 在客户端渲染实际内容
return (
<div>
<h1>这是客户端渲染的内容</h1>
<p>窗口宽度: {window.innerWidth}px</p>
</div>
);
}
5. 替代方案
对于需要在服务端和客户端都执行的代码,可以使用其他方法:
直接在组件函数体中
组件函数体中的代码在服务端和客户端都会执行:
javascript
function Component() {
// 这段代码在服务端和客户端都会执行
const serverTime = new Date().toISOString();
return <div>服务器时间: {serverTime}</div>;
}
使用 useLayoutEffect 的替代方案
创建一个同构的 useLayoutEffect
钩子:
javascript
import { useEffect, useLayoutEffect } from 'react';
// 创建一个在服务端使用 useEffect,在客户端使用 useLayoutEffect 的钩子
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
function Component() {
useIsomorphicLayoutEffect(() => {
// 这段代码在服务端使用 useEffect(不执行),在客户端使用 useLayoutEffect(执行)
}, []);
return <div>内容</div>;
}
详细解释
useLayoutEffect
是 React 提供的另一个副作用钩子,它与 useEffect
的主要区别在于执行时机:
- useEffect 执行时机:在浏览器绘制 DOM 更新后异步执行
- useLayoutEffect 执行时机:在 React 完成 DOM 更新后,但在浏览器绘制之前同步执行
在服务端渲染环境中,useLayoutEffect
和 useEffect
都不会执行,但 React 会发出警告,因为 useLayoutEffect
设计用于在浏览器环境中同步执行。
为了解决这个问题,我们可以创建一个同构的 useLayoutEffect
钩子,它在服务端使用 useEffect
(不执行),在客户端使用 useLayoutEffect
(执行)。
工作原理
- 环境检测 :
typeof window !== 'undefined'
检查代码是否在浏览器环境中运行 - 条件选择 :
- 在浏览器中:使用
useLayoutEffect
,在 DOM 更新后同步执行 - 在服务端:使用
useEffect
,在服务端渲染时不会执行
- 在浏览器中:使用
使用场景
这种模式特别适用于需要在客户端立即执行 DOM 操作的场景,例如:
- 测量 DOM 元素:需要立即获取元素尺寸
- 动画:需要在浏览器绘制前应用动画
- 焦点管理:需要在 DOM 更新后立即设置焦点
完整示例
javascript
import { useState, useEffect, useLayoutEffect } from 'react';
// 创建同构的 useLayoutEffect
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
function AnimatedComponent() {
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const [isClient, setIsClient] = useState(false);
// 使用同构的 useLayoutEffect
useIsomorphicLayoutEffect(() => {
// 这段代码在服务端不会执行
// 在客户端,它会在 DOM 更新后、浏览器绘制前执行
const element = document.getElementById('animated-element');
if (element) {
setWidth(element.offsetWidth);
setHeight(element.offsetHeight);
}
// 标记组件已在客户端渲染
setIsClient(true);
}, []);
// 在服务端渲染一个占位符
if (!isClient) {
return <div id="animated-element" style={{ width: '100px', height: '100px' }}>加载中...</div>;
}
// 在客户端渲染实际内容
return (
<div>
<div
id="animated-element"
style={{
width: `${width}px`,
height: `${height}px`,
transition: 'all 0.3s ease',
backgroundColor: '#f0f0f0'
}}
>
元素尺寸: {width}x{height}
</div>
</div>
);
}
注意事项
- 避免水合不匹配:确保服务端和客户端渲染的内容一致,避免水合错误
- 性能考虑 :
useLayoutEffect
是同步执行的,可能会阻塞渲染,应谨慎使用 - 条件渲染 :使用
isClient
状态来区分服务端和客户端渲染,避免使用浏览器 API 导致错误
在库中实现
如果你正在开发一个 React 库,可以在库中实现这个模式:
javascript
// my-react-library.js
import { useEffect, useLayoutEffect } from 'react';
// 导出同构的 useLayoutEffect
export const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
// 使用这个钩子的组件
export function MyComponent() {
useIsomorphicLayoutEffect(() => {
// 客户端代码
}, []);
return <div>内容</div>;
}
这样,库的使用者就不需要关心服务端渲染的兼容性问题,库会自动处理这些差异。
6. 常见问题和解决方案
1. 浏览器 API 不可用
在服务端渲染时,浏览器 API(如 window
、document
)不可用,这会导致错误。
解决方案:
javascript
function Component() {
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>窗口宽度: {windowWidth || '...'}px</p>
</div>
);
}
2. 数据获取
在服务端渲染应用中,数据获取通常需要在服务端完成,然后传递给客户端。
解决方案:
javascript
// 服务端
async function renderApp(req, res) {
// 在服务端获取数据
const data = await fetchData();
const appHtml = renderToString(
<App initialData={data} />
);
res.send(`
<!DOCTYPE html>
<html>
<head><title>React SSR 应用</title></head>
<body>
<div id="root">${appHtml}</div>
<script>
window.__INITIAL_DATA__ = ${JSON.stringify(data)};
</script>
<script src="/client.js"></script>
</body>
</html>
`);
}
// 客户端
function App({ initialData }) {
const [data, setData] = useState(initialData);
useEffect(() => {
// 只在客户端执行,用于后续数据更新
if (!initialData) {
fetchData().then(result => {
setData(result);
});
}
}, [initialData]);
return (
<div>
{data ? (
<ul>
{data.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
) : (
<p>加载中...</p>
)}
</div>
);
}
7. 最佳实践
- 使用 useEffect 处理仅客户端的逻辑:如 DOM 操作、事件监听、浏览器 API 调用等
- 使用条件渲染处理服务端和客户端的差异 :如使用
typeof window !== 'undefined'
检查 - 避免在服务端和客户端产生不同的渲染结果:这会导致水合不匹配
- 使用 useLayoutEffect 的替代方案 :创建一个同构的
useLayoutEffect
钩子 - 在服务端获取数据,在客户端更新数据:避免在客户端重复获取初始数据
结论
理解 useEffect
在服务端渲染环境中的执行行为对于构建高质量的 SSR 应用至关重要。通过正确处理服务端和客户端的差异,可以创建流畅、高效的 SSR 应用,提供出色的用户体验。