Dark Mode 實(shí)踐踩坑記錄
只能說,實(shí)現(xiàn) Dark Mode 的盡頭是手寫。
手機(jī) QQ 最近火急火燎地整改,暗黑模式的支持就是其中的一個(gè)整改項(xiàng)。由于騰訊課堂在手機(jī) QQ 有一個(gè)常駐入口,因此我們也要按照它們的要求實(shí)現(xiàn)真正意義上的 dark mode 支持 (而不是目前手機(jī) QQ 強(qiáng)制給加的一層灰色蒙層)。
大學(xué)時(shí)候有個(gè)項(xiàng)目也是自己設(shè)計(jì)和實(shí)現(xiàn)的 dark mode 支持,當(dāng)時(shí)是手寫的,依稀記得后面從哪些文章里看到說可以一行代碼實(shí)現(xiàn)暗黑模式云云,于是企圖在這次實(shí)踐過程中應(yīng)用下這些奇技淫巧,然而經(jīng)過一天的實(shí)踐,我發(fā)現(xiàn)這些方法有繞不過的坑,最后只得推翻重來手寫一把,下面來細(xì)說一下。
常見實(shí)踐
在開始寫代碼前先 Google 了一下,CSS Tricks 的?A Complete Guide to Dark Mode on the Web?比較推薦閱讀,里面寫的還挺全的。
開啟方式
一般來說會(huì)有兩種開啟方式,一種會(huì)在頁面 (通常右上角) 使用一個(gè) switch 開關(guān)控制頁面是 light 還是 dark,一種會(huì)根據(jù)系統(tǒng)或者應(yīng)用的 Preference 來自動(dòng)切換。
Manually toggle
對(duì)于手動(dòng)選擇的模式,我們要如何讓開關(guān)和樣式關(guān)聯(lián)上呢?肯定要給這個(gè)開關(guān)加個(gè)事件處理函數(shù)了,里面可以去改變頁面根元素的類名,通過類名控制樣式,如下。
const btn = document.querySelector('.btn-toggle');
btn.addEventListener('click', function() {
document.body.classList.toggle('dark-theme');
})
// 方法 1: 通過改變類,并使用不同的樣式
body {
color: #222;
background: #fff;
a {
color: #0033cc;
}
}
body.dark-theme {
color: #eee;
background: #121212;
a {
color: #809fff;
}
}
// 方法 2: 通過改變類,并使用不同的 CSS 變量
body {
--text-color: #222;
--bkg-color: #fff;
--anchor-color: #0033cc;
}
body.dark-theme {
--text-color: #eee;
--bkg-color: #121212;
--anchor-color: #809fff;
}
body {
color: var(--text-color);
background: var(--bkg-color);
a {
color: var(--anchor-color);
}
}
也可以去改變加載的樣式文件,通過不同的 css 文件來控制樣式。
<html lang="en">
<head>
<link href="light-theme.css" rel="stylesheet" id="theme-link">
head>
html>
const btn = document.querySelector(".btn-toggle");
const theme = document.querySelector("#theme-link");
btn.addEventListener("click", function() {
if (theme.getAttribute("href") == "light-theme.css") {
theme.href = "dark-theme.css";
} else {
theme.href = "light-theme.css";
}
});
/* light-theme.css 文件寫一份樣式 */
/* dark-theme.css 文件寫一份樣式 */
Follow system
對(duì)于要自動(dòng)適配系統(tǒng)的模式,我們要如何判斷系統(tǒng)的偏好并編寫樣式呢?一種方法是用 CSS 所支持的?prefers-color-scheme?這個(gè) media query 來包含樣式,另一種類似,也是通過對(duì)這個(gè) query 的匹配來判斷繼而添加類名和樣式。
/* 通過 media query 直接寫 */
@media (prefers-color-scheme: dark) { }
@media (prefers-color-scheme: light) { }
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.body.classList.add('dark-theme');
} else {
document.body.classList.remove('dark-theme');
}
用戶偏好
這個(gè)功能其實(shí)是有真實(shí)的需求的,比如如果我們不記住用戶偏好,那么肯定只能有一種默認(rèn)值,再在加載的過程中判斷是偏好 light 或 dark。這種情況下,我們勢必會(huì)得罪一方 (eg. 默認(rèn) light 那么對(duì)于 dark 偏好的用戶來說,肯定會(huì)先閃過白色樣式再加載到正確的黑色樣式,反之亦然),這種現(xiàn)象叫做 “flash of incorrect theme” (FOIT)。
想要記住用戶偏好,可以把這個(gè)偏好值存儲(chǔ)在 localStorage 里,不過這對(duì)于「follow system」的用戶來說不適用,總不能給 system preference 添加監(jiān)聽函數(shù),它一改我就去改這個(gè)偏好值吧,系統(tǒng)偏好不在我的管轄范圍內(nèi),在頁面設(shè)置偏好倒是可行。對(duì)于前后端不分離的類型,還可以把偏好值放到 cookie 里,讓 server 獲取到偏好從而返回對(duì)應(yīng)的 HTML。
奇技淫巧
有一個(gè)方法可以一行代碼搞定 dark mode,而且乍一看效果還可以。
html {
filter: invert(1) hue-rotate(180deg);
}


