簡單又好用的前端深色模式和主題化開發(fā)方案

作者:DevUI團隊
https://juejin.im/post/5eca7cbf518825430c3ab223
DevUI是一支兼具設計視角和工程視角的團隊,服務于華為云DevCloud平臺和華為內(nèi)部數(shù)個中后臺系統(tǒng),服務于設計師和前端工程師。
官方網(wǎng)站:devui.design ?Ng組件庫:ng-devui(歡迎Star)
引言
深色模式(Dark Mode)在iOS13 引入該特性后各大應用和網(wǎng)站都開始支持了深色模式。在這之前,深色模式更常見于程序IDE開發(fā)界面和視頻網(wǎng)站界面。前者通過降低屏幕亮度,使得使用人員長時間盯著屏幕眼睛沒有那么疲憊;后者通過深色模式來降噪,從而突出主體內(nèi)容部分??焖匍_發(fā)一個深色模式難嗎?在支持css自定義屬性(又稱css變量,css variables)的現(xiàn)代瀏覽器里,可以說是相當?shù)娜菀?。甚至可以在運行時實時新增主題,擺脫傳統(tǒng)css主題文件加載模式下的主題需要預編譯內(nèi)置不能隨時修改的弊端。下面我們來看一下如何使用css自定義屬性來完成深色模式和主題化的開發(fā)。
主題切換器開發(fā)
首先我們需要打通一套支持css自定義屬性的開發(fā)模式。
CSS自定義屬性使用
這里簡單介紹一下CSS自定義屬性,有時候也被稱作CSS變量或者級聯(lián)變量。它包含的值可以在整個文檔中重復使用。自定義屬性使用 --``變量名``:``變量值來定義,用var(--變量名[,默認值]) 函數(shù)來獲取值。舉一個簡單例子:
<div><p>textp>div>
/* css */
div { --my-color: red; border: 1px solid var(--my-color); }
p { color: var(--my-color); }
這時候div的邊框和內(nèi)部的p元素就能使用這個定義的變量來設置自己的顏色。
通常CSS自定義屬性需要定義在元素內(nèi),通過在:root偽類上設置自定義屬性,可以在整個文檔需要的地方使用。CSS變量是可以繼承的,也就是說我們可以通過CSS繼承創(chuàng)建一些局部主題,這里就不展開局部主題的討論,我們只需要使用好:root偽類就能對整站實施主題化了。
如何切換主題呢,我們在運行的時候給頭部插入一段,并通過id或者引用的方式保持對該style元素的引用,通過修改style元素innerText為 :root{--變量1: 色值3; --變量2: 色值4;……}就可以成功替換變量顏色了。
由于主題數(shù)據(jù)可能是從接口等其他地方獲取的,我們可以在使用的地方給它先加上默認值,避免主題數(shù)據(jù)到達之前出現(xiàn)沒有顏色的現(xiàn)象,比如 p { color: var(--變量1, 色值1);}這樣,就使用上了css自定義屬性來在運行時動態(tài)加載不同的主題顏色值。
Sass/Less支持
如果直接在開發(fā)css中使用css變量很容易由于書寫問題,定義問題最后導致變量眾多,管理困難,變更默認色值替換成本高等問題。在大型網(wǎng)站的開發(fā)中通常會用sass/less來預定義一些顏色變量來進行色彩管理。
在使用sass和less的時候可以改變原來的傳遞色值方式改為傳遞css自定義屬性和默認值。color定義文件:
| before | after |
|---|---|
| // sass | $brand-primary: #5e7ce0; |
| // less | @brand-primary: #5e7ce0; |
| // sass | $brand-primary: var(--brand-primary, #5e7ce0); |
| // less | @brand-primary: var(--brand-primary, #5e7ce0); |
這里有個副作用就是,一旦色值被定義為var變量,則這個var表達式就無法再被less/sass的色彩計算函數(shù)所計算使用,這塊我們在后面的章節(jié)再進行討論。
定義完對應的變量之后, 使用的地方就可以直接使用使用這些變量,方便統(tǒng)一管理。
使用媒體查詢
prefer-color-scheme是瀏覽器獲取系統(tǒng)上用戶對顏色主題的傾向性的css api,使用該api我們就可以輕松使得網(wǎng)站的主題跟隨系統(tǒng)的顏色設置展示不同的顏色了。
css的API如下:
// css
@media (prefers-color-scheme: light) {
:root{--變量1: 色值1;--變量2: 色值2; ……}
}
@media (prefers-color-scheme: dark) {
:root{--變量1: 色值3; --變量2: 色值4; ……}
}
腳本方面也有對應的媒體查詢方案,js的API如下:
// js
function isDarkSchemePreference(){
return window.matchMedia('screen and (prefers-color-scheme: dark)').matches;
}
主題切換服務
最后我們需要寫一個主題服務,主要目的就是支持在切換主題的時候應用不同的css變量數(shù)據(jù),假定我們的css變量的數(shù)據(jù)存儲在一個對象里,key值為css變量名,value值為css變量在該主題下的值,那么我們的主題切換服務的關(guān)鍵核心函數(shù)如下:
// theme.ts
export class Theme {
id: ThemeId;
name: string;
data: {
[cssVarName: string]: string
};
}
// theme-service.ts
class ThemeService {
contentElement;
eventBus;
// ……
applyTheme(theme: Theme) {
this.currentTheme = theme;
if (!this.contentElement) {
const styleElement = document.getElementById('devuiThemeVariables');
if ( styleElement) {
this.contentElement = <HTMLStyleElement>styleElement;
} else {
this.contentElement = document.createElement('style');
this.contentElement.id = 'devuiThemeVariables';
document.head.appendChild(this.contentElement);
}
}
this.contentElement.innerText = ':root { ' + this.formatCSSVariables(theme.data) + ' }';
document.body.setAttribute('ui-theme', this.currentTheme.id);
// 通知外部主題變更
this.notify(theme, 'themeChanged');
}
formatCSSVariables(themeData: Theme['data']) {
return Object.keys(themeData).map(
cssVar => ('--' + cssVar + ':' + themeData[cssVar])
).join(';');
}
private notify(theme: Theme, eventType: string) {
if (!this.eventBus) { return; }
this.eventBus.trigger(eventType, theme);
}
}
其中applyTheme函數(shù)會創(chuàng)建一個style元素,如果已經(jīng)創(chuàng)建好了則直接改變style的內(nèi)容。如果要支持跟隨系統(tǒng)還需要一些額外函數(shù)的判斷,這里就不展開了,可以參考鏈接,原理是通過動畫結(jié)束事件監(jiān)聽媒體查詢變化,對應可以使用enquirejs庫。
至此我們打通了主題服務和css變量值在開發(fā)中的應用,下面就可以開發(fā)一個深色模式了。
深色模式開發(fā)
語義化色彩變量
深色模式涉及到了大量網(wǎng)站視覺的“反色”,在已有的網(wǎng)站當中,應該好好排查和梳理網(wǎng)站的顏色,把顏色歸一和約束到一定的變量范圍和數(shù)量里,并給顏色的不同使用場景一個不同的語義變量名,這樣能取得場景分離的效果。
從文本顏色上我們舉個簡單的例子:
通常的網(wǎng)站里都會有正文(主要文本),幫助提示信息(次要文本),文本占位符。這里我們可以使用三個變量來描述這些文本text-color-primary,text-color-secondary,text-color-tertiary,也可以使用text-color-normal,text-color-help-info,text-color-placeholder來描述這這些顏色值。
這里強烈建議使用更有語義的變量而不是色值本身的描述,比如:錯誤背景色,應該使用background-color-danger而不是background-color-red,因為對于不同的主題顏色值可能是不一樣的。

