You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Next.js中ThemeProvider出现className不匹配警告的解决方案咨询

Next.js 主题切换 Hydration 不匹配问题的解决方案

问题原因

服务端渲染时无法访问window和localStorage,导致useState惰性初始化函数默认返回light主题;但客户端初始化时会读取本地存储或系统配色偏好,返回实际主题(比如dark),最终服务端渲染的body.className与客户端不一致,触发Hydration警告。

自定义解决方案

方案1:客户端延迟初始化主题,避免服务端客户端差异

将主题初始化逻辑移至useEffect中,服务端先渲染无主题的占位状态,客户端挂载后再同步主题,彻底消除Hydration不匹配:

'use client'

import { createContext, useEffect, useState } from 'react'
import {
  type ThemeContextData,
  type ThemeProviderProps,
  type Themes
} from './types'
import { darkTheme, lightTheme } from '@/styles/themes.css'

export const ThemeContext = createContext<ThemeContextData>({} as ThemeContextData)

export function ThemeProvider({
  children,
  className
}: ThemeProviderProps): JSX.Element {
  // 初始状态设为null,表示未初始化
  const [theme, setTheme] = useState<Themes | null>(null)

  // 客户端挂载后读取主题并设置
  useEffect(() => {
    const savedTheme = window.localStorage.getItem('@matheussartori/theme') as Themes | null
    const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
    const initialTheme = savedTheme || systemTheme
    
    setTheme(initialTheme)
    window.localStorage.setItem('@matheussartori/theme', initialTheme)
  }, [])

  // 主题变更时同步本地存储
  useEffect(() => {
    if (theme) {
      window.localStorage.setItem('@matheussartori/theme', theme)
    }
  }, [theme])

  // 未初始化时,渲染基础className的body,避免Hydration冲突
  if (!theme) {
    return <body className={className}>{children}</body>
  }

  const themeClassNames = { light: lightTheme, dark: darkTheme }
  const classNames = className ? `${themeClassNames[theme]} ${className}` : themeClassNames[theme]

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <body className={classNames}>{children}</body>
    </ThemeContext.Provider>
  )
}

优点:实现简单,无需额外配置;缺点:客户端挂载前会短暂显示无主题状态,可能出现闪烁。


方案2:在HTML渲染阶段提前注入脚本设置主题

通过在_document.tsx中添加内联脚本,在React Hydration之前就设置好body的主题className,让服务端渲染的HTML与客户端最终状态一致:

  1. 创建app/_document.tsx(App Router):
import { Html, Head, Main, NextScript } from 'next/document'
import { darkTheme, lightTheme } from '@/styles/themes.css'

export default function Document() {
  return (
    <Html>
      <Head />
      <body>
        {/* 提前执行脚本设置主题className */}
        <script
          dangerouslySetInnerHTML={{
            __html: `
              (function() {
                const savedTheme = localStorage.getItem('@matheussartori/theme');
                const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
                const theme = savedTheme || systemTheme;
                const themeClass = theme === 'dark' ? '${darkTheme}' : '${lightTheme}';
                document.body.classList.add(themeClass);
              })();
            `,
          }}
        />
        <Main />
        <NextScript />
      </body>
    </Html>
  )
}
  1. 修改ThemeProvider,移除body标签(由_document.tsx管理),并同步主题变更:
'use client'

import { createContext, useEffect, useState } from 'react'
import {
  type ThemeContextData,
  type ThemeProviderProps,
  type Themes
} from './types'
import { darkTheme, lightTheme } from '@/styles/themes.css'

export const ThemeContext = createContext<ThemeContextData>({} as ThemeContextData)

export function ThemeProvider({
  children,
  className
}: ThemeProviderProps): JSX.Element {
  // 客户端初始化主题,服务端返回的默认值不影响
  const [theme, setTheme] = useState<Themes>(() => {
    if (typeof window !== 'undefined') {
      const savedTheme = window.localStorage.getItem('@matheussartori/theme') as Themes | null
      const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
      return savedTheme || systemTheme
    }
    return 'light'
  })

  // 主题变更时同步body类名和本地存储
  useEffect(() => {
    document.body.classList.remove(lightTheme, darkTheme)
    document.body.classList.add(theme === 'dark' ? darkTheme : lightTheme)
    window.localStorage.setItem('@matheussartori/theme', theme)
  }, [theme])

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}
  1. 更新layout.tsx,调整结构:
import { Inter } from 'next/font/google'
import '@/styles/global.css'
import { ThemeProvider } from '@/contexts/themes/ThemeProvider'

const inter = Inter({ subsets: ['latin'] })

export const metadata = {
  title: 'lorem',
  description: 'lorem'
}

export default function RootLayout({
  children
}: {
  children: React.ReactNode
}): JSX.Element {
  return (
    <html lang="en">
      <body className={inter.className}>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}

优点:无主题闪烁,Hydration完全匹配;缺点:需要维护_document.tsx中的脚本与主题类名的一致性。


方案3:使用成熟的主题库(推荐)

直接使用next-themes库,它已经内置处理了Next.js SSR/SSG下的Hydration问题,无需手动处理复杂逻辑:

  1. 安装依赖:
npm install next-themes
  1. 替换自定义的ThemeProvider:
'use client'

import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { type ThemeProviderProps } from './types'

export function ThemeProvider({ children }: ThemeProviderProps) {
  return (
    <NextThemesProvider
      attribute="class"
      defaultTheme="system"
      storageKey="@matheussartori/theme"
    >
      {children}
    </NextThemesProvider>
  )
}
  1. 在CSS中定义主题类:
.dark {
  /* 深色主题样式 */
}

.light {
  /* 浅色主题样式 */
}

优点:开箱即用,无需手动处理Hydration和存储逻辑;缺点:依赖第三方库。

内容的提问来源于stack exchange,提问作者Matheus Sartori

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.22 22:34:54