React18.2.0+Next.js13.1.5主题切换引发Hydration失败问题求助
问题描述
使用 React 18.2.0 + Next.js 13.1.5 实现点击切换主题功能时,出现两个未处理的运行时错误:
Error: Hydration failed because the initial UI does not match what was rendered on the server
Error: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering
相关代码:
const [darkMode, setDarkMode] = useState(false); return ( <div className={darkMode ? "dark" : ""}> <Head> <title>Nared Fuengverojsakul</title> <meta name="description" content="Generated by create next app" /> <link rel="icon" href="/favicon.ico" /> </Head> <main className=" bg-white px-10 dark:bg-gray-900 md:px-20 lg:px-40"> <section className="min-h-screen"> <nav className="py-10 mb-12 flex justify-between dark:text-white"> <h1 className="font-burtons text-xl">PORTFOLIO</h1> <ul className="flex items-center"> <li> <BsFillMoonStarsFill onClick={() => setDarkMode(!darkMode)} className=" cursor-pointer text-2xl" /> </li> </ul> </nav> </section> </main> </div> );
错误原因
Next.js 采用服务端渲染(SSR),服务端会先生成静态 HTML 返回给客户端,客户端再进行「注水」操作匹配服务端的 DOM 结构。如果客户端初始渲染的 UI 与服务端生成的 HTML 不一致,就会触发 hydration 错误。
你的代码中,服务端初始渲染时 darkMode 为 false,根元素没有 dark 类;但如果客户端尝试从本地存储读取之前保存的主题状态(比如用户之前切换过深色模式),或者在注水过程中立刻修改状态,就会导致客户端与服务端的 DOM 结构不匹配,触发错误。
解决方案
1. 延迟初始化主题状态(推荐)
使用 useEffect 在客户端挂载后再读取本地存储的主题状态,保证服务端与客户端初始渲染的 HTML 一致:
import { useState, useEffect } from 'react'; import Head from 'next/head'; import { BsFillMoonStarsFill } from 'react-icons/bs'; export default function Home() { // 服务端初始状态为 false,确保服务端渲染的 HTML 统一 const [darkMode, setDarkMode] = useState(false); useEffect(() => { // 仅在客户端执行,读取本地存储的主题设置 const savedDarkMode = localStorage.getItem('darkMode'); if (savedDarkMode) { setDarkMode(JSON.parse(savedDarkMode)); } }, []); // 切换主题时同步保存到本地存储 const toggleTheme = () => { const newMode = !darkMode; setDarkMode(newMode); localStorage.setItem('darkMode', JSON.stringify(newMode)); }; return ( <div className={darkMode ? "dark" : ""}> <Head> <title>Nared Fuengverojsakul</title> <meta name="description" content="Generated by create next app" /> <link rel="icon" href="/favicon.ico" /> </Head> <main className=" bg-white px-10 dark:bg-gray-900 md:px-20 lg:px-40"> <section className="min-h-screen"> <nav className="py-10 mb-12 flex justify-between dark:text-white"> <h1 className="font-burtons text-xl">PORTFOLIO</h1> <ul className="flex items-center"> <li> <BsFillMoonStarsFill onClick={toggleTheme} className=" cursor-pointer text-2xl" /> </li> </ul> </nav> </section> </main> </div> ); }
2. 禁用组件的 SSR
使用 Next.js 的 dynamic 导入,让主题切换组件仅在客户端渲染,彻底避免服务端与客户端的状态差异:
// 单独创建 ThemeToggle 组件文件(比如 components/ThemeToggle.js) import { useState, useEffect } from 'react'; import { BsFillMoonStarsFill } from 'react-icons/bs'; export default function ThemeToggle() { const [darkMode, setDarkMode] = useState(false); useEffect(() => { const savedDarkMode = localStorage.getItem('darkMode'); if (savedDarkMode) { setDarkMode(JSON.parse(savedDarkMode)); } }, []); const toggleTheme = () => { const newMode = !darkMode; setDarkMode(newMode); localStorage.setItem('darkMode', JSON.stringify(newMode)); // 同步修改根元素类名 document.documentElement.classList.toggle('dark', newMode); }; return ( <BsFillMoonStarsFill onClick={toggleTheme} className=" cursor-pointer text-2xl" /> ); }
在页面中动态导入并禁用 SSR:
import dynamic from 'next/dynamic'; import Head from 'next/head'; const ThemeToggle = dynamic(() => import('../components/ThemeToggle'), { ssr: false }); export default function Home() { return ( <div> <Head> <title>Nared Fuengverojsakul</title> <meta name="description" content="Generated by create next app" /> <link rel="icon" href="/favicon.ico" /> </Head> <main className=" bg-white px-10 dark:bg-gray-900 md:px-20 lg:px-40"> <section className="min-h-screen"> <nav className="py-10 mb-12 flex justify-between dark:text-white"> <h1 className="font-burtons text-xl">PORTFOLIO</h1> <ul className="flex items-center"> <li> <ThemeToggle /> </li> </ul> </nav> </section> </main> </div> ); }
3. 使用 Suspense 包裹主题组件
用 Suspense 组件包裹主题切换按钮,即使 hydration 出错,也只会影响该组件,不会导致整个应用切换到客户端渲染:
import { useState, useEffect, Suspense } from 'react'; import Head from 'next/head'; import { BsFillMoonStarsFill } from 'react-icons/bs'; export default function Home() { const [darkMode, setDarkMode] = useState(false); useEffect(() => { const savedDarkMode = localStorage.getItem('darkMode'); if (savedDarkMode) { setDarkMode(JSON.parse(savedDarkMode)); } }, []); const toggleTheme = () => { const newMode = !darkMode; setDarkMode(newMode); localStorage.setItem('darkMode', JSON.stringify(newMode)); }; return ( <div className={darkMode ? "dark" : ""}> <Head> <title>Nared Fuengverojsakul</title> <meta name="description" content="Generated by create next app" /> <link rel="icon" href="/favicon.ico" /> </Head> <main className=" bg-white px-10 dark:bg-gray-900 md:px-20 lg:px-40"> <section className="min-h-screen"> <nav className="py-10 mb-12 flex justify-between dark:text-white"> <h1 className="font-burtons text-xl">PORTFOLIO</h1> <ul className="flex items-center"> <li> <Suspense fallback={<div>Loading...</div>}> <BsFillMoonStarsFill onClick={toggleTheme} className=" cursor-pointer text-2xl" /> </Suspense> </li> </ul> </nav> </section> </main> </div> ); }
内容的提问来源于stack exchange,提问作者Nared Fuengverojsakul

