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 年再来看这篇文章……