切换语言
切换主题

Next.js 暗黑模式实现:next-themes 完全指南

说实话,我第一次在 Next.js 项目里做暗黑模式的时候,真的被坑惨了。页面加载的那一瞬间,先是白光一闪,然后才切换到暗黑模式——那个闪烁的效果简直让人抓狂。用户在评论里吐槽说”眼睛都要被闪瞎了”,我才意识到这个问题有多严重。

后来试了好几个方案,自己手写、用 use-dark-mode 库,甚至翻了无数篇教程,最后发现 next-themes 才是真正的救星。现在我的项目用的全是它,零闪烁,配置超简单,系统主题也能完美跟随。这篇文章就把我踩过的坑和找到的解决方案都分享给你。

为什么我最终选择了 next-themes

刚开始我也纠结过要不要自己写一套主题切换逻辑。毕竟就是读个 localStorage,改个 class 嘛,看起来很简单。但实际动手才发现,Next.js 的服务端渲染特性让这件事变得异常复杂。

我试过几种方案:

手写方案:最大的问题就是闪烁。因为 SSR 的时候服务器不知道用户的主题偏好,渲染出来的是默认的亮色主题,等到客户端 hydration 的时候才能读取 localStorage,这时候切换到暗色主题就会有明显的闪烁。

use-dark-mode:这个库其实不错,但它不是专门为 Next.js 设计的,在 SSR 场景下还是会有一些兼容问题。

theme-ui:功能很强大,但对于只需要暗黑模式切换的场景来说太重了,bundle size 也大。

最后我发现了 next-themes,GitHub 上 6000+ Star,专门为 Next.js 设计,零依赖,体积只有不到 1kb gzipped。关键是它真的做到了零闪烁,开箱即用的系统主题支持,还有自动持久化。TypeScript 支持也很完善,用起来非常舒服。

完整实现步骤

安装依赖

老规矩,先装包:

npm install next-themes

或者你用 pnpm、yarn 都行:

pnpm add next-themes
# 或
yarn add next-themes

创建 ThemeProvider 组件

接下来要创建一个 Provider 组件。我一般会在项目里建一个 providers 或者 components 目录来放这类东西。

创建文件 providers/theme-provider.tsx

'use client'

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

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

注意这里必须标记为 'use client',因为 next-themes 需要访问浏览器的 API。这是我当初踩的第一个坑——一开始没加这个标记,报了一堆 hydration 错误。

在 Layout 中集成

现在把 ThemeProvider 加到你的根布局里。如果你用的是 App Router(Next.js 13+),应该是 app/layout.tsx

import { ThemeProvider } from '@/providers/theme-provider'
import './globals.css'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="zh-CN" suppressHydrationWarning>
      <body>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}

这里有几个关键配置,我一个个解释:

attribute="class":告诉 next-themes 通过修改 <html> 元素的 class 来切换主题。配合 Tailwind CSS 的 dark: 前缀使用非常方便。

defaultTheme="system":默认跟随系统主题。用户第一次访问的时候会自动检测操作系统的主题偏好。

enableSystem:启用系统主题检测功能。这个必须开启,否则 defaultTheme="system" 不会生效。

disableTransitionOnChange:禁用切换时的过渡动画。这个可以根据你的需求调整,但我建议开启,因为暗黑模式切换时如果有过渡动画,会看到所有元素一起动画,视觉效果反而不太好。

suppressHydrationWarning:这个是加在 <html> 标签上的,非常重要!因为 next-themes 会在客户端 hydration 之前修改 html 元素的 class,如果不加这个属性,React 会报 warning。

创建主题切换按钮

有了 Provider,现在就可以做一个切换按钮了。创建 components/theme-toggle.tsx

'use client'

import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'

export function ThemeToggle() {
  const [mounted, setMounted] = useState(false)
  const { theme, setTheme } = useTheme()

  useEffect(() => {
    setMounted(true)
  }, [])

  if (!mounted) {
    return null
  }

  return (
    <button
      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
      className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
      aria-label="切换主题"
    >
      {theme === 'dark' ? '🌞' : '🌙'}
    </button>
  )
}

这里有个小技巧:在组件加载完成之前返回 null。为什么要这样做?因为服务端渲染的时候拿不到主题信息,如果直接渲染会导致 hydration mismatch。等到客户端 mounted 之后,useTheme 才能正确返回当前主题。

