Next 14使用自定义useIsMobile钩子出现React水合错误求助
我一直用自定义React钩子useIsMobile判断视口宽度是否小于阈值,之前所有项目都正常。最近用Next 14建新项目时,出现了这个错误:
Error: Text content does not match server-rendered HTML.
调试后发现问题出在这个钩子上:服务端预渲染的是“移动端版本”内容,但客户端首次渲染的是正确的“桌面端版本”,导致服务端和客户端的React树不匹配。就算加了'use client'指令也没用,甚至CSS媒体查询偶尔也会触发这个错误,直接被忽略。
以下是我使用的钩子代码及调用示例:
useWindowSize钩子代码
'use client'; import { useState, useEffect } from 'react'; const useWindowSize = () => { const [windowSize, setWindowSize] = useState({ width: typeof window !== 'undefined' ? window?.innerWidth : null, height: typeof window !== 'undefined' ? window?.innerHeight : null, }); useEffect(() => { const handleResize = () => { if (typeof window !== 'undefined') { setWindowSize({ width: window?.innerWidth, height: window?.innerHeight, }); } }; window?.addEventListener('resize', handleResize); handleResize(); return () => window?.removeEventListener('resize', handleResize); }, []); return windowSize; }; export default useWindowSize;
useIsMobile钩子代码
'use client'; import { useState, useEffect } from 'react'; import useWindowSize from './useWindowSize'; const useIsMobile = (breakpoint: number = 768): boolean => { const { width } = useWindowSize()!; const [isMobile, setMobile] = useState<boolean>( width! <= (breakpoint || 768) ); useEffect(() => { setMobile(width! <= (breakpoint || 768)); }, [width, breakpoint]); return isMobile; }; export default useIsMobile;
调用示例
const isMobile = useIsMobile(500); return ( <div className={styles.nav}> {!isMobile && <div className={styles.logo}>Logo</div>}
修复方案
核心问题
服务端渲染时没有window对象,初始width为null,而null <= 数字在JS中会被判定为true,导致服务端渲染移动端内容;但客户端初始化时window.innerWidth是真实的桌面宽度,首次渲染为桌面端,直接触发Hydration不匹配。
具体修复步骤
调整useWindowSize的初始状态
服务端直接将初始尺寸设为undefined,避免服务端与客户端初始值差异,仅在客户端通过useEffect获取真实窗口尺寸:'use client'; import { useState, useEffect } from 'react'; const useWindowSize = () => { const [windowSize, setWindowSize] = useState<{ width?: number; height?: number }>({}); useEffect(() => { const handleResize = () => { setWindowSize({ width: window.innerWidth, height: window.innerHeight, }); }; window.addEventListener('resize', handleResize); handleResize(); // 客户端初始化时立即获取尺寸 return () => window.removeEventListener('resize', handleResize); }, []); return windowSize; }; export default useWindowSize;修改useIsMobile的状态逻辑
初始状态设为undefined,直到客户端获取到真实窗口尺寸后再更新状态,避免服务端和客户端首次渲染内容不一致:'use client'; import { useState, useEffect } from 'react'; import useWindowSize from './useWindowSize'; const useIsMobile = (breakpoint: number = 768): boolean | undefined => { const { width } = useWindowSize(); const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined); useEffect(() => { if (width !== undefined) { setIsMobile(width <= breakpoint); } }, [width, breakpoint]); return isMobile; }; export default useIsMobile;组件中处理未初始化状态
在调用钩子的组件里,先判断isMobile是否为undefined,可以显示空占位或加载态,等客户端获取尺寸后再切换到对应内容:const isMobile = useIsMobile(500); // Hydration阶段先渲染兼容服务端的占位内容 if (isMobile === undefined) { return <div className={styles.nav}></div>; } return ( <div className={styles.nav}> {!isMobile && <div className={styles.logo}>Logo</div>} </div> );优先使用CSS媒体查询替代JS判断
如果只是控制元素显示隐藏,尽量用CSS媒体查询,避免Hydration问题:.logo { display: block; } @media (max-width: 500px) { .logo { display: none; } }这种方式下服务端和客户端渲染的HTML结构完全一致,仅通过CSS控制显示,不会触发Hydration错误。
内容的提问来源于stack exchange,提问作者Oliver

