深色模式切换会闪一下?把配色偏好提前到首屏绘制之前-第三维度MokIII

现象:点一次链接,屏幕闪一下

站点默认是深色主题。用户切到浅色之后,每次点进一篇文章,页面都会先按深色画一遍,再跳回浅色——就是那一下白/黑闪烁。

用无痕窗口、清空缓存都没用,因为它不是缓存问题。

一、原因:默认值在服务端,偏好却在页脚才读

主题的配色机制是两段式的:

  1. 服务端按主题设置输出一个默认值:
<body data-scheme="dark">
  1. 前端 JS(挂在页脚)读 localStorage 里的用户偏好,再改回来。

问题是:页脚 JS 执行时,首屏早就按深色绘制完了。所以每次导航都会闪一下。

这类问题有个通名:FOUC(Flash of Unstyled/Unstyled Content,样式闪动)。

二、思路:把"读偏好"提前到首屏绘制之前

要让偏好生效,就得在 <head> 里同步执行——在浏览器开始绘制之前。

但这里有个鸡生蛋问题:<head> 执行时,<body> 元素还不存在,你没法直接改它的属性。

解法是用 MutationObserver 监听 body 一被插入就动手:

<script>
( function () {
  try {
    var s = localStorage.getItem( 'data-scheme' );
    if ( s !== 'light' && s !== 'dark' && s !== 'auto' ) { return; }   // 没设置过就不管

    var root = document.documentElement;
    var apply = function () {
      var b = document.body;
      if ( ! b ) { return false; }                    // body 还没出现
      if ( s === 'auto' ) { b.removeAttribute( 'data-scheme' ); }       // 跟随系统 = 去掉属性
      else { b.setAttribute( 'data-scheme', s ); }
      return true;
    };

    if ( ! apply() ) {
      // body 还没插入:一出现就立刻改
      new MutationObserver( function ( records, observer ) {
        if ( apply() ) { observer.disconnect(); }
      } ).observe( root, { childList: true } );

      document.addEventListener( 'DOMContentLoaded', apply );   // 兜底
    }
  } catch ( e ) {}    // localStorage 被禁用时不能报错中断
} )();
</script>

三个细节:

  • try/catch 不能省:隐私模式下访问 localStorage 可能直接抛异常,不包住的话这段脚本会中断,后面的都不执行;
  • auto 时是"移除属性"而不是设成 auto:要和主题自身的语义保持一致(跟随系统就是不加属性,让 CSS 的媒体查询生效);
  • MutationObserver + DOMContentLoaded 双保险:不同浏览器对 body 插入时机的处理略有差异。

三、顺带解决:顶部 logo 也会闪错版本

主题在深色/浅色下用的是两张不同的 logo 图(.logo-light / .logo-dark),靠 CSS 类切换。

既然我们在 body 属性被写入的瞬间就能介入,那就顺手用 CSS 按属性控制显示哪一个:

body[data-scheme="light"] .logo-dark  { display: none !important; }
body[data-scheme="light"] .logo-light { display: inline-block !important; }
body[data-scheme="dark"]  .logo-light { display: none !important; }
body[data-scheme="dark"]  .logo-dark  { display: inline-block !important; }

⚠️ 这里有个必须注意的点

CSS 必须挂在 body[data-scheme] 上,不能挂在"启动脚本写的一次性标记"上。

因为主题自己的 JS 在切换配色时改的正是 body 的 data-scheme 属性,同时给 .logo-* 增删自己的类名。

如果你把 CSS 挂在一个只写一次的标记上(比如 <html class="scheme-ready">),那 !important 会永久压住主题的类名——用户切到深色之后,logo 就再也换不回来了。这个 bug 很难当场发现,因为初始加载看起来完全正常。

四、和页面缓存兼容吗

兼容,而且是刻意设计成兼容的。

这段脚本只读 localStorage、不读 cookie。所以服务端输出的 HTML 对所有访客完全相同——不管访客选了什么配色,拿到的都是同一份缓存文件,配色在浏览器端决定。

反例:如果你的方案是"服务端读 cookie 决定输出哪种配色",那这份 HTML 就因人而异,页面缓存会直接失效(或者给用户返回别人的配色)。

主题页脚那段 JS 照旧执行,两者结果一致(幂等),不会互相打架。

五、验证

  1. 切到浅色 → 刷新 → 进文章时不应该闪;
  2. 切到深色 → 同样不应闪;
  3. 切一次配色,确认 logo 正确跟着换(这是第 3 节那个坑的验收点);
  4. 开一个无痕窗口(没有 cookie/localStorage)→ 应该正常按服务端默认值渲染,不报错。

六、回滚

删掉插件文件,恢复"先按服务端默认值绘制、页脚 JS 再切换"的原始行为。回滚后闪烁会回来——这不是故障,是变回了原状。


一句话总结:配色闪烁的本质是"默认值在服务端、偏好却在页脚才读"。解法是把读取提前到 <head> 的同步脚本里,用 MutationObserver 在 body 出现的瞬间写入属性;顺带用 body[data-scheme] 控制 logo——但千万别挂在一次性标记上,否则切回去就再也换不回来了。