如果你想做一个三态切换(light / dark / system),可以这样写:

export function ThemeToggle() {
  const [mounted, setMounted] = useState(false)
  const { theme, setTheme } = useTheme()

  useEffect(() => {
    setMounted(true)
  }, [])

  if (!mounted) return null

  const cycleTheme = () => {
    if (theme === 'light') setTheme('dark')
    else if (theme === 'dark') setTheme('system')
    else setTheme('light')
  }

  const getIcon = () => {
    if (theme === 'light') return '🌞'
    if (theme === 'dark') return '🌙'
    return '💻'
  }

  return (
    <button
      onClick={cycleTheme}
      className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
    >
      {getIcon()}
    </button>
  )
}

闪烁问题深度解析

其实当初让我下决心研究这个问题的,就是那个烦人的闪烁。我花了不少时间才彻底搞明白它的原理。

FOUC 是怎么产生的

FOUC(Flash of Unstyled Content)在 Next.js 的暗黑模式实现中特别常见。问题的根源在于 SSR 和客户端状态不一致。

你想啊,服务端渲染的时候,Node.js 环境里没有 window 对象,也拿不到 localStorage,更不知道用户的系统主题偏好。所以服务端只能渲染一个默认主题(通常是亮色)。

然后 HTML 发送到浏览器,开始 hydration。这时候 React 要把服务端渲染的静态 HTML 变成可交互的组件。这个过程中,JavaScript 才能读取 localStorage,发现用户之前选的是暗色主题,于是修改 DOM,加上 dark class。

这个修改就会引起重新渲染,所有的样式都要从亮色切换到暗色——闪烁就是这么来的。

next-themes 的解决方案

next-themes 的解决方案很巧妙:它会在 <head> 里注入一个 blocking script。这个 script 会在页面渲染之前执行,立即读取 localStorage 里的主题设置,然后给 <html> 元素加上对应的 class。

大概是这样的逻辑:

(function() {
  try {
    const theme = localStorage.getItem('theme')
    const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
    const currentTheme = theme || systemTheme
    
    if (currentTheme === 'dark') {
      document.documentElement.classList.add('dark')
    }
  } catch (e) {}
})()

因为这个 script 是同步执行的,会阻塞页面渲染,所以能保证在任何内容显示之前,正确的主题 class 就已经设置好了。这样一来,CSS 从一开始就应用了正确的样式,自然就不会有闪烁了。

常见错误配置

我见过很多人配置出问题,主要集中在这几个点:

忘记加 suppressHydrationWarning

如果你忘了在 <html> 标签上加这个属性,控制台会一直报这样的警告:

Warning: Prop `className` did not match. Server: "" Client: "dark"

虽然不影响功能,但看着烦。

ThemeProvider 位置不对

有人把 ThemeProvider 放在了 Server Component 里,或者放在 body 外面,都会有问题。记住,ThemeProvider 必须包裹你的页面内容,而且必须是 Client Component。

Tailwind 配置错误

如果你的 tailwind.config.js 是这样的:

module.exports = {
  darkMode: 'media',
}

那肯定有问题。media 模式是纯 CSS 方案,只能跟随系统主题,无法手动切换。应该改成:

module.exports = {
  darkMode: 'class',
}

主题持久化与系统跟随

持久化机制

next-themes 默认会把你的主题选择保存到 localStorage 里,key 是 'theme'。这个行为是自动的,你不需要写任何额外代码。

如果你想自定义 storage key,可以这样配置:

<ThemeProvider
  attribute="class"
  defaultTheme="system"
  enableSystem
  storageKey="my-theme"
>
  {children}
</ThemeProvider>

在某些场景下,你可能需要用 Cookie 而不是 localStorage。比如你想在服务端就知道用户的主题偏好,避免任何可能的闪烁。这时候可以这样做:

  1. 在中间件里读取 Cookie,设置到响应头
  2. 服务端渲染时根据响应头渲染对应主题
  3. 客户端同步 Cookie 和 localStorage

不过老实讲,对于大多数场景,next-themes 默认的方案已经够用了。

系统主题跟随

enableSystem 配置项让 next-themes 可以监听系统主题的变化。当用户在操作系统设置里切换深色/浅色模式时,如果你的应用当前主题是 system,就会自动跟随切换。

底层实现是监听 prefers-color-scheme 媒体查询:

window.matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', (e) => {
    // 主题切换逻辑
  })

