Next.js响应式渲染组件引发Hydration失败问题求助
问题背景
我用Next.js开发Header组件,原代码如下:
// 原header.tsx代码 import Image from 'next/image'; import { HeaderMenu } from './menu/headerMenu'; import { HamburgerButtons, HamburgerMenu } from './menu/hamburgerMenu'; import { useState } from 'react'; import SearchForm from './searchForm'; import classNames from 'classnames'; import SmartLink from './links'; const Header = ({ isFixed }: { isFixed?: boolean }) => { const [isHamburgerOpen, toggleHamburger] = useState<boolean>(false); const toggleHamburgerMenu = (): void => { isHamburgerOpen ? toggleHamburger(false) : toggleHamburger(true); } return ( <header className={classNames(isFixed && 'fixed')}> <SmartLink href="/"> <Image src="/logos/eeat-low-resolution-logo-color-on-transparent-background.svg" alt="Logo" width="70" height="85"/> </SmartLink> <HeaderMenu/> <div className="flex flexItemsCenter"> <SearchForm placeholder="SEARCH..."/> <HamburgerButtons onClick={toggleHamburgerMenu} isHamburgerOpen={isHamburgerOpen}/> </div> <HamburgerMenu isHamburgerOpen={isHamburgerOpen}/> </header> ) }; export default Header;
为了在桌面端(视窗宽度≥992px)隐藏汉堡按钮和菜单,我引入react-responsive的useMediaQuery做判断,修改后的代码:
// 修改后的header.tsx import Image from 'next/image'; import { useMediaQuery } from 'react-responsive'; import { HeaderMenu } from './menu/headerMenu'; import { HamburgerButtons, HamburgerMenu } from './menu/hamburgerMenu'; import { useState } from 'react'; import SearchForm from './searchForm'; import classNames from 'classnames'; import SmartLink from './links'; const Header = ({ isFixed }: { isFixed?: boolean }) => { const [isHamburgerOpen, toggleHamburger] = useState<boolean>(false); const toggleHamburgerMenu = (): void => { isHamburgerOpen ? toggleHamburger(false) : toggleHamburger(true); } const isDesktop = useMediaQuery({ query: '(min-width: 992px)' }); return ( <header className={classNames(isFixed && 'fixed')}> <SmartLink href="/"> <Image src="/logos/eeat-low-resolution-logo-color-on-transparent-background.svg" alt="Logo" width="70" height="85"/> </SmartLink> <HeaderMenu/> <div className="flex flexItemsCenter"> <SearchForm placeholder="SEARCH..."/> {!isDesktop && <HamburgerButtons onClick={toggleHamburgerMenu} isHamburgerOpen={isHamburgerOpen}/>} </div> {!isDesktop && <HamburgerMenu isHamburgerOpen={isHamburgerOpen}/>} </header> ) }; export default Header;
汉堡组件代码:
// hamburgerMenu.tsx import classnames from 'classnames' import { HeaderMenu } from './headerMenu' export const HamburgerButtons = ({ onClick, isHamburgerOpen }: { onClick: () => void, isHamburgerOpen: boolean }) => ( <div id="HamburgerButtons" className={classnames(isHamburgerOpen && 'hamburgerOpen')} onClick={onClick}> <span></span> <span></span> <span></span> </div> ) export const HamburgerMenu = ({ isHamburgerOpen }: { isHamburgerOpen: boolean }) => { return ( <div id="Hamburger" className={classnames(isHamburgerOpen && 'hamburgerMenuOpen')}> <HeaderMenu id="HamburgerHeaderMenu"/> </div> ) }
现在在桌面尺寸下刷新页面会报错:
Error: Hydration failed because the initial UI does not match what was rendered on the server.
但移动端尺寸启动页面一切正常,请问如何解决?
错误原因
核心问题是服务端渲染(SSR)时没有浏览器的window对象,react-responsive的useMediaQuery在服务端无法获取真实视窗尺寸,会返回默认值(通常为false);而客户端能正确读取视窗宽度,导致服务端渲染的DOM结构和客户端hydrate时的DOM结构不一致,触发Hydration不匹配错误。
解决方案
方案1:动态导入组件并禁用SSR
用Next.js的next/dynamic动态导入汉堡相关组件,设置ssr: false让组件只在客户端渲染,避免服务端和客户端的判断差异:
import Image from 'next/image'; import { useState, useEffect } from 'react'; import { HeaderMenu } from './menu/headerMenu'; import SearchForm from './searchForm'; import classNames from 'classnames'; import SmartLink from './links'; import dynamic from 'next/dynamic'; // 动态导入汉堡组件,禁用SSR const HamburgerButtons = dynamic(() => import('./menu/hamburgerMenu').then(mod => mod.HamburgerButtons), { ssr: false }); const HamburgerMenu = dynamic(() => import('./menu/hamburgerMenu').then(mod => mod.HamburgerMenu), { ssr: false }); const Header = ({ isFixed }: { isFixed?: boolean }) => { const [isHamburgerOpen, toggleHamburger] = useState<boolean>(false); const [isDesktop, setIsDesktop] = useState(false); const toggleHamburgerMenu = (): void => { toggleHamburger(!isHamburgerOpen); }; // 在客户端挂载后判断视窗尺寸 useEffect(() => { const checkIsDesktop = () => { setIsDesktop(window.matchMedia('(min-width: 992px)').matches); }; // 初始化判断 checkIsDesktop(); // 监听视窗变化 window.addEventListener('resize', checkIsDesktop); return () => window.removeEventListener('resize', checkIsDesktop); }, []); return ( <header className={classNames(isFixed && 'fixed')}> <SmartLink href="/"> <Image src="/logos/eeat-low-resolution-logo-color-on-transparent-background.svg" alt="Logo" width="70" height="85"/> </SmartLink> <HeaderMenu/> <div className="flex flexItemsCenter"> <SearchForm placeholder="SEARCH..."/> {!isDesktop && <HamburgerButtons onClick={toggleHamburgerMenu} isHamburgerOpen={isHamburgerOpen}/>} </div> {!isDesktop && <HamburgerMenu isHamburgerOpen={isHamburgerOpen}/>} </header> ) }; export default Header;
方案2:用useEffect延迟视窗判断
不依赖react-responsive,在客户端组件挂载后用window.matchMedia判断视窗尺寸,确保只在客户端执行判断:
// 替换header.tsx中原有的useMediaQuery代码 const [isDesktop, setIsDesktop] = useState(false); useEffect(() => { const mediaQuery = window.matchMedia('(min-width: 992px)'); // 初始化设置 setIsDesktop(mediaQuery.matches); // 监听视窗变化 const handleResize = () => setIsDesktop(mediaQuery.matches); mediaQuery.addEventListener('change', handleResize); return () => mediaQuery.removeEventListener('change', handleResize); }, []);
服务端渲染时isDesktop初始值为false,组件挂载后会立刻更新为客户端实际的视窗状态,虽然会有一次重渲染,但不会触发Hydration错误。
方案3:App Router下使用'use client'指令
如果项目用Next.js 13+的App Router,给Header组件加上'use client'标记,让组件完全在客户端渲染,此时react-responsive可以正常工作:
'use client'; // 标记为客户端组件 import Image from 'next/image'; import { useMediaQuery } from 'react-responsive'; import { HeaderMenu } from './menu/headerMenu'; import { HamburgerButtons, HamburgerMenu } from './menu/hamburgerMenu'; import { useState } from 'react'; import SearchForm from './searchForm'; import classNames from 'classnames'; import SmartLink from './links'; const Header = ({ isFixed }: { isFixed?: boolean }) => { const [isHamburgerOpen, toggleHamburger] = useState<boolean>(false); const toggleHamburgerMenu = (): void => { toggleHamburger(!isHamburgerOpen); }; // 客户端组件中useMediaQuery可以正常工作 const isDesktop = useMediaQuery({ query: '(min-width: 992px)' }); return ( <header className={classNames(isFixed && 'fixed')}> <SmartLink href="/"> <Image src="/logos/eeat-low-resolution-logo-color-on-transparent-background.svg" alt="Logo" width="70" height="85"/> </SmartLink> <HeaderMenu/> <div className="flex flexItemsCenter"> <SearchForm placeholder="SEARCH..."/> {!isDesktop && <HamburgerButtons onClick={toggleHamburgerMenu} isHamburgerOpen={isHamburgerOpen}/>} </div> {!isDesktop && <HamburgerMenu isHamburgerOpen={isHamburgerOpen}/>} </header> ) }; export default Header;
内容的提问来源于stack exchange,提问作者game lover

