Dark ModeCSSUXAccessibilityReact

深色模式不只是换个颜色:daima.life 如何实现零闪烁的主题自动切换

2026-04-0610 分钟阅读

凌晨两点搓代码,页面一刷新,白花花的屏幕直接把你闪瞎。这种体验太糟糕了。复盘 daima.life 如何实现系统级联动、零 FOUC 闪烁的深浅主题自动切换。

1. 糟糕的开头

凌晨两点半,我窝在被子里用 daima.life 格式化一段后端刚传来的 JSON。房间全黑,只有屏幕的光在我脸上跳。我按了一下 Ctrl+R 刷新页面,然后——白光一闪,我的瞳孔急速收缩,整个人条件反射地把笔记本盖上了。缓过来之后,我盯着屏幕上的深色主题按钮,心想:我不是都选了深色模式了吗?为什么刷新的那一瞬间,页面会先白一下再变黑?

这个 bug 的学名叫 FOUC(Flash of Unstyled Content)——准确说,是"Flash of Wrong Theme"。它的根源在于:浏览器渲染 HTML 的速度比 JavaScript 执行快太多了。页面在 JS 读取到你上次存的主题偏好之前,就已经按照默认的白色样式渲染了第一帧。这种体验不叫"有深色模式",这叫"深色模式还没来得及生效"。对一个自诩追求极致的开发者来说,让用户的眼睛被闪一下,跟让用户看到 undefined 一样丢人。

2. 我的思考

市面上 90% 的深色模式实现都是"假的"——本质上就是在 React 的 useEffect 里读一下 localStorage,然后给 <body> 切一个 class。这套逻辑跑通了没问题,但它有一个致命时间差:JS 是在 HTML 渲染之后才执行的。在 Next.js 的 SSG/SSR 模式下,服务端吐出来的 HTML 压根不知道用户选了什么主题。于是第一帧必然是错的。

daima.life 的设计目标很明确:用户刷新页面的那一帧,主题就必须是对的。不能闪,哪怕 0.1 秒都不行。为了实现这一点,我的方案是把主题判断逻辑从"JS 运行时"提前到"HTML 解析时"——用一段阻塞式的内联 <script>,在浏览器绘制第一帧之前,就把正确的主题 class 打上去。

3. 技术硬核区:零 FOUC 的三段式主题引擎

第一段:阻塞式内联脚本 (Blocking Inline Script)

这段代码必须放在 <head> 的最前面,在所有 CSS 文件加载之前执行。它是同步的、阻塞的——这恰恰是我们需要的,因为我们要在浏览器绘制第一个像素之前就拿到答案。

// 放在 <head> 最前面的阻塞性脚本
// 必须在任何样式表和 React 初始化之前执行
(function() {
  var theme = null;

  // 优先级 1:检查用户手动设置的偏好(localStorage)
  try {
    theme = localStorage.getItem('daima-theme');
  } catch(e) {}

  // 优先级 2:若用户无手动设置,跟随操作系统
  if (!theme) {
    var mql = window.matchMedia('(prefers-color-scheme: dark)');
    theme = mql.matches ? 'dark' : 'light';
  }

  // 在第一帧渲染前,直接写入 <html> 的 class 和 color-scheme
  document.documentElement.classList.add(theme);
  document.documentElement.style.colorScheme = theme;
})();

第二段:CSS 变量的双层设计

很多人搞深色模式就是定义两套颜色然后用 class 切换。但如果你的工具有代码高亮、图表、按钮等几十种 UI 元素,硬编码两套色值就是自找麻烦。daima.life 使用 CSS 自定义属性(Custom Properties)+ HSL 色彩空间,只需要调整亮度参数就能完成主题的整体切换:

/* 核心:用 HSL 让深浅色切换只改一个变量 */
:root {
  --hue: 220;
  --sat: 15%;
  --bg-l: 98%;        /* 浅色主题的背景亮度 */
  --fg-l: 12%;        /* 浅色主题的文字亮度 */
  --surface-l: 94%;
}

:root.dark {
  --bg-l: 8%;         /* 深色主题只需翻转亮度值 */
  --fg-l: 90%;
  --surface-l: 14%;
}

/* 所有 UI 元素引用同一组变量 */
body {
  background: hsl(var(--hue) var(--sat) var(--bg-l));
  color: hsl(var(--hue) var(--sat) var(--fg-l));
}

.card, .panel, .toolbar {
  background: hsl(var(--hue) var(--sat) var(--surface-l));
}

第三段:系统主题实时联动

搞定了初始化加载,还得处理一个场景:用户在操作系统层面切换了深浅色(比如 macOS 的自动日落切换),daima.life 应该实时响应而不是等用户刷新。这需要监听 matchMedia 的 change 事件:

// React 组件内:实时监听系统主题变化
useEffect(() => {
  const mql = window.matchMedia('(prefers-color-scheme: dark)');

  const handler = (e: MediaQueryListEvent) => {
    // 仅在用户未手动设置主题时才跟随系统
    const userOverride = localStorage.getItem('daima-theme');
    if (!userOverride) {
      const newTheme = e.matches ? 'dark' : 'light';
      document.documentElement.classList.remove('light', 'dark');
      document.documentElement.classList.add(newTheme);
      document.documentElement.style.colorScheme = newTheme;
    }
  };

  mql.addEventListener('change', handler);
  return () => mql.removeEventListener('change', handler);
}, []);

4. FAQ 模块

Q1: 阻塞式脚本会影响页面加载性能吗?

A: 这段脚本只有 200 字节,纯同步执行,没有任何 I/O 操作。实测在 2026 年的主流设备上,执行时间不超过 0.3ms。相比之下,FOUC 闪烁导致的重绘成本远大于这个开销。这是一笔"用 0.3 毫秒换取零闪烁"的交易,稳赚。

Q2: 如果用户在不同标签页切换了主题,其他标签页怎么同步?

A: 利用 StorageEvent。当一个标签页修改了 localStorage 中的主题值,其他同源标签页会收到 storage 事件。daima.life 监听了这个事件,实现了跨标签页的主题实时同步。用户在 A 标签页点了"切到深色",B 标签页瞬间跟着变。不需要刷新,不需要轮询。

Q3: SSR 环境下怎么处理?服务端不知道用户的 prefers-color-scheme。

A: 这是深色模式实现中最经典的坑。服务端确实不知道用户的系统偏好(除非你用 Cookie 传递,但那又违反了"隐私优先"的原则)。daima.life 的方案是:服务端不做任何主题假设,吐出来的 HTML 不带任何主题 class。所有主题判断都交给那段放在 <head> 里的阻塞脚本——它在 CSS 解析之前执行,所以浏览器永远不会渲染出一个"主题错误"的帧。这也是为什么那段脚本必须是内联的,而不能是外部文件——外部 JS 的加载是异步的,来不及。

5. 结尾

一个主题切换按钮,看起来不过是前端最基础的功能。但当你把零闪烁、系统联动、跨标签页同步、SSR 兼容、HSL 色彩空间这些约束全部叠加在一起时,它就变成了一道精密的系统工程题。我现在已经在测试一个更猛的想法:基于用户当前环境光传感器(AmbientLightSensor API)的自适应对比度——如果你在阳光下,深色模式会自动提升文字亮度;如果你在暗室,浅色模式的背景光会自动调暗。让屏幕的亮度像呼吸一样自然,而不是一把开关的粗暴切换。听着很科幻?2027 年再来看这篇文章……