用户也可以手动覆盖系统主题。比如系统是亮色模式,但他在你的网站上切换到暗色,next-themes 会记住这个选择,下次访问还是暗色。

多主题支持

虽然我们主要讨论的是暗黑模式,但 next-themes 其实支持任意多个主题。比如你可以做一个紫色主题、绿色主题之类的:

<ThemeProvider
  attribute="class"
  defaultTheme="system"
  enableSystem
  themes={['light', 'dark', 'purple', 'green']}
>
  {children}
</ThemeProvider>

然后在 CSS 里定义对应的样式:

.purple {
  --background: #f3e8ff;
  --foreground: #581c87;
}

.green {
  --background: #dcfce7;
  --foreground: #14532d;
}

配合 CSS 变量使用非常灵活。

实战技巧与常见问题

配合 Tailwind CSS

如果你用 Tailwind,配置就更简单了。首先确保 tailwind.config.js 里设置了:

module.exports = {
  darkMode: 'class',
  // 其他配置...
}

然后就可以愉快地使用 dark: 前缀了:

<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
  <h1 className="text-2xl font-bold">标题</h1>
  <p className="text-gray-600 dark:text-gray-400">段落文本</p>
</div>

Tailwind 的 dark: 变体会在 <html> 元素有 dark class 的时候生效,跟 next-themes 的工作方式完美契合。

动画与过渡

关于 disableTransitionOnChange 这个配置,我个人建议开启。因为如果你的 CSS 里有很多 transition 属性,切换主题的时候所有元素都会一起动画,看起来有点乱。

但如果你确实想要过渡效果,可以这样做:

<ThemeProvider
  attribute="class"
  defaultTheme="system"
  enableSystem
  disableTransitionOnChange={false}
>
  {children}
</ThemeProvider>

然后在全局 CSS 里加上:

* {
  transition: background-color 0.2s ease, color 0.2s ease;
}

这样切换的时候会有淡入淡出的效果。不过我试过几次,总觉得没有过渡更干净利落。

TypeScript 类型支持

next-themes 的 TypeScript 支持很好。如果你想扩展主题类型,可以这样:

import { useTheme } from 'next-themes'

type Theme = 'light' | 'dark' | 'purple'

export function useCustomTheme() {
  const { theme, setTheme } = useTheme()
  
  return {
    theme: theme as Theme,
    setTheme: (theme: Theme) => setTheme(theme),
  }
}

这样在使用的时候就有类型提示了,不会错误地设置一个不存在的主题。

常见问题排查

问题1:主题切换了但样式没变

检查这几点:

  • Tailwind 的 darkMode 配置是不是 'class'
  • CSS 里是不是正确使用了 dark: 前缀或者 .dark 选择器
  • 浏览器控制台看看 <html> 元素的 class 有没有正确添加

问题2:刷新页面还是会闪一下

如果还有闪烁,可能是:

  • 忘了在 <html> 上加 suppressHydrationWarning
  • ThemeProvider 的位置不对
  • 有其他脚本在干扰(比如 Google Analytics 之类的)

问题3:系统主题跟随不生效

