Next.js 15 暗色模式完美切换:从 Hydration Error 到零闪烁

0 阅读7分钟

项目背景:我用 Next.js 15 搭建了一个 100+ 工具的在线站点,部署在 Cloudflare Pages 上。 要实现暗色模式切换,我以为最多半天。实际排查了三天,经历了 8 个坑。

问题清单

📚 本文是 UtlKit 技术系列第 10 篇系列索引 →

在找到最终方案之前,我遇到了这些问题:

#问题状态
1React Hydration Error #418✅ 已解决
2暗色模式首屏白屏闪烁✅ 已解决
3Console SyntaxError: missing ) after argument list✅ 已解决
4切换按钮图标不跟随主题变化✅ 已解决
5SPA 导航后主题丢失✅ 已解决

每一个问题的修复都会引发新的问题,最后拼出来的方案看起来简单,但走了不少弯路。

坑 1:React Hydration Error #418

现象

打开 Chrome DevTools,Console 满屏红字:

Error: Hydration failed because the server HTML didn't match the client.

根因

项目最初用了 next-themes 库:

// ❌ next-themes 在 SSR 和 CSR 之间不一致
<ThemeProvider attribute="class" defaultTheme="dark">
  <html>{children}</html>
</ThemeProvider>

next-themes 的内部实现让 SSR 渲染的 HTML(没有 theme class)和 CSR 水合后的 HTML(有 class="dark")不一致,React 19 报 Hydration Error。

更隐蔽的是主题切换按钮:

// ❌ 条件渲染导致 SSR/CSR 文本不匹配
<button onClick={toggle}>
  {isDark ? '☀️' : '🌙'}
</button>

SSR 时 isDark 是默认值(dark),渲染 ☀️。CSR 时如果用户系统是浅色,isDark 变了,渲染 🌙。文本内容不同 → 418。

修复

去掉 next-themes,自己写 ThemeProvider。切换按钮改用 CSS 控制显隐而非条件渲染,两个 icon 都始终渲染在 DOM 里:

// ✅ 两个 icon 都渲染,用 CSS 控制显隐
<span className="theme-toggle-light-icon">🌙</span>
<span className="theme-toggle-dark-icon">☀️</span>
/* CSS 根据 data-theme 属性控制显隐 */
[data-theme="light"] .theme-toggle-light-icon { display: inline; }
[data-theme="light"] .theme-toggle-dark-icon { display: none; }
[data-theme="dark"] .theme-toggle-light-icon { display: none; }
[data-theme="dark"] .theme-toggle-dark-icon { display: inline; }

SSR 和 CSR 的文本内容完全相同(🌙☀️),只是 CSS display 不同,React 不报 mismatch。

坑 2:暗色模式首屏白屏闪烁

现象

页面加载时先闪一下白色,然后变成暗色。

第一版修复:head script

<head> 里加 inline script:

<script>
  (function(){
    var s = localStorage.getItem('theme') || 'dark';
    document.documentElement.classList.add(s);
    document.documentElement.style.backgroundColor = s === 'dark' ? '#0f172a' : '#f8fafc';
  })();
</script>

效果: 闪烁消失 ✅ 新问题: SPA 导航后主题丢失(因为 Next.js 的 pushState 会重建 DOM)

第二版修复:拦截 pushState

// 拦截 history API,SPA 导航后恢复主题
var p = history.pushState;
history.pushState = function(){ p.apply(this, arguments); restoreTheme() };
var r = history.replaceState;
history.replaceState = function(){ r.apply(this, arguments); restoreTheme() };
window.addEventListener('popstate', restoreTheme);

效果: SPA 导航不丢主题 ✅

坑 3:Console SyntaxError

现象

Uncaught SyntaxError: missing ) after argument list at bmi-calculator/:1:4908

根因

<script>dangerouslySetInnerHTML 放在 <head> 里:

<script dangerouslySetInnerHTML={{ __html: `(function(){...&&...})()` }} />