解釋一下這里的樣式,filter 其實(shí)是濾鏡,它本身提供了很多處理的接口,參考,比如模糊?blur()、灰度?grayscale()、對(duì)比度?contrast()?等。
其中 invert 的作用是反轉(zhuǎn)顏色通道數(shù)值,接收的值在 0~1。可以把?invert(param)?想象成一個(gè)函數(shù)?f(value, param) = param * (255 - value) + (1 - param) * value,當(dāng) param 為 0 時(shí)這個(gè)公式退化為 f (value) = value 也就是不變色,當(dāng) param 為 1 時(shí)這個(gè)公式退化為?f(value) = 255 - value。比如一個(gè)?rgb(0, 0, 255)?在被套用 invert (1) 后會(huì)變成?rgba(255, 255, 0);一個(gè)?rgb(255, 0, 0)?在被套用 invert (0.85) 后會(huì)變成?rgb(38, 217, 217)(套公式,0.85*(255-255) + (1-0.85)* 255 = 38.25),參考。
其中 hue-rotate 的作用是轉(zhuǎn)動(dòng)色盤,接收的值在 0deg~360deg。這個(gè)其實(shí)更好理解,如下圖是色盤,比如一個(gè)純紅色在被套用?hue-rotate(90deg)?后會(huì)變成綠色,相當(dāng)于我的取色點(diǎn)針順時(shí)針轉(zhuǎn)了 90°,具體的計(jì)算和矩陣運(yùn)算相關(guān),參考。感覺這個(gè)轉(zhuǎn)換還蠻復(fù)雜的,參考?stackoverflow 的討論,還有個(gè)將 black 通過 filter 轉(zhuǎn)換成想要顏色的工具。

在二者疊加的效果下,就會(huì)有很神奇的暗色模式了。但我們可以很明顯地看到,這里的圖片也被反色了,這不是我們預(yù)期的效果,一個(gè)常見的做法是給 img 標(biāo)簽再使用這個(gè) filter 給反回去,它是生效的,如下圖。
html {
filter: invert(1) hue-rotate(180deg);
// 圖片反轉(zhuǎn)再反轉(zhuǎn),就和原先一樣了
img {
filter: invert(1) hue-rotate(180deg);
}
}

PS: 對(duì)于 invert 非 1 的,無法通過兩次 revert 來反轉(zhuǎn)到初始值,參考。
暗黑模式の坑
根據(jù)目標(biāo)色反推源頭色
問題
如果要在實(shí)踐中使用 filter 來實(shí)現(xiàn)暗黑模式,那么我們就不需要給各種 color 設(shè)置偏黑色的,而是用原始的偏白色顏色,因?yàn)檫@樣套用 filter 自然就會(huì)變黑。想要達(dá)到目標(biāo)樣式,只需要設(shè)置一個(gè)特定的偏白色,讓這個(gè)色通過 filter 后呈現(xiàn)目標(biāo)樣式就行 (目標(biāo)顏色在設(shè)計(jì)稿里)。那么問題來了,我要怎么根據(jù)設(shè)計(jì)稿里的偏黑顏色,去反推我要設(shè)置的偏白初始值呢?解決
聰明的我想到了一種方法,就是反其道而行之。先把目標(biāo)值設(shè)為某個(gè)元素的 color,給整個(gè)頁面加個(gè) filter,用取色器應(yīng)用 (無法用 chrome devtools 的取色器噢!) 來取當(dāng)前的顏色,這個(gè)顏色是不是就是我們需要的呢?果不其然,的確如此。不過隨著實(shí)驗(yàn)越多,我發(fā)現(xiàn)黑白這一類的可以得到正確的顏色,但是彩色的貌似不是這么容易就能推出來的。這里取色還要注意下,電腦和外接顯示屏不一樣。
通過 Background url 設(shè)置的圖片無法反色
問題
像下面的例子,即使加了上面的樣式,還是沒法反色。


