引言
近期,華為雲DevCloud推出了開發者友好的深色模式,深受開發者們的喜愛和關注。大家都知道,深色模式(Dark Mode)在iOS13 引入該特性後各大應用和網站都開始支援了深色模式。在這之前,深色模式更常見于程式IDE開發界面和視訊網站界面。前者通過降低螢幕亮度,使得使用人員長時間盯着螢幕眼睛沒有那麼疲憊;後者通過深色模式來降噪,進而突出主體内容部分。随着産品技術的疊代,在支援css自定義屬性(又稱css變量,css variables)的現代浏覽器,完全可以在運作時實時新增主題,擺脫傳統css主題檔案加載模式下的主題需要預編譯内置不能随時修改的弊端。
接下來,我們看一下如何使用css自定義屬性來完成深色模式和主題化的開發。
主題切換器開發
首先我們需要打通一套支援css自定義屬性的開發模式。
- CSS自定義屬性使用
這裡簡單介紹一下CSS自定義屬性,有時候也被稱作CSS變量或者級聯變量。它包含的值可以在整個文檔中重複使用。自定義屬性使用 --變量名: 變量值來定義,用var(--變量名[, 預設值]) 函數來擷取值。舉一個簡單例子:
<!--html--> <div><p>text</p></div> /* css */ div { --my-color: red; border: 1px solid var(--my-color); } p { color: var(--my-color); }
這時候div的邊框和内部的p元素就能使用這個定義的變量來設定自己的顔色。
通常CSS自定義屬性需要定義在元素内,通過在:root僞類上設定自定義屬性,可以在整個文檔需要的地方使用。CSS變量是可以繼承的,也就是說我們可以通過CSS繼承建立一些局部主題,這裡就不展開局部主題的讨論,我們隻需要使用好:root僞類就能對整站實施主題化了。
如何切換主題呢,我們在運作的時候給頭部插入一段<style>:root{--變量1: 色值1;--變量2: 色值2 ;……}</style>,并通過id或者引用的方式保持對該style元素的引用,通過修改style元素innerText為 :root{--變量1: 色值3; --變量2: 色值4;……}就可以成功替換變量顔色了。
由于主題資料可能是從接口等其他地方擷取的,我們可以在使用的地方給它先加上預設值,避免主題資料到達之前出現沒有顔色的現象,比如 p { color: var(--變量1, 色值1);}這樣,就使用上了css自定義屬性來在運作時動态加載不同的主題顔色值。
- Sass/Less支援
如果直接在開發css中使用css變量很容易由于書寫問題,定義問題最後導緻變量衆多,管理困難,變更預設色值替換成本高等問題。在大型網站的開發中通常會用sass/less來預定義一些顔色變量來進行色彩管理。
在使用sass和less的時候可以改變原來的傳遞色值方式改為傳遞css自定義屬性和預設值。color定義檔案:

這裡有個副作用就是,一旦色值被定義為var變量,則這個var表達式就無法再被less/sass的色彩計算函數所計算使用,這塊我們在後面的章節再進行讨論。
定義完對應的變量之後, 使用的地方就可以直接使用使用這些變量,友善統一管理。
- 使用媒體查詢
prefer-color-scheme是浏覽器擷取系統上使用者對顔色主題的傾向性的css api,使用該api我們就可以輕松使得網站的主題跟随系統的顔色設定展示不同的顔色了。
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變量資料,假定我們的css變量的資料存儲在一個對象裡,key值為css變量名,value值為css變量在該主題下的值,那麼我們的主題切換服務的關鍵核心函數如下:
// 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函數會建立一個style元素,如果已經建立好了則直接改變style的内容。如果要支援跟随系統還需要一些額外函數的判斷,這裡就不展開了,可以參考連結,原理是通過動畫結束事件監聽媒體查詢變化,對應可以使用enquirejs庫。
至此我們打通了主題服務和css變量值在開發中的應用,下面就可以開發一個深色模式了。
深色模式開發
- 語義化色彩變量
深色模式涉及到了大量網站視覺的“反色”,在已有的網站當中,應該好好排查和梳理網站的顔色,把顔色歸一和限制到一定的變量範圍和數量裡,并給顔色的不同使用場景一個不同的語義變量名,這樣能取得場景分離的效果。
從文本顔色上我們舉個簡單的例子:
通常的網站裡都會有正文(主要文本),幫助提示資訊(次要文本),文本占位符。這裡我們可以使用三個變量來描述這些文本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 語義化變量示意
- 使用統一語義變量控制元件表現
需要定義多少的變量才恰當,這個取決于網站的色彩空間限制範圍和使用場景的定義粒度。當定義了一套變量之後我們就可以對元件/網站的不同組成部分進行變量統一。
比如搜尋框和下拉框,使用同樣的變量控制相同部分的表現,使得元件在主題變化的可以使用相同的顔色規則。
圖2 使用變量對元件進行規約
- 提供暗黑主題色值
完成了上面重要的兩步,我們就可以通過給變量提供一套新的色值來達到主題的變化了。
圖3 通過色值的切換實作深色主題切換
- 圖檔的處理
圖檔的處理并不能像文字一樣地去反轉顔色或者反轉亮度,這樣可能照成不适。通常如果有準備亮色和暗色兩套圖檔,可以采用變量化圖檔位址在不同主題下切黑圖檔。如果圖檔來自使用者輸入,其他地方的截圖,這時候需要稍微處理一些降低亮度。圖檔簡化地擷取目前的主題狀态可以在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); }
前者不适用與帶有背景圖檔的層處理,也不适合通過疊加圖檔遮擋來呈現效果的處理,但是用在文章部落格中的插入圖檔非常簡單有效,圖檔可以自然地疊加到純色深色的背景色上。後者給了另一種方案完成背景層的疊加,但對代碼有一定的入侵。
- 提供主題變化訂閱應對第三方元件場景
通過以上幾個基本的步驟就能在編碼的過程中通過使用變量指定顔色值,獲得主題的能力。但是面對大量第三方元件,有自己的主題,也可能有自己的深色主題,這塊再去入侵式地修改成自定義的變量工作量不小且并不一定合适。
這時候需要提供主題訂閱,在主題發生變化的時候,獲得通知,然後給第三方元件設定一定對應的變更。
我們需要一個簡單的eventbus,實作方式不限。這裡給出一個簡單版本的接口如下:
// theme/interface.ts export interface IEventBus { on(eventName: string, callbacks: Function): void; off(eventName: string, callbacks: Function): void; trigger(eventName: string, data: any): void; }
切換主題的時候發出themeChanged事件,使用on監聽就能夠獲得目前主題變更事件,通過判斷主題,給第三方的元件套上對應的主題,或者修改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規則裡的帶有var(--變量名, 變量值)在該行的上一行插入了一行替換為直接變量值的值,相容不支援css var的浏覽器。
- css-vars-ponyfill 使 IE9+ 和 Edge 12+支援上主題切換
css-vars-ponyfill 這個npm包可以使得ie9+/edge12+支援上css自定義屬性,它是一個帶有選項的相容方案,大概原理就是通過監聽style裡帶有var自定義屬性的值,替換為原值并插入。該相容方案目前不相容直接挂在在元素上的局部的css自定義屬性定義。該方案還提供了實時監聽style插入的選項,支援var鍊式的取值。簡單地加入polyfill就可以使用了。
// polyfill.ts import cssVars from 'css-vars-ponyfill'; cssVars({ watch: true, silent: true});
一些問題的探讨
- 什麼網站需要開發深色模式?
深色模式适合長時間閱讀、長時間沉浸式浏覽的網站,包括新聞、部落格、知識庫等文章浏覽和視訊網站,開發IDE界面等沉浸式互動。這些網站使用深色模式可以通過降低亮度減少對眼睛的刺激,減少長時間浏覽的疲憊和暈眩的感覺。
深色模式不适合一些非深色風格産品的展示,深沉的背景色會影響産品風格呈現、傳遞的情感和使用者觀看時候的心情,不适當的顔色搭配容易引起反感。像一些電商網站深色模式要慎重處理,深色可能會使得産品圖檔呈現的積極風格受到一定程度的抑制,顔色可能會影響使用者的購物欲望。一些主題推廣宣傳類的網站也是,顔色可能會削弱主題的表達。
- 有沒有更簡單的深色模式映射切換?比如使用HSL替代RGB色值。
HSL色值的表達形式是通過色相、飽和度、亮度,既然深色模式是調整亮度和飽和度,那是否可以通過hsl色值來自動計算呢? 這種自動出暗色版本的色值還有待探索中,主要有兩個原因:1)深色模式的舒适度不是線性亮度和飽和度映射能完成的,顔色的函數計算深色映射顯得相對單調。2)實際情況是一個顔色可能會映射到多個暗黑場景的顔色。
針對第一點,目前有一些UI會推出非線性反色的算法,也是為了解決顔色一起調整亮度之後變得看不清、色彩反色後沖擊過大的問題。這類的算法還有很多優化空間。在淺色搭配情況下可能很好看的顔色,放到深色下可能就會引起不舒适:不恰當的對比度會引起視覺上看不清晰;不恰當的色彩碰撞會引起反感;不恰當的飽和度、亮度會顯得UI有點髒。
針對第二點,可以舉以下的場景來說明:同樣是白色,有色背景下的白色,在深色模式下可能還是保持白色;而作為背景色的白色在深色場景下會對應調整為深色。
圖4 一種白色的存在切換主題的多種映射
此時,自動通過色值計算就需要區分顔色的周邊顔色或者底層疊加顔色來計算,這無疑加大了計算難度。是以這塊自動計算并不太容易,還需要一些的探索。
- Sass/Less使用var變量後變成字元串管理,無法對顔色進行變換計算?
本身sass/less的變量和css自定義屬性就不是一套變量系統,sass/less的是一種編譯型變量(編譯時确定值,編譯後不存在),而css是一個運作時變量(即運作時确定值)。用sass/less去管理css變量時為了管理css變量防止定義失誤,但使用了Sass或Less之後替換成var之後會發現,sass和less是一些比如lighten、fadeout、rgba等等的函數都無法使用了,因為對與sass和less來說,var(--xxx, #xxx)是一個字元串不是顔色值。這塊目前也沒有比較好的方法, 有一些文章也讨論了一些解法,如 連結,大體的思路是拆分顔色的表達為hsl形式,然後對顔色的次元進行操作處理,實際上還是不能無感覺地使用内建的色彩變換函數。
另一個解法/方案是:把涉及顔色變換的地方統一處理然後再賦予新的css變量名,不再在mixin等函數裡對顔色進行變換而是對變量名進行規則變化。
如果讀者有其他較好的思路也可以在評論裡分享。
點選關注,第一時間了解華為雲新鮮技術~