检查:

  • enableSystem 是不是设置为 true
  • 浏览器是否支持 prefers-color-scheme(现代浏览器都支持)
  • 当前主题是不是 system(如果手动切换过,可能是 lightdark

总结

回想起来,从最开始被闪烁问题困扰,到现在能够顺畅地实现暗黑模式,next-themes 真的帮了大忙。它解决的不只是技术问题,更重要的是让用户体验变得更好。

核心要点再回顾一下:

  1. 使用 next-themes 可以零配置解决 Next.js 暗黑模式的闪烁问题
  2. 记得在 <html> 上加 suppressHydrationWarning,ThemeProvider 要标记为客户端组件
  3. Tailwind 的 darkMode 配置设为 'class'
  4. 主题切换按钮要等 mounted 后再渲染,避免 hydration 不匹配
  5. 系统主题跟随和手动切换可以完美共存

如果你还没在项目里用过 next-themes,真的建议试一试。官方文档也写得很清楚:github.com/pacocoursey/next-themes

现在就去给你的 Next.js 项目加上丝滑的暗黑模式吧!你的用户会感谢你的。

Next.js 暗黑模式实现完整流程

使用next-themes实现零闪烁的暗黑模式,支持系统主题跟随和手动切换

⏱️ 预计耗时: 30 分钟

  1. 1

    步骤1: 安装 next-themes

    安装依赖:
    • npm install next-themes

    或者使用其他包管理器:
    • pnpm add next-themes
    • yarn add next-themes

    注意:next-themes是零依赖库,体积很小
  2. 2

    步骤2: 配置 ThemeProvider

    在根布局中添加:
    • 创建providers.tsx文件(标记'use client')
    • 使用ThemeProvider包裹children
    • 在app/layout.tsx中导入使用

    关键配置:
    • attribute="class":使用class切换主题
    • enableSystem:启用系统主题跟随
    • storageKey:localStorage存储键名

    注意:ThemeProvider必须是客户端组件
  3. 3

    步骤3: 配置 Tailwind CSS

    在tailwind.config.js中:
    • 设置darkMode: 'class'
    • 这样Tailwind会根据html标签的class切换主题

    配置示例:
    module.exports = {
    darkMode: 'class',
    // ... 其他配置
    }

    使用dark:前缀定义暗色样式:
    className="bg-white dark:bg-gray-900"
  4. 4

    步骤4: 修复 hydration 警告

    在html标签添加:
    • suppressHydrationWarning属性
    • 避免服务端和客户端主题不一致的警告

    在layout.tsx中:
    <html lang="zh" suppressHydrationWarning>
    <body>{children}</body>
    </html>

    这样可以避免Next.js的hydration警告
  5. 5

    步骤5: 创建主题切换按钮

    使用useTheme hook:
    • 创建ThemeToggle组件(标记'use client')
    • 使用useTheme()获取theme和setTheme
    • 等mounted后再渲染,避免hydration不匹配

    示例:
    const { theme, setTheme } = useTheme()
    const [mounted, setMounted] = useState(false)

    useEffect(() => setMounted(true), [])
    if (!mounted) return null

    <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
    切换主题
    </button>
  6. 6

    步骤6: 测试和验证

    测试要点:
    • 测试手动切换主题(无闪烁)
    • 测试系统主题跟随
    • 测试刷新后主题保持
    • 测试不同页面主题一致

    检查清单:
    • 页面加载无闪烁
    • 主题切换流畅
    • localStorage正确存储
    • 系统主题切换自动跟随

常见问题

为什么页面加载时会闪烁?
这是因为SSR时服务器不知道用户的主题偏好,渲染的是默认主题,等到客户端hydration时才能读取localStorage切换主题,导致闪烁。next-themes通过在服务端渲染时注入script标签提前读取主题,完美解决这个问题。
next-themes 和其他主题库有什么区别?
next-themes专门为Next.js设计,完美解决SSR闪烁问题,零依赖,体积小(<1kb)。use-dark-mode不是为Next.js设计,在SSR场景下有兼容问题。theme-ui功能强大但太重,对于只需要暗黑模式切换的场景来说过度设计。
如何实现系统主题跟随?
在ThemeProvider中设置enableSystem={true},next-themes会自动检测系统主题偏好并应用。用户也可以手动切换主题,手动切换会覆盖系统主题。支持三种模式:light、dark、system。
为什么需要 suppressHydrationWarning?
因为服务端渲染时不知道用户的主题偏好,渲染的是默认主题,而客户端hydration时会根据localStorage切换到用户选择的主题,导致服务端和客户端的HTML不一致。suppressHydrationWarning告诉React这是预期的,避免警告。
主题切换按钮为什么要等 mounted 后再渲染?
避免hydration不匹配。服务端渲染时无法知道localStorage中的主题,如果直接渲染按钮,服务端和客户端的HTML会不一致,导致React hydration错误。等mounted后再渲染可以确保只在客户端渲染。
如何自定义主题切换逻辑?
使用useTheme hook的setTheme方法可以自定义切换逻辑。例如:setTheme(theme === 'dark' ? 'light' : 'dark')。也可以直接设置特定主题:setTheme('dark')、setTheme('light')、setTheme('system')。
next-themes 支持哪些主题?
默认支持light和dark两个主题。也可以通过ThemeProvider的themes属性自定义更多主题,例如:themes={['light', 'dark', 'blue', 'green']}。每个主题对应不同的CSS类名。

12 分钟阅读 · 发布于: 2025年12月20日 · 修改于: 2026年1月22日

评论

使用 GitHub 账号登录后即可评论

相关文章