项目背景:我用 Next.js 15 搭建了一个 100+ 工具的在线站点,部署在 Cloudflare Pages 上。 要实现暗色模式切换,我以为最多半天。实际排查了三天,经历了 8 个坑。
问题清单
📚 本文是 UtlKit 技术系列第 10 篇 — 系列索引 →
- 上一篇:CF 缓存优化 →
- 下一篇:[多语言 →]
在找到最终方案之前,我遇到了这些问题:
| # | 问题 | 状态 |
|---|---|---|
| 1 | React Hydration Error #418 | ✅ 已解决 |
| 2 | 暗色模式首屏白屏闪烁 | ✅ 已解决 |
| 3 | Console SyntaxError: missing ) after argument list | ✅ 已解决 |
| 4 | 切换按钮图标不跟随主题变化 | ✅ 已解决 |
| 5 | SPA 导航后主题丢失 | ✅ 已解决 |
每一个问题的修复都会引发新的问题,最后拼出来的方案看起来简单,但走了不少弯路。
坑 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;
}
三层优先级:
:root默认浅色@media (prefers-color-scheme: dark)覆盖为深色(系统偏好)[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 | ✅ 完美 |
核心经验:
dangerouslySetInnerHTML在<head>里是陷阱 — Next.js RSC 会序列化它的__html,导致&&→\u0026\u0026的语法错误- 外部 JS 文件比 inline 慢一帧 — 需要 HTTP 请求,首帧渲染时 JS 还没执行
- CSS
@media是消除闪烁的根本方案 — 浏览器解析 CSS 时就知道颜色,不需要任何 JavaScript - Tailwind v4 的
darkMode配置有坑 —variant不被支持,dark:始终绑定到@media prefers-color-scheme - 三层优先级策略 —
:root默认 →@media覆盖系统偏好 →[data-theme]覆盖用户选择 - SPA 导航会清掉
<html>上的 class — 需要拦截pushState/replaceState恢复
项目地址
📚 本文是 UtlKit 技术系列第 10 篇 — 系列索引 →
- 上一篇:CF 缓存优化 →
- 下一篇:[多语言 →]
UtlKit — 100+ 个免费在线工具。所有上述问题都是实际开发和部署过程中遇到的,解决方案已在线上稳定运行。
如果觉得有帮助,欢迎点个⭐️。有任何问题欢迎评论讨论。