主題閃爍有兩種,成因完全不同,解法也不同,混為一談會導致修了半天沒修對地方。第一種是系統偏好的閃爍:頁面在樣式表載入前用預設白色畫布繪製了一幀。第二種是手動覆寫的閃爍:使用者選了暗色但系統是亮色,伺服器送出的 HTML 是亮色的,等 JS 讀完 localStorage 才改過來。

第一種:宣告太晚

瀏覽器在解析 HTML 的過程中就可能繪製第一幀。如果 color-scheme 只寫在外部樣式表裡,那麼從「開始解析 HTML」到「樣式表下載完成並套用」之間,畫布是 UA 的預設白色。網路愈慢、樣式表愈大,白閃愈明顯。

解法是把宣告提前到 HTML 本身,這也是 MDN 建議把 meta 放在任何 CSS 之前的原因:

<head>
  <meta charset="utf-8">
  <meta name="color-scheme" content="light dark">   <!-- 必須在 CSS 之前 -->
  <link rel="stylesheet" href="/style.css">
</head>

這一行讓 UA 在還沒看到任何 CSS 時就知道畫布可以是深色。它便宜、無副作用、不需要 JS,是所有網站都應該加的一行,包括那些用 JS 框架的。

若你的 CSS 是外部檔案且很大,可以再加一層保險——把最關鍵的畫布顏色 inline 到 <head>

<style>
  :root { color-scheme: light dark; }
  html { background: light-dark(#ffffff, #0e1013); }
</style>

第二種:狀態在 JS 手上

這一種才是多數人遇到的問題。使用者的偏好存在 localStorage,而 localStorage 只有 JS 讀得到,所以 HTML 剛送達時瀏覽器並不知道要用哪一套。若讀取的腳本被 defer、被打包進 bundle、或放在 </body> 前,畫面就會先以錯誤主題繪製一幀再被改掉——那一下白閃比完全不支援暗色還刺眼。

解法是首屏 inline script,且必須符合三個條件:寫在 HTML 檔案裡(不是外部 JS)、不加 defer / async / type=module放在 <head> 中任何會產生內容的元素之前

<head>
  <meta charset="utf-8">
  <meta name="color-scheme" content="light dark">
  <script>
    // 首屏主題腳本:必須同步、必須 inline、必須在 CSS 之前或緊接其後
    try {
      var t = localStorage.getItem('theme');
      if (t === 'light' || t === 'dark') {
        document.documentElement.dataset.theme = t;
        document.documentElement.style.colorScheme = t;
        document.querySelector('meta[name="color-scheme"]').content = t;
      }
    } catch (e) { /* 隱私模式或封鎖儲存時,安靜地跟隨系統 */ }
  </script>
  <link rel="stylesheet" href="/style.css">
</head>

幾個細節值得逐一說明:

  • try/catch 不是防禦性裝飾。 Safari 的無痕模式、部分 in-app browser、以及封鎖第三方儲存的設定下,localStorage.getItem 會直接丟例外。沒有 try/catch 的話這個例外會中止整段腳本,後面所有東西都不會執行。
  • 同時設 dataset.themestyle.colorScheme 與 meta 三處。 data-theme 給你的 CSS 用;inline 的 colorScheme 確保原生控件立刻正確、而且不必等外部 CSS;改 meta 是為了在 CSS 還沒到之前就讓 UA 知道。
  • 不要在這裡讀 matchMedia 系統偏好由 CSS 媒體查詢處理即可,在 JS 裡重複判斷只會多一份會不同步的邏輯。這段腳本唯一的職責是還原手動覆寫
  • 腳本要小。 它是阻塞解析的,超過幾百 byte 就開始影響 FCP。上面這段壓過之後約 300 byte,這是合理的上限。

CSP 若禁止 inline script,用 nonce 或 hash 放行這一段。這是少數值得為它開例外的 inline script。

第三種解法:把狀態搬到伺服器(SSR 最乾淨)

如果有 SSR,最徹底的解法是不要讓瀏覽器猜——伺服器直接輸出正確的屬性:

// 伺服器端(示意)
const theme = req.cookies.theme;                       // 'light' | 'dark' | undefined
const attr = theme ? ` data-theme="${theme}"` : '';
res.send(`<!doctype html><html lang="zh-Hant"${attr}> ... `);

這樣完全不需要 inline script,第一幀就是對的。代價在快取:HTML 的快取鍵必須包含這個 cookie,否則 CDN 會把某個使用者的暗色 HTML 餵給其他人。要嘛設 Vary: Cookie(會顯著降低快取命中率),要嘛在 edge 端渲染,要嘛只快取「沒有 cookie」的版本、有 cookie 的請求回源。

這個取捨是實際的:一個高流量內容站為了消除閃爍而讓 HTML 快取命中率從 95% 掉到 40%,通常不划算,改用 inline script 較合理。有登入牆、HTML 本來就不快取的產品,則應該直接走 SSR 這條路。

第四種:純 CSS 三態(無 JS 首屏)

<link media> 分檔的寫法(見 CSS 實作 的寫法 C),首屏在沒有任何 JS 的情況下就已經依系統偏好正確渲染,JS 只負責之後的手動覆寫。這個做法把「首屏正確」與「可切換」解耦,是靜態站相當划算的選擇。

怎麼確認你真的修好了

閃爍的特性是只有一到兩幀,肉眼在快網路上看不出來,所以必須用工具驗證:

  1. DevTools → Network → 節流到 Slow 4G 或自訂更慢,然後硬重新整理。節流是關鍵,本機開發永遠看不到這個問題。
  2. Performance 面板錄製載入過程,展開 screenshots 逐幀檢查第一幀的底色。這是最可靠的方法,能明確看到白閃出現在哪個時間點、以及當時在等什麼資源。
  3. 停用 JavaScript 重新載入:預期結果是「跟隨系統偏好正確渲染」,而不是壞掉或固定亮色。如果停用 JS 後網站只剩亮色,代表你的系統偏好邏輯被寫進了 JS,那是錯的——系統偏好應該由 CSS 負責。
  4. 切到暗色偏好、清掉 localStorage、再設成強制亮色,四種組合(系統亮/暗 × 手動亮/暗)都測一次。最容易漏的是「系統暗 + 手動亮」。

常見的錯誤修法

  • body { visibility: hidden } 等 JS 就緒才顯示。 這把閃爍換成白畫面延遲,Lighthouse 的 FCP 與 LCP 會直接惡化,而且 JS 失敗時整頁看不見。
  • 在 inline script 裡連系統偏好一起判斷並寫死 data-theme 之後使用者在系統切換日夜,頁面不會跟著變,因為你已經把它釘死在某個具體值上。
  • 把腳本放進框架的 _app / 根元件。 那是 hydration 之後才跑的,時機太晚。Next.js、Nuxt、SvelteKit 都有各自把 raw script 插進 <head> 的機制,要用那個,不要用元件。
  • 加 transition 想「淡入」掩蓋閃爍。 只會讓錯誤主題那一幀停留更久。切換動畫要在使用者操作時才啟用,載入時不該有,可用 <html class="no-transition"> 在載入完成後移除來達成。

下一步:對比、暗色不是純黑、高程與陰影