独立开发中·📚18 篇日记·📝最近更新:2026年07月12日
🌓

暗色模式那400ms的白色闪烁,我用了两天消灭它

SSR下暗色模式的hydration mismatch导致页面加载白闪。

水哥
2026-07-077 分钟阅读

一个闪烁毁了所有的精致

做完暗色模式的那天晚上,我截了好几张图发到群里,收获了一堆赞。

第二天早上打开页面,第一眼就看到了——页面加载时先闪了一帧白色背景,再跳到暗色。那种感觉,就像你在高级餐厅点了一道牛排,结果上菜时服务员用手端过来。

问题的本质: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 的白屏闪烁),会摧毁用户对所有细节的信任。

现在每当我用暗色模式打开自己的平台,背景无缝变暗,图片柔和过渡,文本可读性完美——那一瞬间的满足感,就是坚持较劲的回报。

相关文章