一个闪烁毁了所有的精致
做完暗色模式的那天晚上,我截了好几张图发到群里,收获了一堆赞。
第二天早上打开页面,第一眼就看到了——页面加载时先闪了一帧白色背景,再跳到暗色。那种感觉,就像你在高级餐厅点了一道牛排,结果上菜时服务员用手端过来。
问题的本质:SSR 和客户端主题不同步
Next.js 的 SSG 在服务端生成 HTML 时,还拿不到用户选择的主题(因为主题是存在 localStorage 里的,服务端没有)。
时间线:
0ms: 服务端返回 HTML(默认浅色主题,白色背景)
200ms: 浏览器渲染,白色背景闪现
500ms: JS 执行,读取 localStorage,发现是暗色
600ms: 切换到暗色背景
这 400ms 的延迟,就是那个刺眼的白色闪烁。
这就是经典的 hydration mismatch:服务端渲染的是浅色,客户端执行 hydration 发现应该是暗色,中间差了一帧。
方案探索(试过三个方案)
方案 1:inject 脚本(部分有效)
<!-- 在 <head> 最前面注入一段阻塞脚本 -->
<script>
(function() {
var theme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', theme);
// 但这时候 CSS 还没加载完...
})();
</script>
这个方法能消除大部分闪烁,但有一个致命问题:CSS 如果还没加载完,data-theme 的属性选择器就不生效。 在慢网下还是会闪。
方案 2:inject CSS(有效但有 FOUC)
<style>
/* 内联关键暗色样式,在 CSS 加载前就用 */
html[data-theme="dark"] {
background-color: #0f0f0f !important;
color-scheme: dark;
}
</style>
<script>
var theme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', theme);
</script>
好多了。但 color-scheme: dark 这个属性在 iOS Safari 上有兼容性问题。
方案 3:next-themes(最终方案)
试了两天各种 hack,最后还是用了 next-themes,它内部做了我上面尝试的所有事情,而且做对了:
// src/components/ThemeProvider.tsx
'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider
attribute="class" // 使用 class 切换,不是 data-theme
defaultTheme="system" // 默认跟随系统
enableSystem={true}
disableTransitionOnChange // 避免切换时的过渡动画闪烁
>
{children}
</NextThemesProvider>
)
}
它的核心魔法在于 next-themes 注入了一个最小的阻塞脚本,在 HTML 的第一个字节到达时就开始执行,但不依赖任何外部 CSS 或 JS 文件。
<!-- next-themes 在 <head> 中注入的内联脚本 -->
<script>
!function(){try{
var d=document.documentElement,
c=d.classList;
c.remove('light','dark');
var e=localStorage.getItem('theme');
if('system'===e||(!e&&true)){
var t='(prefers-color-scheme: dark)',
m=window.matchMedia(t);
if(m.media!==t||m.matches){d.style.colorScheme='dark';c.add('dark')}
else{d.style.colorScheme='light';c.add('light')}
}else if(e){c.add(e);d.style.colorScheme=e}
}catch(e){}}
</script>
这段脚本不依赖任何外部资源,在 <head> 最前面同步执行。在浏览器画第一帧之前,类名已经加上了。
Tailwind 暗色模式配置
因为用了 class 策略,Tailwind 的配置也要对应:
// tailwind.config.ts
export default {
darkMode: 'class', // 关键!用 class 切换而不是 media query
theme: {
extend: {
colors: {
// 浅色模式
light: {
bg: '#FAFAFA',
card: '#FFFFFF',
text: '#1A1A2E',
muted: '#6B7280',
},
// 暗色模式
dark: {
bg: '#0F0F0F',
card: '#1A1A1A',
text: '#F1F1F1',
muted: '#9CA3AF',
}
}
}
}
}
/* globals.css */
body {
@apply bg-[#FAFAFA] text-[#1A1A2E];
}
.dark body {
@apply bg-[#0F0F0F] text-[#F1F1F1];
}
还有一个隐藏的坑:图片和 emoji
暗色模式不只是改背景色。很多图片在浅色背景下好看,在暗色背景下刺眼。
/* 图片在暗色模式下降亮度 */
.dark img:not(.no-dark-filter) {
filter: brightness(0.9) contrast(1.1);
}
/* SVG 图标用 currentColor 而不是硬编码颜色 */
.icon {
color: currentColor; /* 跟随主题 */
}
emoji 在暗色背景下显示会比较突兀,我给文章封面 emoji 加了半透明遮罩:
.dark .article-cover {
filter: brightness(0.8);
}
收获:做好暗色模式的成本
| 问题 | 解决成本 | 如果不解决的后果 |
|---|---|---|
| 加载闪烁 | 2天研究 + next-themes | 用户第一印象崩塌 |
| 图片刺眼 | 3行CSS | 暗色模式根本不敢用 |
| 表单组件 | 每个组件多写 dark 样式 | 输入框看不见边框 |
| 代码高亮 | 换一套暗色主题 | 代码区域一片混乱 |
做好暗色模式不是 加个dark类名 就能搞定的。它是一个涉及 构建策略、CSS架构、组件体系、内容适配 的系统工程。
写在最后
我最初做暗色模式的动机很单纯:白天写代码到晚上,眼睛受不了白色背景,需要暗色模式。
但当你真正做了完整的产品,才意识到:一个小细节的瑕疵(比如那个 400ms 的白屏闪烁),会摧毁用户对所有细节的信任。
现在每当我用暗色模式打开自己的平台,背景无缝变暗,图片柔和过渡,文本可读性完美——那一瞬间的满足感,就是坚持较劲的回报。