什么是水合错误
nextjs官方原文:
Hydration Errors: "Hydration is the process where React takes the server-rendered HTML and makes it interactive by attaching event handlers and reconciling the JavaScript-generated DOM with the server-rendered DOM. A hydration error occurs when there is a mismatch between the server-rendered HTML and the client-rendered HTML. This can happen if the server and client render different content due to logic that depends on browser-specific APIs (like window), conditional rendering that differs between environments, or external data fetching that isn’t properly synchronized."
水合错误:“水合是指 React 接收服务器渲染的 HTML,并通过附加事件处理器并将 JavaScript 生成的 DOM 与服务器渲染的 DOM 进行协调,使其变得可交互的过程。当服务器渲染的 HTML 与客户端渲染的 HTML 不匹配时,就会发生水合错误。这种情况可能发生在以下情况:服务器和客户端由于依赖浏览器特定 API(例如window)的逻辑而渲染不同的内容、在不同环境下条件渲染不同,或者外部数据获取未正确同步。”
我的理解:
“水合”(Hydration)是指将服务器端渲染(SSR)的静态HTML与客户端的JavaScript逻辑结合起来的过程。服务器首先生成HTML内容并发送到浏览器,然后客户端的JavaScript接管页面,使其变得交互式。这个过程就叫“水合”。
React SSR 的完整生命周期
// === 服务器端阶段 ===
// 第1步:组件在 Node.js 环境中执行
function MyComponent() {
const [count, setCount] = useState(0);
// 服务器端:useEffect 不会执行
useEffect(() => {
setCount(Math.random()); // 这行代码在服务器端不会运行
}, []);
return <div>{count}</div>; // 服务器端输出:<div>0</div>
}
// 第2步:renderToString 生成 HTML 字符串
const html = ReactDOMServer.renderToString(<MyComponent />);
// 结果:<div>0</div>
// === 客户端阶段 ===
// 第3步:浏览器接收并显示静态 HTML
// 用户看到:<div>0</div>
// 第4步:JavaScript 包下载完成,React 开始水合
ReactDOM.hydrateRoot(container, <MyComponent />);
// 第5步:组件在浏览器中重新执行
function MyComponent() {
const [count, setCount] = useState(0);
// 客户端:useEffect 会执行
useEffect(() => {
setCount(Math.random()); // 假设生成 0.7234
}, []);
return <div>{count}</div>; // 客户端期望:<div>0.7234</div>
}
// 第6步:React 对比 DOM 结构
// 服务器端 HTML:<div>0</div>
// 客户端虚拟 DOM:<div>0.7234</div>
// 结果:❌ Hydration Error!
水合错误的根本原因分类
在水合的过程中,React 有一个极其严格的原则:它在浏览器端重新计算生成的虚拟 DOM 结构,必须和服务器发过来的 HTML 结构完全一致,而造成二者结构不一致的原因有很多种:
A. 环境差异导致的不一致
// 1. 全局对象访问
function BadComponent() {
// 服务器端:window 是 undefined
// 客户端:window 是对象
const width = window.innerWidth; // ❌ 服务器端报错或返回不同值
return <div>Width: {width}</div>;
}
// 2. 浏览器 API 调用
function AnotherBadComponent() {
// 服务器端:localStorage 不存在
// 客户端:localStorage 存在
const theme = localStorage.getItem('theme') || 'light';
return <div className={theme}>Content</div>;
}
// 3. 时间相关函数
function TimeBadComponent() {
// 服务器端执行时间:T1
// 客户端执行时间:T2 (T2 > T1)
const now = new Date().toISOString();
return <div>Current time: {now}</div>;
}
B. 异步操作的执行差异
function AsyncBadComponent() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 服务器端:useEffect 不执行
// 客户端:useEffect 执行
fetchData().then(result => {
setData(result);
setLoading(false);
});
}, []);
// 服务器端渲染:loading = true, data = null
// 客户端首次渲染:loading = true, data = null (一致)
// 客户端 useEffect 后:loading = false, data = "some data" (不一致!)
if (loading) return <div>Loading...</div>;
return <div>Data: {data}</div>;
}
C. 随机值和不确定性
function RandomBadComponent() {
// 每次执行都产生不同结果
const [id] = useState(() => Math.random().toString());
const [uuid] = useState(() => crypto.randomUUID());
return (
<div id={id} data-uuid={uuid}>
Random content
</div>
);
}
水合保护hooks定义
接收两个参数(非必传):
fallback: 回调函数,在水合结束后调用。skipHydration:是否跳过水合判断。
核心思想:
useEffect只在客户端环境存在,及使用useEffect来保证当前是客户端环境。isMounted用于保证当前组件是挂载状态,避免组件卸载后调用。requestAnimationFrame确保在浏览器下一帧更新水合状态,届时可以确定浏览器dom已挂载、水合结束。
import { useState, useEffect, useRef } from 'react';
export function useHydration(options: any = {}) {
const { fallback, skipHydration = false } = options;
const [isHydrated, setIsHydrated] = useState(skipHydration);
const isMounted = useRef(false);
useEffect(() => {
isMounted.current = true;
if (!skipHydration) {
// 使用 requestAnimationFrame 确保在下一帧更新
const frame = requestAnimationFrame(() => {
// 保证当前组件是挂载状态
if (isMounted.current) {
setIsHydrated(true);
}
});
// 销毁实例防止内存泄露
return () => {
cancelAnimationFrame(frame);
isMounted.current = false;
};
}
return () => {
isMounted.current = false;
};
}, [skipHydration]);
return {
isHydrated,
fallbackValue: fallback,
};
}
使用方法
- 只判断是否水合结束
import { useHydration } from '@/hooks/use-hydration';
function UserProfile() {
const { isHydrated } = useHydration();
if (!isHydrated) {
return <div className="animate-pulse">加载中...</div>;
}
return (
<div>
<h1>欢迎回来!</h1>
<p>当前时间:{new Date().toLocaleString()}</p>
</div>
);
}
- 使用回调函数
import { useHydration } from '@/hooks/use-hydration';
function ThemeToggle() {
const { isHydrated, fallbackValue } = useHydration({
fallback: <div className="skeleton-loader">加载中...</div>
});
return isHydrated ? (
<button className="theme-toggle-btn">主题切换</button>
) : (
fallbackValue
);
}
···