【總結(jié)】1249- 暗黑模式 の 坑
只能說,實現(xiàn) Dark Mode 的盡頭是手寫。
手機 QQ 最近火急火燎地整改,暗黑模式的支持就是其中的一個整改項。由于騰訊課堂在手機 QQ 有一個常駐入口,因此我們也要按照它們的要求實現(xiàn)真正意義上的 dark mode 支持 (而不是目前手機 QQ 強制給加的一層灰色蒙層)。
大學時候有個項目也是自己設(shè)計和實現(xiàn)的 dark mode 支持,當時是手寫的,依稀記得后面從哪些文章里看到說可以一行代碼實現(xiàn)暗黑模式云云,于是企圖在這次實踐過程中應(yīng)用下這些奇技淫巧,然而經(jīng)過一天的實踐,我發(fā)現(xiàn)這些方法有繞不過的坑,最后只得推翻重來手寫一把,下面來細說一下。
常見實踐
在開始寫代碼前先 Google 了一下,CSS Tricks 的?A Complete Guide to Dark Mode on the Web?比較推薦閱讀,里面寫的還挺全的。
開啟方式
一般來說會有兩種開啟方式,一種會在頁面 (通常右上角) 使用一個 switch 開關(guān)控制頁面是 light 還是 dark,一種會根據(jù)系統(tǒng)或者應(yīng)用的 Preference 來自動切換。
Manually toggle
對于手動選擇的模式,我們要如何讓開關(guān)和樣式關(guān)聯(lián)上呢?肯定要給這個開關(guān)加個事件處理函數(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
對于要自動適配系統(tǒng)的模式,我們要如何判斷系統(tǒng)的偏好并編寫樣式呢?一種方法是用 CSS 所支持的?prefers-color-scheme?這個 media query 來包含樣式,另一種類似,也是通過對這個 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');
}
用戶偏好
這個功能其實是有真實的需求的,比如如果我們不記住用戶偏好,那么肯定只能有一種默認值,再在加載的過程中判斷是偏好 light 或 dark。這種情況下,我們勢必會得罪一方 (eg. 默認 light 那么對于 dark 偏好的用戶來說,肯定會先閃過白色樣式再加載到正確的黑色樣式,反之亦然),這種現(xiàn)象叫做 “flash of incorrect theme” (FOIT)。
想要記住用戶偏好,可以把這個偏好值存儲在 localStorage 里,不過這對于「follow system」的用戶來說不適用,總不能給 system preference 添加監(jiān)聽函數(shù),它一改我就去改這個偏好值吧,系統(tǒng)偏好不在我的管轄范圍內(nèi),在頁面設(shè)置偏好倒是可行。對于前后端不分離的類型,還可以把偏好值放到 cookie 里,讓 server 獲取到偏好從而返回對應(yīng)的 HTML。
奇技淫巧
有一個方法可以一行代碼搞定 dark mode,而且乍一看效果還可以。
html {
filter: invert(1) hue-rotate(180deg);
}


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

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

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


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

回復(fù)“加群”與大佬們一起交流學習~
點擊“閱讀原文”查看 130+ 篇原創(chuàng)文章