Next.js 15 的 RSC(React Server Components)会把 dangerouslySetInnerHTML__html 内容序列化为 RSC payload 数据。序列化过程中,&& 被转义成 \u0026\u0026,最终在客户端执行时变成非法 JavaScript 语法。

HTML 里的 script 是正常的,但 RSC payload 里的脚本被破坏了。

修复:移到外部 JS 文件

// ❌ 会被 RSC 序列化破坏
<script dangerouslySetInnerHTML={{ __html: '...' }} />

// ✅ 外部文件,不受 RSC 影响
<script src="/theme-init.js" />

效果: SyntaxError 消失 ✅ 新问题: 外部 JS 需要 HTTP 请求加载,第一帧渲染时 JS 还没执行,又出现了一瞬间的闪烁

坑 4:外部 JS 仍然闪烁

根因

<script src="/theme-init.js"> 需要浏览器先发送 HTTP 请求、下载 JS 文件、再执行。这之间浏览器已经用默认的浅色 CSS 渲染了第一帧。

修复:CSS @media (prefers-color-scheme) 作为默认

核心思路:让浏览器不需要任何 JS 就能渲染正确的主题。

/* CSS 变量默认浅色 */
:root {
  --bg: #f8fafc;
  --surface: #ffffff;
  --border: #e2e8f0;
  --text: #1e293b;
}

/* 系统偏好深色时,@media 覆盖 CSS 变量 */
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0f172a;
    --surface: #1e293b;
    --border: #334155;
    --text: #e2e8f0;
  }
}

/* 用户显式选择主题时,data-theme 覆盖 @media */
html[data-theme="dark"] {
  --bg: #0f172a !important;
  --surface: #1e293b !important;
  --border: #334155 !important;
  --text: #e2e8f0 !important;
}

html[data-theme="light"] {
  --bg: #f8fafc !important;
  --surface: #ffffff !important;
  --border: #e2e8f0 !important;
  --text: #1e293b !important;
}

三层优先级:

  1. :root 默认浅色
  2. @media (prefers-color-scheme: dark) 覆盖为深色(系统偏好)
  3. [data-theme]!important 强制覆盖(用户显式选择)

浏览器在解析 CSS 时就知道该用什么颜色了,不需要等任何 JavaScript

坑 5:切换按钮图标不跟随

现象

切换功能正常(页面确实变了),但按钮始终显示 🌙。

根因

Tailwind CSS 的 dark: 前缀(如 dark:hidden)在 darkMode: 'class' 模式下需要 .dark class 才生效。但 Tailwind v4 的 variant 配置不被支持,dark: 始终绑定到 @media (prefers-color-scheme: dark)

也就是说 dark:hidden 只跟随系统设置,不跟随 JS 设置的 data-theme。如果用户系统是浅色但手动切了暗色,dark:hidden 仍然不生效。

修复:自定义 CSS 类

不用 Tailwind 的 dark: 前缀,自己写 CSS:

[data-theme="light"] .theme-toggle-light-icon,
:not([data-theme]) .theme-toggle-light-icon {
  display: inline;
}
[data-theme="light"] .theme-toggle-dark-icon,
:not([data-theme]) .theme-toggle-dark-icon {
  display: none;
}
[data-theme="dark"] .theme-toggle-light-icon {
  display: none;
}
[data-theme="dark"] .theme-toggle-dark-icon {
  display: inline;
}

:not([data-theme]) 覆盖用户没选择时的默认状态(默认浅色,显示 🌙)。

最终方案

完整架构

┌─────────────────────────────────────────────┐
│ 第一层:CSS @media (prefers-color-scheme)    │
│  浏览器原生支持,零 JS,第一帧就正确           │
├─────────────────────────────────────────────┤
│ 第二层:theme-init.js 设置 data-theme        │
│  读取 localStorage,覆盖 @media              │
│  拦截 pushState/replaceState 恢复主题        │
├─────────────────────────────────────────────┤
│ 第三层:ThemeProvider (React)                │
│  同步 React 状态,用户点击切换按钮时更新       │
└─────────────────────────────────────────────┘

