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

什么是水合错误

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接管页面,使其变得交互式。这个过程就叫"水合"。

水合错误怎么产生:

  • 服务器端渲染与客户端渲染不一致:如果服务器生成的HTML与客户端JavaScript期望的DOM结构不匹配,就会导致水合失败。
  • 数据不匹配:服务器端和客户端获取的数据不同步。
  • 时机问题:客户端脚本在HTML完全加载之前运行,导致无法正确绑定事件或更新DOM。
  • ......

日常开发遇到水合错误的情况

案例1:服务器和客户端数据不一致

  • 服务器返回data.name = 'Alice'
  • 客户端获取data.name = 'Bob'(假设 API 返回最新数据)
  • 水合错误 :服务器渲染时显示Alice,客户端渲染时显示Bob,导致 React 报错。
javascript 复制代码
// 页面组件
export default function Page({ data }) {
  const [userData, setUserData] = useState(data);

  useEffect(() => {
    // 客户端获取最新数据
    fetch('/api/data')
      .then(res => res.json())
      .then(data => setUserData(data));
  }, []);

  return <div>{userData.name}</div>; //Bob
}

// 服务器端获取数据
export async function getServerSideProps() {
  const response = await fetch('https://api.example.com/data');
  const data = await response.json();
  return { props: { data: { name: 'Alice' } } };
}

案例2:浏览器API直接访问

  • 服务器环境:window is undefined
  • 客户端环境:window.innerWidth = 1024
  • 水合错误:服务器无法执行浏览器API相关操作
javascript 复制代码
// 错误实现
function BrokenWindowSize() {
  // 🚫 问题点:直接访问 window 对象
  const [width, setWidth] = useState(window.innerWidth);

  return <div>Window width: {width}px</div>;
}

// 正确实现
function FixedWindowSize() {
  const { isHydrated } = useHydration();
  const [width, setWidth] = useState(0);

  useEffect(() => {
    if (isHydrated) { // ✅ 安全访问浏览器API
      const handleResize = () => setWidth(window.innerWidth);
      window.addEventListener('resize', handleResize);
      return () => window.removeEventListener('resize', handleResize);
    }
  }, [isHydrated]);

  return (
    <div>
      {isHydrated ? `Window width: ${width}px` : 'Measuring...'}
    </div>
  );
}

案例3:动态内容渲染

  • 服务器渲染:Date.now() =1717020000000(静态生成时间)
  • 客户端渲染:Date.now()= 1717020001000(访问时间)
  • 水合错误:时间戳不一致导致内容不匹配
javascript 复制代码
// 错误实现
function BrokenTimestamp() {
  // 🚫 问题点:直接使用动态时间值
  const [time, setTime] = useState(Date.now());

  return <div>Current time: {new Date(time).toLocaleString()}</div>;
}

// 正确实现
function FixedTimestamp() {
  const { isHydrated } = useHydration();
  const [time, setTime] = useState(0);

  useEffect(() => {
    if (isHydrated) { // ✅ 延迟获取动态时间
      setTime(Date.now());
      const timer = setInterval(() => setTime(Date.now()), 1000);
      return () => clearInterval(timer);
    }
  }, [isHydrated]);

  return (
    <div>
      {isHydrated ? 
        `Current time: ${new Date(time).toLocaleString()}` : 
        'Loading time...'}
    </div>
  );
}

案例4:未处理加载状态

  • 服务器渲染时data已经包含了初始值,直接渲染。
  • 客户端渲染时useEffect获取最新数据,可能导致状态更新。
  • 水合错误:如果客户端获取的数据与服务器返回的数据不同,渲染时会报错。
javascript 复制代码
// 页面组件
export default function Page({ data }) {
  const [userData, setUserData] = useState(data);

  useEffect(() => {
    if (typeof window !== 'undefined') {
      fetch('/api/data')
        .then(res => res.json())
        .then(data => setUserData(data));
    }
  }, []);

  if (!userData) {
    return <div>Loading...</div>;
  }

  return <div>{userData.name}</div>;
}

// 服务器端获取数据
export async function getServerSideProps() {
  const response = await fetch('https://api.example.com/data');
  const data = await response.json();
  return { props: { data } };
}

案例5:在服务端渲染组件使用react hooks

因为react hooks是依赖于浏览器环境的及客户端环境,所以无法在服务端使用,这里就不放案例。


水合保护hooks讲解

接收四个参数,第一个是用于接收水合过程中的占位loading组件,第二个用于水合延迟时间,第三个接收水合完成后调用的回调函数,第四个用于接收是否跳过水合保护默认是false。

返回三个参数,第一个是水合是否完成,第二个是占位loading实例,第三个是强制水合函数。

核心思想是用setTimeout+raf嵌套延迟加载完成水合保护。

第一层setTimeout用于在于两点:1.防止服务端环境raf报错。2.将更新推迟到下一帧。

第二层requestAnimationFrame用于精准在当前帧渲染前及下一帧渲染前(上一帧被推迟了)调用。


自定义水合保护hooks源码

typescript 复制代码
import { useState, useEffect, useRef, ReactNode } from 'react';

/**
 * 水合保护 Hook 配置选项接口
 * @template T - fallback 值的类型
 */
interface HydrationOptions<T> {
  /** 水合前显示的内容 */
  fallback?: T | ReactNode;
  /** 延迟水合的时间(毫秒) */
  delay?: number;
  /** 水合完成后的回调函数 */
  onHydrated?: () => void;
  /** 是否跳过自动水合过程 */
  skipHydration?: boolean;
}