圖1 語義化變量示意
使用統(tǒng)一語義變量控制組件表現(xiàn)
需要定義多少的變量才恰當,這個取決于網(wǎng)站的色彩空間約束范圍和使用場景的定義粒度。當定義了一套變量之后我們就可以對組件/網(wǎng)站的不同組成部分進行變量統(tǒng)一。
比如搜索框和下拉框,使用同樣的變量控制相同部分的表現(xiàn),使得組件在主題變化的可以使用相同的顏色規(guī)則。

圖2 使用變量對組件進行規(guī)約
提供暗黑主題色值
完成了上面重要的兩步,我們就可以通過給變量提供一套新的色值來達到主題的變化了。

圖3 通過色值的切換實現(xiàn)深色主題切換
圖片的處理
圖片的處理并不能像文字一樣地去反轉(zhuǎn)顏色或者反轉(zhuǎn)亮度,這樣可能照成不適。通常如果有準備亮色和暗色兩套圖片,可以采用變量化圖片地址在不同主題下切黑圖片。如果圖片來自用戶輸入,其他地方的截圖,這時候需要稍微處理一些降低亮度。圖片簡化地獲取當前的主題狀態(tài)可以在body上增加一個ui主題是否是深色模式的屬性。
深色方案一:圖片增加透明度。適用場景:簡單文章圖片和純色背景。
// css
body[ui-theme-mode='dark'] img {
opacity: 0.8;
}
深色方案二:帶圖片的位置疊加一個灰色半透明的層,適用場景:背景圖,非純色背景等。
// css
body[ui-theme-mode='dark'] .dark-mode-image-overlay {
position: relative;
}
body[ui-theme-mode='dark'] .dark-mode-image-overlay::before {
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(50, 50, 50, 0.5);
}
前者不適用與帶有背景圖片的層處理,也不適合通過疊加圖片遮擋來呈現(xiàn)效果的處理,但是用在文章博客中的插入圖片非常簡單有效,圖片可以自然地疊加到純色深色的背景色上。后者給了另一種方案完成背景層的疊加,但對代碼有一定的入侵。
提供主題變化訂閱應對第三方組件場景
通過以上幾個基本的步驟就能在編碼的過程中通過使用變量指定顏色值,獲得主題的能力。但是面對大量第三方組件,有自己的主題,也可能有自己的深色主題,這塊再去入侵式地修改成自定義的變量工作量不小且并不一定合適。
這時候需要提供主題訂閱,在主題發(fā)生變化的時候,獲得通知,然后給第三方組件設置一定對應的變更。
我們需要一個簡單的eventbus,實現(xiàn)方式不限。這里給出一個簡單版本的接口如下:
// theme/interface.ts
export interface IEventBus {
on(eventName: string, callbacks: Function): void;
off(eventName: string, callbacks: Function): void;
trigger(eventName: string, data: any): void;
}
切換主題的時候發(fā)出themeChanged事件,使用on監(jiān)聽就能夠獲得當前主題變更事件,通過判斷主題,給第三方的組件套上對應的主題,或者修改js顏色變量等等。
降級支持和使用腳本膩子
降級PostCSS插值腳本
一旦使用了var之后,那些不支持var的老瀏覽器會顯示為無顏色,這里我們使用postcss插件處理最后一個階段的css。
// postcss-plugin-add-var-value.js
var postcss = require('postcss');
var cssVarReg = new RegExp('var\\\\(\\\\-\\\\-(?:.*?),(.*?)\\\\)', 'g');
module.exports = postcss.plugin('postcss-plugin-add-origin-css-var-value', () => {
return (root) => {
root.walkDecls(decl => {
if (decl.type !== 'comment' && decl.value && decl.value.match(cssVarReg)) {
decl.cloneBefore({value: decl.value.replace(cssVarReg, (match, item) => item) });
}
});
}
});
該postcss插件通過遍歷css規(guī)則里的帶有var(--變量名, 變量值)在該行的上一行插入了一行替換為直接變量值的值,兼容不支持css var的瀏覽器。
| before | after |
|---|---|
| color: var(--brand-primary, ?#5e7ce0); | color: #5e7ce0; ?color: var(--brand-primary, #5e7ce0); |
css-vars-ponyfill 使 IE9+ 和 Edge 12+支持上主題切換
css-vars-ponyfill 這個npm包可以使得ie9+/edge12+支持上css自定義屬性,它是一個帶有選項的兼容方案,大概原理就是通過監(jiān)聽style里帶有var自定義屬性的值,替換為原值并插入。該兼容方案目前不兼容直接掛在在元素上的局部的css自定義屬性定義。該方案還提供了實時監(jiān)聽style插入的選項,支持var鏈式的取值。簡單地加入polyfill就可以使用了。
// polyfill.ts
import cssVars from 'css-vars-ponyfill';
cssVars({ watch: true, silent: true});
一些問題的探討
什么網(wǎng)站需要開發(fā)深色模式?
深色模式適合長時間閱讀、長時間沉浸式瀏覽的網(wǎng)站,包括新聞、博客、知識庫等文章瀏覽和視頻網(wǎng)站,開發(fā)IDE界面等沉浸式交互。這些網(wǎng)站使用深色模式可以通過降低亮度減少對眼睛的刺激,減少長時間瀏覽的疲憊和暈眩的感覺。
深色模式不適合一些非深色風格產(chǎn)品的展示,深沉的背景色會影響產(chǎn)品風格呈現(xiàn)、傳遞的情感和用戶觀看時候的心情,不適當?shù)念伾钆淙菀滓鸱锤?。像一些電商網(wǎng)站深色模式要慎重處理,深色可能會使得產(chǎn)品圖片呈現(xiàn)的積極風格受到一定程度的抑制,顏色可能會影響用戶的購物欲望。一些主題推廣宣傳類的網(wǎng)站也是,顏色可能會削弱主題的表達。
有沒有更簡單的深色模式映射切換?比如使用HSL替代RGB色值。
HSL色值的表達形式是通過色相、飽和度、亮度,既然深色模式是調(diào)整亮度和飽和度,那是否可以通過hsl色值來自動計算呢?這種自動出暗色版本的色值還有待探索中,主要有兩個原因:1)深色模式的舒適度不是線性亮度和飽和度映射能完成的,顏色的函數(shù)計算深色映射顯得相對單調(diào)。2)實際情況是一個顏色可能會映射到多個暗黑場景的顏色。
針對第一點,目前有一些UI會推出非線性反色的算法,也是為了解決顏色一起調(diào)整亮度之后變得看不清、色彩反色后沖擊過大的問題。這類的算法還有很多優(yōu)化空間。在淺色搭配情況下可能很好看的顏色,放到深色下可能就會引起不舒適:不恰當?shù)膶Ρ榷葧鹨曈X上看不清晰;不恰當?shù)纳逝鲎矔鸱锤?;不恰當?shù)娘柡投取⒘炼葧@得UI有點臟。
針對第二點,可以舉以下的場景來說明:同樣是白色,有色背景下的白色,在深色模式下可能還是保持白色;而作為背景色的白色在深色場景下會對應調(diào)整為深色。

