很多站点的深色模式会闪一下白。
现象很固定:系统是深色,或者用户上次选了深色,刷新页面时先看到一帧白底,然后变黑。快速滚动时尤其明显。
顺序问题
浏览器渲染一个页面,粗略是这样的:
- 解析
<head>,拿到样式 - 构建渲染树,绘制第一帧
- 执行
<script>(除非它被放在更早的位置)
如果主题判定写在 <body> 末尾的脚本里,那么第一帧绘制时,<html> 上还没有 data-theme 属性。CSS 里所有深色规则的选择器是 html[data-theme="dark"],匹配不上,于是按默认的浅色渲染。
一帧之后脚本执行,属性写上了,页面重绘成深色。这就是那一闪。
把判定提前
解决办法不是让 CSS 更快,而是让判定发生在第一帧之前——写进 <head>,并且是内联同步脚本:
<script is:inline>
(() => {
try {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.dataset.theme = saved || (prefersDark ? 'dark' : 'light');
} catch {
document.documentElement.dataset.theme = 'light';
}
})();
</script>
三个注意点。
必须是内联的。 外链脚本即使放在 <head> 也要发起一次网络请求,首帧照样会提前绘制。Astro 里用 is:inline 让这段代码不参与打包,原样输出。
不能加 defer 或 async。 这两个属性都会让脚本排队到解析之后,等于把问题原样搬回来。
要处理 localStorage 抛异常的情况。 隐私模式、被禁用的存储、跨域 iframe 下,访问 localStorage 都可能直接抛异常。如果这个异常发生在 <head>,后面的解析会被打断,页面直接白屏——比闪烁严重得多。
为什么不用纯 CSS
prefers-color-scheme 媒体查询可以在纯 CSS 里做到「跟随系统」,零脚本、零闪烁:
@media (prefers-color-scheme: dark) {
:root { --bg: #0c0c0d; }
}
但它表达不了「用户手动选过深色,而系统是浅色」。一旦需要手动覆盖,就必须有持久化状态,就必须有脚本。
我选择保留手动开关,代价是这段十二行的脚本。它值得,因为用户明确表达过的偏好,优先级应该高于系统的默认值。
顺带一提
同一类问题还会出现在字体上:用 font-display: swap 时,首屏会先渲染回退字体再替换,造成一次文字重排。解决思路一样——要么让关键资源提前,要么接受它并给回退字体配合适的度量,把重排幅度压到看不见。
判断标准始终是:这一帧的代价,是否值得用一段阻塞解析的代码去换。
全文完