关键文件

public/theme-init.js — 在 React 之前执行:

(function(){
  try{
    var d = document.documentElement;
    var stored = localStorage.getItem('theme');
    var isDark;
    if(stored === 'light') isDark = false;
    else if(stored === 'dark') isDark = true;
    else isDark = window.matchMedia('(prefers-color-scheme:dark)').matches;

    if(isDark) d.setAttribute('data-theme', 'dark');
    else d.setAttribute('data-theme', 'light');

    // SPA 导航恢复
    function restore(){
      if(!d.getAttribute('data-theme')){
        // ... 重新读取并设置
      }
    }
    var p = history.pushState;
    history.pushState = function(){
      p.apply(this, arguments);
      requestAnimationFrame(restore);
    };
    // replaceState 和 popstate 同理...
  }catch(e){}
})();

app/[locale]/layout.tsx — 引用外部 JS:

<head>
  <script src="/theme-init.js" />
  {/* 其他 head 内容 */}
</head>

components/ThemeProvider.tsx — React 状态管理:

function setTheme(theme: 'light' | 'dark' | 'system') {
  const resolved = theme === 'system' ? getSystemTheme() : theme
  localStorage.setItem(THEME_KEY, theme)
  const el = document.documentElement
  el.setAttribute('data-theme', resolved)
  el.classList.remove('dark', 'light')
  el.classList.add(resolved)
}

globals.css — 三层优先级 CSS 变量 + 切换图标:

/* 默认浅色 */
:root { --bg: #f8fafc; --text: #1e293b; ... }

/* 系统深色覆盖 */
@media (prefers-color-scheme: dark) {
  :root { --bg: #0f172a; --text: #e2e8f0; ... }
}

/* 用户显式选择强制覆盖 */
html[data-theme="dark"] { --bg: #0f172a !important; ... }
html[data-theme="light"] { --bg: #f8fafc !important; ... }

/* 切换按钮图标 */
[data-theme="dark"] .theme-toggle-light-icon { display: none; }
[data-theme="dark"] .theme-toggle-dark-icon { display: inline; }
[data-theme="light"] .theme-toggle-light-icon { display: inline; }
[data-theme="light"] .theme-toggle-dark-icon { display: none; }

经验总结

做法结果
next-themes❌ Hydration Error #418
dangerouslySetInnerHTML inline script❌ RSC 序列化 SyntaxError
<script src> 外部文件⚠️ 需要 HTTP 请求,仍闪烁一帧
Tailwind dark: 前缀⚠️ v4 始终绑定 media query,不跟 JS
@media 默认 + data-theme override✅ 完美

核心经验:

  1. dangerouslySetInnerHTML<head> 里是陷阱 — Next.js RSC 会序列化它的 __html,导致 &&\u0026\u0026 的语法错误
  2. 外部 JS 文件比 inline 慢一帧 — 需要 HTTP 请求,首帧渲染时 JS 还没执行
  3. CSS @media 是消除闪烁的根本方案 — 浏览器解析 CSS 时就知道颜色,不需要任何 JavaScript
  4. Tailwind v4 的 darkMode 配置有坑variant 不被支持,dark: 始终绑定到 @media prefers-color-scheme
  5. 三层优先级策略:root 默认 → @media 覆盖系统偏好 → [data-theme] 覆盖用户选择
  6. SPA 导航会清掉 <html> 上的 class — 需要拦截 pushState/replaceState 恢复

项目地址


📚 本文是 UtlKit 技术系列第 10 篇系列索引 →


UtlKit — 100+ 个免费在线工具。所有上述问题都是实际开发和部署过程中遇到的,解决方案已在线上稳定运行。


如果觉得有帮助,欢迎点个⭐️。有任何问题欢迎评论讨论。