项目亮点万金油:自定义SSR水合保护hooks

415 阅读4分钟

什么是水合错误

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,
  };
}

使用方法

  1. 只判断是否水合结束
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>
  );
}

  1. 使用回调函数
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
  );
}
···