Next.js 13服务端与客户端UI不匹配致Hydration失败问题求解
我在Next.js 13.1.0中实现了浅色/深色主题切换的ContextProvider,相关代码如下:
ContextProvider 代码
'use client'; import { Theme, ThemeContext } from '@store/theme'; import { ReactNode, useState, useEffect } from 'react'; interface ContextProviderProps { children: ReactNode } const ContextProvider = ({ children }: ContextProviderProps) => { const [theme, setTheme] = useState<Theme>('dark'); useEffect(() => { const storedTheme = localStorage.getItem('theme'); if (storedTheme === 'light' || storedTheme === 'dark') { setTheme(storedTheme); } else { localStorage.setItem('theme', theme); } // added to body because of overscroll-behavior document.body.classList.add(theme); return () => { document.body.classList.remove(theme); }; }, [theme]); const toggle = () => { const newTheme = theme === 'light' ? 'dark' : 'light'; setTheme(newTheme); localStorage.setItem('theme', newTheme); }; return ( <ThemeContext.Provider value={{ theme, toggle }}> {children} </ThemeContext.Provider> ); }; export { ContextProvider };
根布局代码
import '@styles/globals.scss'; import { GlobalContent } from '@components/GlobalContent/GlobalContent'; import { ContextProvider } from '@components/ContextProvider/ContextProvider'; import { Inter } from '@next/font/google'; import { ReactNode } from 'react'; const inter = Inter({ subsets: ['latin'] }); interface RootLayoutProps { children: ReactNode } const RootLayout = ({ children }: RootLayoutProps) => { return ( <html lang="en" className={inter.className}> <head /> <body> <ContextProvider> <GlobalContent> {children} </GlobalContent> </ContextProvider> </body> </html> ); }; export default RootLayout;
GlobalContent 组件代码
'use client'; import styles from '@components/GlobalContent/GlobalContent.module.scss'; import { GlobalHeader } from '@components/GlobalHeader/GlobalHeader'; import { GlobalFooter } from '@components/GlobalFooter/GlobalFooter'; import { ThemeContext } from '@store/theme'; import { ReactNode, useContext } from 'react'; interface GlobalContentProps { children: ReactNode } const GlobalContent = ({ children }: GlobalContentProps) => { const { theme } = useContext(ThemeContext); return ( <div className={`${theme === 'light' ? styles.lightTheme : styles.darkTheme}`}> <GlobalHeader /> <div className={styles.globalWrapper}> <main className={styles.childrenWrapper}> {children} </main> <GlobalFooter /> </div> </div> ); }; export { GlobalContent };
运行后出现错误:
Hydration failed because the initial UI does not match what was rendered on the server.
我已经在useEffect中访问localStorage,以为服务端生成的HTML和客户端首次渲染内容一致,请问该如何解决?
问题原因
虽然你在useEffect中获取localStorage,但useEffect是在客户端DOM挂载后才执行的。服务端渲染时,组件初始theme为'dark',生成的HTML对应darkTheme类;而客户端首次hydration时,初始状态也是'dark',但useEffect执行后如果localStorage中存储的是'light',会立刻更新theme,导致客户端DOM突然切换为lightTheme,和服务端生成的HTML不匹配,触发hydration错误。
同时,服务端没有document.body,所以服务端渲染的HTML中body不会添加主题类,而客户端useEffect执行后会给body添加类,这也会导致DOM不匹配。
解决方法
1. 初始化时直接获取客户端localStorage值
修改ContextProvider的状态初始化逻辑,在客户端直接读取localStorage,避免在hydration后才更新主题:
'use client'; import { Theme, ThemeContext } from '@store/theme'; import { ReactNode, useState, useEffect } from 'react'; interface ContextProviderProps { children: ReactNode } const ContextProvider = ({ children }: ContextProviderProps) => { // 客户端初始化时直接读取localStorage,服务端返回默认值'dark' const [theme, setTheme] = useState<Theme>(() => { if (typeof window !== 'undefined') { const storedTheme = localStorage.getItem('theme'); return storedTheme === 'light' || storedTheme === 'dark' ? storedTheme : 'dark'; } return 'dark'; }); useEffect(() => { // 处理body类名,以及初始化localStorage(如果没有存储的话) document.body.classList.add(theme); if (!localStorage.getItem('theme')) { localStorage.setItem('theme', theme); } return () => { document.body.classList.remove(theme); }; }, [theme]); const toggle = () => { const newTheme = theme === 'light' ? 'dark' : 'light'; setTheme(newTheme); localStorage.setItem('theme', newTheme); }; return ( <ThemeContext.Provider value={{ theme, toggle }}> {children} </ThemeContext.Provider> ); }; export { ContextProvider };
2. 添加hydration完成标记,避免初始不匹配
此时服务端渲染的还是默认'dark'主题,而客户端可能是'light',依然会有hydration不匹配的问题。需要在消费主题的组件中,等到hydration完成后再应用真实主题:
修改GlobalContent组件:
'use client'; import styles from '@components/GlobalContent/GlobalContent.module.scss'; import { GlobalHeader } from '@components/GlobalHeader/GlobalHeader'; import { GlobalFooter } from '@components/GlobalFooter/GlobalFooter'; import { ThemeContext } from '@store/theme'; import { ReactNode, useContext, useState, useEffect } from 'react'; interface GlobalContentProps { children: ReactNode } const GlobalContent = ({ children }: GlobalContentProps) => { const { theme } = useContext(ThemeContext); const [isHydrated, setIsHydrated] = useState(false); // hydration完成后标记状态 useEffect(() => { setIsHydrated(true); }, []); // 未完成hydration时使用服务端默认的darkTheme,完成后切换到真实主题 const currentThemeClass = isHydrated ? (theme === 'light' ? styles.lightTheme : styles.darkTheme) : styles.darkTheme; return ( <div className={currentThemeClass}> <GlobalHeader /> <div className={styles.globalWrapper}> <main className={styles.childrenWrapper}> {children} </main> <GlobalFooter /> </div> </div> ); }; export { GlobalContent };
3. 同步html标签的主题类(可选)
如果你的全局样式依赖html标签的主题类,可以在ContextProvider的useEffect中添加同步逻辑:
useEffect(() => { document.body.classList.add(theme); document.documentElement.classList.add(theme); // 添加html标签类 if (!localStorage.getItem('theme')) { localStorage.setItem('theme', theme); } return () => { document.body.classList.remove(theme); document.documentElement.classList.remove(theme); // 移除html标签类 }; }, [theme]);
原理说明
- 初始化时直接读取客户端localStorage,确保客户端首次渲染的主题和用户存储的一致。
- 添加
isHydrated状态,让客户端在hydration阶段先渲染和服务端一致的默认主题,完成后再切换到真实主题,避免DOM不匹配。 - 同步body和html的类名,确保全局样式正确应用。
内容的提问来源于stack exchange,提问作者dev_el

