【Web技術】623- 簡單好用的前端深色模式/主題化開發方案

DevUI是一支兼具設計視角和工程視角的團隊,服務於華爲雲DevCloud平臺和華爲內部數箇中後臺系統,服務於設計師和前端工程師。
官方網站:devui.design  Ng組件庫:ng-devui(歡迎Star)
官方交流羣:添加 DevUI小助手(微信號:devui-official)進羣

引言

深色模式(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定義文件:

beforeafter
// 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的色彩計算函數所計算使用,這塊我們在後面的章節再進行討論。

定義完對應的變量之後, 使用的地方就可以直接使用使用這些變量,方便統一管理。

使用媒體查詢

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的瀏覽器。

beforeafter
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自定義屬性,它是一個帶有選項的兼容方案,大概原理就是通過監聽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是一些比如lightenfadeoutrgba等等的函數都無法使用了,因爲對與sass和less來說,var(--xxx, #xxx)是一個字符串不是顏色值。這塊目前也沒有比較好的方法, 有一些文章也討論了一些解法,如 鏈接,大體的思路是拆分顏色的表達爲hsl形式,然後對顏色的維度進行操作處理,實際上還是不能無感知地使用內建的色彩變換函數。另一個解法/方案是:把涉及顏色變換的地方統一處理然後再賦予新的css變量名,不再在mixin等函數裏對顏色進行變換而是對變量名進行規則變化。如果讀者有其他較好的思路也可以在評論裏分享。

總結

本文介紹了利用CSS自定義屬性能夠給css定義一些顏色變量,輕鬆地實現深色主題的開發甚至支持更多的主題化。通過色彩變量定義,使用變量,處理圖片和處理三方組件支持實現整站的深色模式的規約和完善。進一步介紹了降級支持的方法,並對深色模式的適用範圍和一些其他方式實現進行了討論。

1. JavaScript 重溫系列(22篇全)

2. ECMAScript 重溫系列(10篇全)

3. JavaScript設計模式 重溫系列(9篇全)

4. 正則 / 框架 / 算法等 重溫系列(16篇全)

5. Webpack4 入門(上)|| Webpack4 入門(下)

6. MobX 入門(上) ||  MobX 入門(下)

7. 59篇原創系列彙總

回覆“加羣”與大佬們一起交流學習~

點擊“閱讀原文”查看70+篇原創文章

點這,與大家一起分享本文吧~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章