深色模式的兩個坑:白屏閃爍與 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 變化來重建色票的,所以主題切換的瞬間,整個藍圖網格會跟著換色,不需要任何額外的事件串接。