原因
首先是因?yàn)檫@種方法設(shè)置圖片的元素,無法通過 img 標(biāo)簽選擇到 (那是自然!),且有個(gè)規(guī)定,對(duì)于設(shè)置了 background 屬性的選擇器,在其中寫的 filter 屬性是完全不生效的,參考。解決辦法要么把這些都換成 img,要么用 hack 一些的加偽元素的方法,不過前者不太現(xiàn)實(shí),后者不太方便。單就這一個(gè)問題就可以否決掉 filter 的方案了。
Filter 影響其他元素
問題
給某個(gè) H5 頁面內(nèi) react root 元素添加 filter 后,發(fā)現(xiàn)頁面上的頂部固定搜索框、底部固定 tab 欄都消失不見了,類似下圖。
正常情況下:
給 react root 元素添加 filter 后:
原因
搜了好多問題,終于通過一篇被搬運(yùn)的文章發(fā)現(xiàn)了問題所在 (感謝這篇文章!),原因是 filter 屬性會(huì)影響 fixed 的組件,因?yàn)樗鼤?huì)給 absolute 和 fixed 的元素添加一個(gè) containing block,除非這個(gè)被添加 filter 的元素是 document 的根元素 (也就是 html 元素),否則 fixed 和 absolute 相對(duì)的位置就不對(duì)了。fixed 的元素會(huì)相對(duì)于使用了 filter 的元素來定位,而不是相對(duì)于視口 viewport,解決辦法有兩種,要么把 filter 只設(shè)置在 html 元素上來避開,要么針對(duì)每個(gè) fixed 元素套一個(gè) container,只給這個(gè) container 使用 filter,參考。
直出頁面無法設(shè)置?Dark Mode 類名
問題
在調(diào)試的時(shí)候,發(fā)現(xiàn)某個(gè)直出頁面大體顏色都正常,但有兩個(gè)模塊顏色不對(duì)勁。解決
排查到原因在于,這個(gè) Container 中有兩個(gè)子組件少了?dark-mode?的類名。但是這幾個(gè)組件都是同樣的判斷條件和傳遞 props 方式,為什么會(huì)有的帶上了正確的類名,令人百思不得其解。
后面再思考下,有可能是因?yàn)?props 不行,如果我把 props 改成 state 呢?實(shí)踐后發(fā)現(xiàn)可行,也就是在 constructor 中設(shè)置一個(gè) isDarkMode 的值為?false,在 componentDidMount 的時(shí)候去設(shè)置 isDarkMode 的值為?!!this.props.isDarkMode。但如果我在 constructor 中就設(shè)置?!!this.props.isDarkMode?就會(huì)不生效,為什么呢?原因在于我們對(duì)系統(tǒng)偏好的判斷,是通過?window.matchMedia?來的,這個(gè)在直出的 server 端必然獲取不到,這里我們 server 返回的 html 字符串就是錯(cuò)誤的。當(dāng)頁面返回到 client 這邊,加載的 js 會(huì)執(zhí)行各種生命周期函數(shù),componentDidMount 這里的 setState 值和 constructor 中的初始 state 值是一樣的,就不會(huì)觸發(fā) update,就會(huì)直接使用我們錯(cuò)誤的 html 字符串對(duì)應(yīng)的頁面。因此會(huì)顯示錯(cuò)誤。

往期推薦



最后
歡迎加我微信,拉你進(jìn)技術(shù)群,長期交流學(xué)習(xí)...
歡迎關(guān)注「前端Q」,認(rèn)真學(xué)前端,做個(gè)專業(yè)的技術(shù)人...



