主題切換器要處理的狀態不是兩個而是三個:跟隨系統、強制亮色、強制暗色。把它做成兩態(只有亮/暗)會產生一個無法回頭的狀態——使用者一旦按過,就永遠脫離系統偏好,之後系統日夜自動切換對這個網站失效。這一篇給完整實作,以及三種儲存方式各自的取捨。
狀態模型
儲存的值只有三種可能,而「跟隨系統」的正確表示法是沒有值,不是字串 "system":
localStorage['theme'] 不存在 → 跟隨系統
localStorage['theme'] = 'light' → 強制亮色
localStorage['theme'] = 'dark' → 強制暗色
用「不存在」表示跟隨系統,好處是回到系統模式時把鍵刪掉即可,不會留下需要另外處理的第三個字串;而且新使用者的預設狀態天然就是跟隨系統。
DOM 上的對應同樣是屬性存在與否:
<html> <!-- 跟隨系統 -->
<html data-theme="light"> <!-- 強制亮色 -->
<html data-theme="dark"> <!-- 強制暗色 -->完整實作
const STORAGE_KEY = 'theme';
const root = document.documentElement;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
/** 套用主題。value 為 'light' | 'dark' | null(null = 跟隨系統) */
function applyTheme(value) {
if (value === 'light' || value === 'dark') {
root.dataset.theme = value;
localStorage.setItem(STORAGE_KEY, value);
} else {
delete root.dataset.theme;
localStorage.removeItem(STORAGE_KEY);
}
syncThemeColor();
}
/** 目前實際生效的是亮還是暗(把「跟隨系統」解析成具體值) */
function resolvedTheme() {
return root.dataset.theme || (mq.matches ? 'dark' : 'light');
}
/** 同步瀏覽器 UI 顏色,見「Mobile web」一篇 */
function syncThemeColor() {
const color = getComputedStyle(root).getPropertyValue('--bg-canvas').trim();
document.querySelector('meta[name="theme-color"]:not([media])')
?.setAttribute('content', color);
}
// 使用者在系統設定裡切換時,只有「跟隨系統」的狀態需要反應
mq.addEventListener('change', () => {
if (!root.dataset.theme) syncThemeColor();
});
// 跨分頁同步:另一個分頁改了偏好,這個分頁要跟上
window.addEventListener('storage', (e) => {
if (e.key === STORAGE_KEY) {
const v = e.newValue;
if (v === 'light' || v === 'dark') root.dataset.theme = v;
else delete root.dataset.theme;
syncThemeColor();
}
});storage 事件那段常被省略,但它處理的是很容易被抓包的情境:使用者開了兩個分頁,在其中一個切換主題,另一個沒動。storage 事件只在其他分頁觸發,所以不會造成回圈。
UI 該長什麼樣:兩種主流做法
做法一:三段式選擇器(segmented control)。 三個選項並排,狀態一目瞭然:
<fieldset class="theme-switch">
<legend>外觀</legend>
<label><input type="radio" name="theme" value="system" checked> 跟隨系統</label>
<label><input type="radio" name="theme" value="light"> 亮色</label>
<label><input type="radio" name="theme" value="dark"> 暗色</label>
</fieldset>document.querySelector('.theme-switch').addEventListener('change', (e) => {
applyTheme(e.target.value === 'system' ? null : e.target.value);
});用原生 radio 而不是自製按鈕,鍵盤操作、螢幕閱讀器語意、焦點環全部免費取得。
做法二:單一切換鈕。 Google 的 modern-web-guidance 明確建議不要暴露三個狀態,理由是三態控件的視覺回饋容易讓使用者搞不清楚目前在哪一態;他們建議做成「系統預設 vs 它的相反」的兩狀態開關。
這兩種建議互相衝突,取捨依產品而定:內容型網站(部落格、文件站)使用者停留短、切換一次就走,兩態開關足夠;工具型產品有設定頁、使用者長期使用、且會期待「跟隨系統」是可回復的選項,三態較合適。折衷做法是主介面放兩態開關、設定頁提供三態,這樣兩邊的優點都拿得到。無論哪種,按鈕的無障礙名稱要描述動作或狀態(aria-pressed 或明確 label),不要只放一個沒有替代文字的月亮圖示。
三種儲存方式的取捨
| 方式 | 首屏是否已正確 | 需要 JS | 適用 |
|---|---|---|---|
localStorage | 否,需 inline script 補救 | 是 | 純靜態站、SPA |
| Cookie | 是,伺服器可讀 | 否(讀取端) | SSR(Next.js、Rails、Laravel) |
| 伺服器端使用者設定 | 是 | 否 | 有登入的產品,需跨裝置一致 |
localStorage 是最簡單的選項,代價是它只能在 JS 執行後讀到,因此必定需要一段首屏 inline script,否則會閃爍(見 避免主題閃爍)。另外要注意 in-app browser 的儲存可能是暫時性的:部分 app 的 WebView 使用非持久化資料存放區,使用者的選擇離開頁面就消失,見 In-app WebView 專篇。
Cookie 的優勢是伺服器在產生 HTML 時就知道要輸出哪個 data-theme,首屏完全沒有閃爍問題,也不需要 inline script。代價是每個請求都會多帶幾個 byte,而且要處理 SameSite、以及 CDN 快取要把這個 cookie 納入 vary(否則會把暗色版 HTML 快取給亮色使用者,這是很容易踩的坑)。實作上比較穩的做法是cookie 只在 SSR 用來輸出 data-theme 屬性,其餘邏輯照舊,並讓 CDN 對這個 cookie 做 vary 或改用 edge 端渲染。
伺服器端設定只有在使用者有帳號、且期待跨裝置一致時才值得。要注意跨裝置一致其實未必是使用者要的——他可能希望桌機亮色、手機暗色。這種情況下按裝置存(cookie/localStorage)反而正確。
客戶端提示:Sec-CH-Prefers-Color-Scheme
第一次造訪時,伺服器既沒有 cookie 也沒有 localStorage,理論上不可能知道使用者的系統偏好——除非用客戶端提示。Chromium 93 起支援 Sec-CH-Prefers-Color-Scheme 請求標頭,讓伺服器在回應前就知道使用者的系統偏好:
# 回應標頭:先宣告要接收這個提示
Accept-CH: Sec-CH-Prefers-Color-Scheme
Vary: Sec-CH-Prefers-Color-Scheme
# 後續請求會帶上
Sec-CH-Prefers-Color-Scheme: dark
實務上的三個限制必須清楚:這是高熵提示,網站要主動 opt in;Accept-CH 在第一個請求還沒生效,所以第一次造訪仍然拿不到;而且它是 Chromium 系的功能,Safari 與 Firefox 不支援。所以它是優化而不是解法——正確的組合是「CSS 媒體查詢處理系統偏好(普世有效)+ cookie/inline script 處理手動覆寫(處理首屏)+ client hint 作為 Chromium 上的額外優化(可選)」。
沒有它也不會閃爍,因為系統偏好的部分本來就由 CSS 媒體查詢處理,那條路徑不需要 JS 也不需要伺服器知道。真正需要補救的只有「使用者手動覆寫了系統偏好」這個情況,而那個情況一定有 cookie 或 localStorage 可讀。這個區分是理解整個閃爍問題的關鍵。
別忘了同步這些附屬狀態
切換主題時,除了 data-theme 與 color-scheme,還有幾樣東西必須一起更新,漏掉任何一個都會出現「切了但有一塊沒變」:
<meta name="theme-color">的 content(瀏覽器 UI 色)。- 已經插入頁面的第三方 iframe(金流、留言、地圖)——多數需要重新載入或呼叫它們自己的 API 才會換色。
- Canvas/WebGL 繪製的內容——它們不吃 CSS,需要在切換時重繪。
- 動態產生的
<meta name="color-scheme">(若你用 inline script 改過它)。 - 已開啟的
<dialog>、popover 與 portal 內容——若它們掛在document.body之外或用了獨立的color-scheme。
下一步:避免主題閃爍:FOUC 的真正成因。