/**
 * 水合保护 Hook 返回值接口
 * @template T - fallback 值的类型
 */
interface HydrationResult<T> {
  /** 是否已完成水合 */
  isHydrated: boolean;
  /** 水合前显示的内容 */
  fallbackValue: T | ReactNode;
  /** 手动触发水合的函数 */
  forceHydration: () => void;
}

/**
 * 水合保护 Hook - 用于防止服务端渲染与客户端渲染内容不一致导致的水合错误
 * 
 * 该 Hook 解决的问题:
 * 1. 在 SSR 环境中,服务器无法访问浏览器 API (window, document 等)
 * 2. 当组件依赖这些 API 时,服务端渲染和客户端渲染的结果会不一致
 * 3. 这种不一致会导致 React 水合错误
 * 
 * @param options - 水合配置选项
 * @returns 包含水合状态和控制函数的对象
 */
export function useHydration<T = unknown>(options: HydrationOptions<T> = {}): HydrationResult<T> {
  // 解构配置选项,设置默认值
  const {
    fallback,         // 水合前显示的内容
    delay = 0,        // 延迟水合的时间,默认为 0
    onHydrated,       // 水合完成后的回调函数
    skipHydration = false  // 是否跳过自动水合,默认为 false
  } = options;
  
  // 使用 ref 跟踪组件是否已挂载到 DOM
  // 这可以防止在组件卸载后仍然尝试更新状态
  const isMounted = useRef(false);
  
  // 使用 state 跟踪水合状态
  // 初始值为 false,表示尚未水合
  const [isHydrated, setIsHydrated] = useState(false);
  
  // 使用 ref 存储 fallback 值
  // 这样可以避免 fallback 变化导致的重新渲染
  const fallbackValue = useRef(fallback);
  
  /**
   * 强制触发水合的函数
   * 当需要手动控制水合时间点时使用
   */
  const forceHydration = () => {
    // 只有在组件已挂载且尚未水合的情况下才执行
    if (isMounted.current && !isHydrated) {
      setIsHydrated(true);
      // 如果提供了回调函数,则调用它
      onHydrated?.();
    }
  };
  
  // 使用 useEffect 处理水合逻辑
  // 这个 effect 只在组件挂载和 delay 变化时运行
  useEffect(() => {
    // 标记组件已挂载
    isMounted.current = true;
    
    // 如果设置了跳过水合,则直接返回清理函数
    // 这允许用户完全控制水合时机
    if (skipHydration) {
      return () => {
        isMounted.current = false;
      };
    }
    
    // 用于存储定时器 ID,以便在组件卸载时清除
    let timer: NodeJS.Timeout;
    
    // 处理有延迟的情况
    if (delay > 0) {
      // 设置定时器,在指定延迟后触发水合
      timer = setTimeout(() => {
        // 再次检查组件是否仍然挂载
        if (isMounted.current) {
          setIsHydrated(true);
          // 调用水合完成回调
          onHydrated?.();
        }
      }, delay);
    } else {
      // 处理无延迟的情况
      // 使用 setTimeout + requestAnimationFrame 确保在下一帧渲染前完成
      // 这比直接设置状态更安全,可以避免某些边缘情况下的问题
      if (typeof window !== 'undefined') {
        timer = setTimeout(() => {
          // requestAnimationFrame 确保在浏览器下一次重绘之前调用
          requestAnimationFrame(() => {
            if (isMounted.current) {
              setIsHydrated(true);
              onHydrated?.();
            }
          });
        }, 0);
      }
    }
    
    // 返回清理函数
    // 在组件卸载或依赖项变化时执行
    return () => {
      // 清除定时器,防止内存泄漏
      clearTimeout(timer);
      // 标记组件已卸载
      isMounted.current = false;
    };
  }, [delay, onHydrated, skipHydration]); // 依赖项列表
  
  // 返回水合状态、备用值和强制水合函数
  return {
    isHydrated,                  // 当前水合状态
    fallbackValue: fallbackValue.current,  // 水合前显示的内容
    forceHydration               // 手动触发水合的函数
  };
}

使用示例:

javascript 复制代码
const { isHydrated, fallbackValue, forceHydration } = useHydration({
  fallback: <Loading/>, // 水合前的占位loading组件
  delay: 0, // 延迟 0ms 水合
  onHydrated: () => console.log("Theme toggle hydrated"),
});
相关推荐
web_155342746566 分钟前
SpringMVC 请求参数接收
前端·javascript·算法
LoveYa!17 分钟前
HTML5 初探:新特性与本地存储的魔法
前端·笔记·学习·html·html5
你脸上有BUG28 分钟前
前端样式库推广——TailwindCss
前端·css·样式·tailwindcss
_未知_开摆1 小时前
Error: The project seems to require pnpm but it‘s not installed.
前端·webpack·vue
肉肉不吃 肉1 小时前
父子组件传递数据和状态管理数据
前端·javascript·vue.js·pinia
不会叫的狼1 小时前
回调方法传参汇总
前端
霸王蟹1 小时前
Vue3自定义指令实现前端权限控制 - 按钮权限
前端·javascript·vue.js·笔记·学习·html
前端极客探险家1 小时前
《Next.js 14 App Router 实战:用「Server Actions」重构全栈表单的最佳实践》
开发语言·javascript·重构
Fighting_p2 小时前
【el-upload】el-upload组件 - list-type=“picture“ 时,文件预览展示优化
javascript·vue.js·ecmascript
nt11072 小时前
大模型实现sql生成 --- 能力不足时的retry
前端·langchain·llm