深色模式的兩個坑:白屏閃爍與 View Transitions

  • Astro
  • 前端

這個站的深色模式規則很常見:優先讀 localStorage 裡使用者手動選過的主題,沒有就跟隨系統的 prefers-color-scheme。難的不是邏輯,是兩個時機問題。

坑一:深色使用者會看到一瞬間的白屏

主題是由 JS 設定 <html data-theme="dark"> 才生效的。如果這段 JS 放在一般的 bundle 裡,瀏覽器會先用預設(淺色)畫面渲染,等 JS 載入後才變深——深色使用者每次進站都會被閃一下。

解法是把套主題的邏輯寫成 inline script 放在 <head>,讓它在首次繪製之前同步執行:

<script is:inline>
  const applyTheme = () => {
    const theme =
      localStorage.getItem('theme') ??
      (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
    document.documentElement.dataset.theme = theme;
  };
  applyTheme();
</script>

Astro 的 is:inline 會阻止它被打包和搬移,保證執行時機夠早。程式碼必須夠短,因為它是阻塞渲染的。

坑二:View Transitions 換頁後主題不見了

這個站用 <ClientRouter /> 做無刷新換頁。它換頁的方式是把新頁面的 <html> 屬性整個換上來——而新頁面的原始 HTML 裡沒有 data-theme(那是 JS 加的),所以每次換頁主題都會被重設

解法是監聽換頁完成的事件,再套一次:

document.addEventListener('astro:after-swap', applyTheme);

astro:after-swap 發生在新 DOM 換上之後、繪製之前,所以重套主題不會造成閃爍。這兩段合起來不到二十行,但少了任何一段,體驗就會在某個角落破功。

收尾:切換按鈕

按鈕本身就很單純了:切換 data-theme、寫回 localStorage。值得一提的是背景 canvas 是用 MutationObserver 監聽 data-theme 變化來重建色票的,所以主題切換的瞬間,整個藍圖網格會跟著換色,不需要任何額外的事件串接。