圖4 一種白色的存在切換主題的多種映射
此時,自動通過色值計算就需要區(qū)分顏色的周邊顏色或者底層疊加顏色來計算,這無疑加大了計算難度。
所以這塊自動計算并不太容易,還需要一些的探索。
Sass/Less使用var變量后變成字符串管理,無法對顏色進行變換計算?
本身sass/less的變量和css自定義屬性就不是一套變量系統(tǒng),sass/less的是一種編譯型變量(編譯時確定值,編譯后不存在),而css是一個運行時變量(即運行時確定值)。用sass/less去管理css變量時為了管理css變量防止定義失誤,但使用了Sass或Less之后替換成var之后會發(fā)現(xiàn),sass和less是一些比如lighten、fadeout、rgba等等的函數(shù)都無法使用了,因為對與sass和less來說,var(--xxx, #xxx)是一個字符串不是顏色值。這塊目前也沒有比較好的方法, 有一些文章也討論了一些解法,如 鏈接,大體的思路是拆分顏色的表達為hsl形式,然后對顏色的維度進行操作處理,實際上還是不能無感知地使用內(nèi)建的色彩變換函數(shù)。另一個解法/方案是:把涉及顏色變換的地方統(tǒng)一處理然后再賦予新的css變量名,不再在mixin等函數(shù)里對顏色進行變換而是對變量名進行規(guī)則變化。如果讀者有其他較好的思路也可以在評論里分享。
總結(jié)
本文介紹了利用CSS自定義屬性能夠給css定義一些顏色變量,輕松地實現(xiàn)深色主題的開發(fā)甚至支持更多的主題化。通過色彩變量定義,使用變量,處理圖片和處理三方組件支持實現(xiàn)整站的深色模式的規(guī)約和完善。進一步介紹了降級支持的方法,并對深色模式的適用范圍和一些其他方式實現(xiàn)進行了討論。
??愛心三連擊
1.看到這里了就點個在看支持下吧,你的「點贊,在看」是我創(chuàng)作的動力。
2.關(guān)注公眾號
程序員成長指北,回復「1」加入Node進階交流群!「在這里有好多 Node 開發(fā)者,會討論 Node 知識,互相學習」!3.也可添加微信【ikoala520】,一起成長。
“在看轉(zhuǎn)發(fā)”是最大的支持
