← 全部文章

深色模式的第一帧

页面闪白的根因不是 CSS 慢,而是判定主题的脚本跑在了渲染之后。把这段逻辑提前到 head 里同步执行,闪烁就没有了。

一只特立独行的熊猫 · · 约 2 分钟读完 · · #css #web

很多站点的深色模式会闪一下白。

现象很固定:系统是深色,或者用户上次选了深色,刷新页面时先看到一帧白底,然后变黑。快速滚动时尤其明显。

顺序问题

浏览器渲染一个页面,粗略是这样的:

  1. 解析 <head>,拿到样式
  2. 构建渲染树,绘制第一帧
  3. 执行 <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 让这段代码不参与打包,原样输出。

不能加 deferasync 这两个属性都会让脚本排队到解析之后,等于把问题原样搬回来。

要处理 localStorage 抛异常的情况。 隐私模式、被禁用的存储、跨域 iframe 下,访问 localStorage 都可能直接抛异常。如果这个异常发生在 <head>,后面的解析会被打断,页面直接白屏——比闪烁严重得多。

为什么不用纯 CSS

prefers-color-scheme 媒体查询可以在纯 CSS 里做到「跟随系统」,零脚本、零闪烁:

@media (prefers-color-scheme: dark) {
  :root { --bg: #0c0c0d; }
}

但它表达不了「用户手动选过深色,而系统是浅色」。一旦需要手动覆盖,就必须有持久化状态,就必须有脚本。

我选择保留手动开关,代价是这段十二行的脚本。它值得,因为用户明确表达过的偏好,优先级应该高于系统的默认值

顺带一提

同一类问题还会出现在字体上:用 font-display: swap 时,首屏会先渲染回退字体再替换,造成一次文字重排。解决思路一样——要么让关键资源提前,要么接受它并给回退字体配合适的度量,把重排幅度压到看不见。

判断标准始终是:这一帧的代价,是否值得用一段阻塞解析的代码去换。

全文完