前言
深色模式 (Dark Mode) 可以說是現代網站的基本功能之一,不只是美觀,更能提升使用者在不同環境下的閱讀體驗 (我本人也是深色模式愛好者)。
在這篇文章中,我會分享我在 React 專案中實作 Dark Mode 的方法 ~(本專案使用 Vite + React,因此不考慮 SSR)
設計目標
開始實作前,定義了幾個核心需求:
- 畫面依照深/淺色切換樣式
- 網站要能夠記住使用者偏好
要實現這樣的功能,需要完成這三件事:
1. 設計深/淺色對應的 CSS
2. 建立切換按鈕
3. 儲存偏好設定
Step 1: 用 CSS Variables 定義主題
如果為了深色模式重新寫一大堆 background-color 、color 不太方便,更有效的方式是利用 CSS Variable。
在 CSS 中,我們可以定義變數:
:root {
/* 宣告變數 */
--bg: #ffffff;
--text: #111111;
}
/* 深色模式 */
[data-theme="dark"] {
--bg: #111111;
--text: #ffffff;
}
body {
/* 使用變數 */
background: var(--bg);
color: var(--text);
}
Step 2: React 控制主題切換
以下為錯誤寫法,後續會說明原因與修正方式!!
2.1 使用 `useState` 儲存主題狀態
const [theme, setTheme] = useState("light");
2.2 使用 `useEffect []` 設定初始值
載入畫面時依照偏好設定深/淺色,如果沒有儲存的偏好設置則依照系統主題
useEffect(() => {
const savedTheme = localStorage.getItem("theme");
if (savedTheme) {
setTheme(savedTheme);
} else {
// 偵測系統主題
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
setTheme(prefersDark ? "dark" : "light");
}
}, []);
2.3 使用 `useEffect [theme]` 切換主題
用按鈕處切換 theme
<button onClick={() => settheme(theme==="light"?"dark" :"light")}>
切換模式
</button>
當 theme 變更時,切換深淺色模式,並將主題存到 localstorage 裡
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}, [theme]);
遇到的問題:刷新頁面主題設置失效
上方的程式碼其實有個小問題,會導致重新整理頁面時,主題設置不會被記住,永遠是淺色。
原因是 react 的 StrictMode 幫我抓到了我 useEffect 寫不好的地方。
React StrictMode 在開發環境下會「刻意重新執行部分 lifecycle (也就是 mount + unmount)」,用來幫助開發者發現副作用 (side effects)寫法的問題。
來看一下我的程式碼:
第一次掛載
1. 載入頁面時,會給 theme 設定初始值 "light"
const [theme, setTheme] = useState("light");
2. useEffect [] 觸發,讀取 localstorage 的內容得到 dark
useEffect(()=>{
const savedTheme = localStorage.getItem("theme");
if(savedTheme){
settheme(savedTheme);
}else{
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
settheme(prefersDark? "dark": "light")
}
},[]);
在這時,雖然在 savedTheme = localStorage.getItem("theme") 讀取到了 "dark",但是 setTheme 是非同步,將資料寫回這件事不會馬上發生,而是排隊等待執行。
3. useEffect [theme] 執行
useEffect(()=>{
console.log("useEffect #2 執行,theme =", theme);
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}, [theme]);
這個 useEffect 執行時 theme 還是 "light",所以 "light" 被寫回 localstorage。
第二次掛載
1. theme 又被初始為 "light"
const [theme, setTheme] = useState("light");
2. useEffect [] 再執行一次
第一次掛載時,localstorage 已經被寫成 "light",所以這時讀取到的 const savedTheme = localStorage.getItem("theme"); 會是 "light",導致第一次 useEffect [] 寫回的 setTheme(dark) 被 setTheme(light) 蓋掉。
3. useEffect [theme] 抓到的又是 "light"
解法:使用 lazy initialization
修改程式碼
const [theme, settheme] = useState(()=>{
const savedTheme = localStorage.getItem("theme");
if(savedTheme){
return savedTheme;
}else{
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
return prefersDark? "dark": "light";
}
})
在渲染過程中直接讀取 localstorage 作為初始值,這樣完全不需要 `useEffect []`,也不會有被覆蓋的問題。
總結
這次的問題其實可以總結成一個重要原則:
不要用 useEffect 初始化 state
原因是:
- useEffect 是在 render 之後才執行
- 中間可能發生資料覆蓋問題
正確做法應該是:
- 初始化資料用 useState (lazy initialization)
- 副作用同步才用 useEffect
這樣才能確保資料來源單一且穩定!!
要做一個切換深淺色主題的功能本身不會很難,但是在初始化的時候沒有處理好小細節,反而會出現意料外的錯誤行為~ 經過這個錯誤也讓我更加了解 useState、useEffect 等 hooks 的特性,也算是因禍得福~~





















