深色模式適配和主題切換

guangzan發表於2021-04-30

1.1 前置

如果你已經瞭解 CSS 自定義屬性和匹配系統主題設定的相關知識,略過此部分。

1.1.1 CSS 自定義屬性

“自定義屬性”(有時候也被稱作“CSS變數”或者“級聯變數”)是由CSS作者定義的。宣告變數時,變數名前要加上 --,例如 --example: 20px 即是一個 css 自定義屬性的宣告語句。意思是將 20px 賦值給自定義變數 --example

在 css 的任何選擇器中都可以宣告 CSS 自定義屬性,通常將所有 CSS 自定義屬性宣告在 :root 選擇器中,以便在在整個文件中重複使用。:root 選擇器匹配文件樹的根元素。對於 HTML 文件來說,:root 匹配 <html> 元素,除了優先順序更高之外,與 html 標籤選擇器相同。

示例:

:root {
  --example: 20px
}

等價於:

html {
  --example: 20px
}

通過 CSS 的 var() 函式讀取自定義屬性。例如:var(--example) 會返回 --example 所對應的值。var() 函式還可以使用第二個引數,表示自定義屬性備用值。var() 會從左向右讀取值,如果第一個變數不存在,就讀取第二個。例如:var(--example, 40px), 如果 --example 不存在,將返回 40px。當然第二個引數同樣可以使用 css 自定義屬性而不是具體的值,例如:var(--example1, --example2)

示例:

<div class="container">
  <div></div>
  <div></div>
  <div></div>
  <div></div>
</div>
.container div:nth-child(even) {
  background-color: #90ee90;
}
.container div:nth-child(odd) {
  background-color:  #ffb6c1;
}

image.png

接下來,使用 css 自定義屬性

+ :root {
+     --green: #90ee90;
+     --pink: #ffb6c1;
+ }
.container div:nth-child(even) {
-    background-color: #90ee90;
+    background-color: var(--green);
}
.container div:nth-child(odd) {
-    background-color:  #ffb6c1;
+    background-color: var(--pink);
}

image.png
在上面的程式碼片段中,使用 CSS 自定義屬性替換原來的顏色值,效果依然相同。
image.png
如果不考慮相容 IE 瀏覽器,可以使用它,已經有大量的網站使用 CSS 自定義屬性。要相容 IE 也有辦法,postcss-css-variables 外掛將 CSS 自定義屬性 (CSS 變數) 語法轉換為靜態表示形式。

1.1.2 跟隨系統設定

使用 CSS 媒體查詢匹配系統設定。prefers-color-scheme 用於檢測使用者是否有將系統的主題色設定為亮色或者暗色。

// 使用者選擇選擇使用淺色主題的系統介面
@media (prefers-color-scheme: light) { }

// 使用者選擇選擇使用深色主題的系統介面
@media (prefers-color-scheme: dark) { }

// 表示系統未得知使用者在這方面的選項
@media (prefers-color-scheme: no-preference) { }

使 JavaScript matchedMedia API 匹配系統設定。

const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)');

if (prefersDarkScheme.matches) {
  // 使用者系統主題設定為 dark
}

1.2 深色、淺色模式的實現

有多種方式實現深色模式。

1.2.1 使用 CSS 自定義屬性

使用 var() 函式的備用值實現淺色模式和深色模式之間的切換。

:root{
   --default-color: #555,
   --color: var(--dark-var, --default-var)
}

body{
  color: var(--color)
}

body 最終得到的 color 為 #555 ,如果宣告瞭 —-dark-var 變數,body 得到的 color 的值將為 —-dark-var 的值。可以通過 JavaScript 將變數 —-dark-var 插入到 css ,或者通過媒體查詢。

@media (prefers-color-scheme: dark) {
  :root {
    --dark-color: #fff
  }
}

1.2.2 給 HTML 標籤新增屬性

:root 選擇器會匹配 html 元素,給 <html> 動態新增 theme 屬性,像這樣 <html theme="dark"> 。在 CSS 中使用屬性選擇器 :root[theme="dark"] 匹配深色模式。

:root{
    --color: #555
}
:root[theme="dark"]{
    --color: #fff
}
body {
  color: var(--color)
}

<html> 的屬性 theme 的值不為 "dark" 時,var() 函式讀取的是 :root{} 內的自定義屬性(淺色模式匹配的的自定義屬性),反之,則讀取的是 :root[theme="dark"] 中的自定義屬性。同樣也可以結合媒體查詢,實現跟隨系統的效果:

@media (prefers-color-scheme: dark) {
  :root {
    --color: #fff
  }
}

這種方式的好處是擴充套件性更強,程式碼量較少,程式碼維護也更加方便。不僅僅可以切換到深色模式,還可以切換到其他主題。例如給 html 的 theme 屬性設定其他值 <html theme="pink"> ,只需要新增下面這段 css:

:root[theme="pink"]{
    --color: pink
    // ...
}

通過 JavaScript 給 <html> 的 theme 屬性賦值為 "pink" ,就能切換到該主題。

1.2.3 使用 class 和 CSS 自定義屬性

類似的思路我們可以給 <html> 新增一個 class 來實現。

:root{
  --color: #222;
}

:root.dark{
  --color: #eee;
}
const button = document.querySelector('.toggle');

button.addEventListener('click', function() {
  document.html.classList.toggle('dark');
})

1.2.4 僅使用 class

如果你的專案需要相容 IE,僅使用 class 作為標識也可以實現效果。通過 JavaScript 改變 body 上的 class 來決定網站使用的主題。

<body class="dark || light">
const btn = document.querySelector('.toggle');

btn.addEventListener('click', function() {
  document.body.classList.toggle('dark');
})
body {
  color: #222;
  background: #fff;
}

body.dark{
  color: #eee;
  background: #121212;
}

試想以下,使用者設定深色模式的作業系統並不意味著他們希望將深色模式應用到網站上。如果有此需求,可以先使用媒體查詢覆蓋深色模式。

:root {
  --color: #000000;
}

:root.dark{
  --color: #ffffff;
}

@media (prefers-color-scheme: dark) {
  :root {
    --color: #ffffff;
  }
  :root.light {
    --color: #000000;
  }
}

1.2.5 使用單獨的 css 檔案

light-theme.css

body {
  color: #222;
  background: #fff;
}

dark-theme.css

body {
  color: #eee;
  background: #121212;
}

這時候你可能會有疑問了,如何通過點選切換主題呢?在引入 css 時這樣做:

<head>
  <link href="light-theme.css" rel="stylesheet" id="theme-link">
</head>

link 一個標籤一個 ID, 就可以通過 JavaScript 選擇它了。

const btn = document.querySelector(".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";
  }
});

1.2.6 Darkmode.js

GitHub 開源專案 Darkmode.js,通過 CSS 屬性 mix-blend-mode 暴力實現深色模式,現在它有 2.2k Star。mix-blend-mode 描述當前元素的內容應該與當前元素的直系父元素的內容和元素的背景如何混合,值為 difference 時即“反相”。
image.png
嘗試寫個例子:

<body>
  <div class="container">
    <p>
      Lorem ipsum dolor sit amet consectetur adipisicing elit. Delectus facere
      rerum quasi nesciunt nam, nisi velit minima rem quaerat laboriosam natus
      ab illum tempore atque repellendus tempora, vitae ratione repellat.
    </p>
  </div>
  <div class="mix-mask"></div>
</body>
body {
  background-color: #fff;
}
.container {
  width: 600px;
  margin: 60px auto 0;
  padding: 40px;
  background-color: #fff;
  border: 1px solid #ccc;
  border-radius: 4px;
}
.mix-mask {
  display: none;
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  mix-blend-mode: difference;
  background-color: #fff;
}

image.png
使 .mix-mask 顯示

.mix-mask {
-   display: none;
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    mix-blend-mode: difference;
    background-color: #fff;
}

image.png

官網的示例:
darkmodejs-效果.gif
它的原始碼十分簡單,感興趣可以瞭解下

// es module
// 通過 typeof 判斷當前是否為瀏覽器環境,並匯出常量
export const IS_BROWSER = typeof window !== "undefined";

// es6 支援匯出 class
// class 只是一個語法糖,babel 轉化
export default class Darkmode {
  // constructor -> class例項化時執行
  // 使用者通過例項化該類並傳遞一個 options
  // 建構函式接收 options -> 使用者配置
  constructor(options) {
    if (!IS_BROWSER) {
      return;
    }

    // 預設配置
    const defaultOptions = {
      bottom: "32px", // 按鈕位置
      right: "32px", // 按鈕位置
      left: "unset", // 按鈕位置
      time: "0.3s", // 過渡時間
      mixColor: "#fff", // 混合層背景色
      backgroundColor: "#fff", // 建立的背景層背景色
      buttonColorDark: "#100f2c", // 亮色狀態下的按鈕顏色
      buttonColorLight: "#fff", // 暗色狀態下的按鈕色
      label: "", // 按鈕中的內容
      saveInCookies: true, // 是否存在cookie 預設 local storage
      autoMatchOsTheme: true, // 跟隨系統設定
    };

    // 通過 Object.assign 合併預設配置和使用者配置
    // 淺拷貝
    options = Object.assign({}, defaultOptions, options);

    // 需要在 css 使用配置
    // style 以字串的形式呈現
    // 如果單獨抽離css,需要更多的邏輯程式碼
    const css = `
      .darkmode-layer {
        position: fixed;
        pointer-events: none;
        background: ${options.mixColor};
        transition: all ${options.time} ease;
        mix-blend-mode: difference;
      }

      .darkmode-layer--button {
        width: 2.9rem;
        height: 2.9rem;
        border-radius: 50%;
        right: ${options.right};
        bottom: ${options.bottom};
        left: ${options.left};
      }

      .darkmode-layer--simple {
        width: 100%;
        height: 100%;
        top: 0;
        left: 0;
        transform: scale(1) !important;
      }

      .darkmode-layer--expanded {
        transform: scale(100);
        border-radius: 0;
      }

      .darkmode-layer--no-transition {
        transition: none;
      }
      
      .darkmode-toggle {
        background: ${options.buttonColorDark};
        width: 3rem;
        height: 3rem;
        position: fixed;
        border-radius: 50%;
        border:none;
        right: ${options.right};
        bottom: ${options.bottom};
        left: ${options.left};
        cursor: pointer;
        transition: all 0.5s ease;
        display: flex;
        justify-content: center;
        align-items: center;
      }

      .darkmode-toggle--white {
        background: ${options.buttonColorLight};
      }

      .darkmode-toggle--inactive {
        display: none;
      }

      .darkmode-background {
        background: ${options.backgroundColor};
        position: fixed;
        pointer-events: none;
        z-index: -10;
        width: 100%;
        height: 100%;
        top: 0;
        left: 0;
      }

      img, .darkmode-ignore {
        isolation: isolate;
        display: inline-block;
      }

      @media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) {
        .darkmode-toggle {display: none !important}
      }

      @supports (-ms-ime-align:auto), (-ms-accelerator:true) {
        .darkmode-toggle {display: none !important}
      }
    `;

    // 混合層 -> 反相
    const layer = document.createElement("div");
    // 按鈕 -> 點選切換夜間模式
    const button = document.createElement("button");
    // 背景層 -> 使用者自定義背景色
    const background = document.createElement("div");

    // 初始化類(初始樣式)
    button.innerHTML = options.label;
    button.classList.add("darkmode-toggle--inactive");
    layer.classList.add("darkmode-layer");
    background.classList.add("darkmode-background");

    // 通過 localStorage 儲存狀態
    // darkmodeActivated 獲取當前是否在darkmode下
    const darkmodeActivated =
      window.localStorage.getItem("darkmode") === "true";

    // 系統是否預設開啟暗色模式
    // matchMedia 方法的值可以是任何一個 CSS @media 規則 的特性。
    // matchMedia 返回一個新的 MediaQueryList 物件,表示指定的媒體查詢字串解析後的結果。
    // matches	boolean	如果當前document匹配該媒體查詢列表則其值為true;反之其值為false。
    const preferedThemeOs =
      options.autoMatchOsTheme &&
      window.matchMedia("(prefers-color-scheme: dark)").matches;

    // 是否儲存localStorage
    const darkmodeNeverActivatedByAction =
      window.localStorage.getItem("darkmode") === null;

    if (
      (darkmodeActivated === true && options.saveInCookies) ||
      (darkmodeNeverActivatedByAction && preferedThemeOs)
    ) {
      // 啟用夜間模式
      layer.classList.add(
        "darkmode-layer--expanded",
        "darkmode-layer--simple",
        "darkmode-layer--no-transition"
      );
      button.classList.add("darkmode-toggle--white");
      // 啟用 darkmode 時,將類 darkmode--activated 新增到body
      document.body.classList.add("darkmode--activated");
    }

    // 插入
    document.body.insertBefore(button, document.body.firstChild);
    document.body.insertBefore(layer, document.body.firstChild);
    document.body.insertBefore(background, document.body.firstChild);

    // 將 css 插入 <style/>
    this.addStyle(css);

    // 初始化變數 button layer saveInCookies time
    // 方便函式中呼叫
    this.button = button;
    this.layer = layer;
    this.saveInCookies = options.saveInCookies;
    this.time = options.time;
  }

  // 接收樣式 css 字串
  // 建立 link 標籤在 head 中插入
  addStyle(css) {
    const linkElement = document.createElement("link");

    linkElement.setAttribute("rel", "stylesheet");
    linkElement.setAttribute("type", "text/css");
    // 使用encodeURIComponent將字串編碼
    linkElement.setAttribute(
      "href",
      "data:text/css;charset=UTF-8," + encodeURIComponent(css)
    );
    document.head.appendChild(linkElement);
  }

  // 切換按鈕
  showWidget() {
    if (!IS_BROWSER) {
      return;
    }

    const button = this.button;
    const layer = this.layer;
    // s -> ms
    const time = parseFloat(this.time) * 1000;

    button.classList.add("darkmode-toggle");
    button.classList.remove("darkmode-toggle--inactive");
    layer.classList.add("darkmode-layer--button");

    // 監聽點選事件
    button.addEventListener("click", () => {
      // 當前是否在暗色模式
      // isActivated()返回 bool 見下方
      const isDarkmode = this.isActivated();

      if (!isDarkmode) {
        // 新增過渡樣式
        layer.classList.add("darkmode-layer--expanded");
        // 禁用按鈕
        button.setAttribute("disabled", true);
        setTimeout(() => {
          // 清除過渡動畫
          layer.classList.add("darkmode-layer--no-transition");
          // 顯示混合層
          layer.classList.add("darkmode-layer--simple");
          // 取消禁用
          button.removeAttribute("disabled");
        }, time);
      } else {
        // 邏輯相反
        layer.classList.remove("darkmode-layer--simple");
        button.setAttribute("disabled", true);
        setTimeout(() => {
          layer.classList.remove("darkmode-layer--no-transition");
          layer.classList.remove("darkmode-layer--expanded");
          button.removeAttribute("disabled");
        }, 1);
      }

      // 處理按鈕樣式,黑暗模式下背景色為白色調,反之為暗色調
      // 如果 darkmode-toggle--white 類值已存在,則移除它,否則新增它
      button.classList.toggle("darkmode-toggle--white");
      // 如果 darkmode--activated 類值已存在,則移除它,否則新增它
      document.body.classList.toggle("darkmode--activated");
      // 取反存 localStorage
      window.localStorage.setItem("darkmode", !isDarkmode);
    });
  }

  // 允許使用方法 toggle()啟用/禁用暗模式
  // 即以程式設計的方式切換模式,而不是使用內建的按鈕
  // new Darkmode().toggle()
  toggle() {
    if (!IS_BROWSER) {
      return;
    }

    const layer = this.layer;
    const isDarkmode = this.isActivated();

    // 處理樣式
    layer.classList.toggle("darkmode-layer--simple");
    document.body.classList.toggle("darkmode--activated");
    // 存狀態
    window.localStorage.setItem("darkmode", !isDarkmode);
  }

  // 檢查是否啟用了暗色模式
  isActivated() {
    if (!IS_BROWSER) {
      return null;
    }
    // 通過判斷body是否包含啟用css class
    // contains 陣列方法 返回 bool
    return document.body.classList.contains("darkmode--activated");
  }
}

亮色模式狀態下:
darkmodejs-retract.jpg

  • 按鈕:右下角黑色小方塊,效果圖中就是點選切換它切換暗色\亮色模式。
  • 頁面內容:圖中藍色部分。即該例項中的文字所在的層,包含其父級容器。
  • 混合層:按鈕下方小塊。混合層亮色模式下不可見,通過上面的效果圖你能明白該層在切換到夜間時經過過渡動畫覆蓋整個頁面,除了 button。
  • 自定義背景層:圖中綠色邊框所在層。使用者自定義背景色,外掛建立的層。

深色模式狀態下:
darkmodejs-extend.jpg
與淺色模式狀態對比,明顯之處就是藏在按鈕下方的小方塊展開了,覆蓋了整個頁面。這個展開的小方塊這就是混合層,這個層包含 CSS 屬性 mix-blend-mode: difference。正是如此實現的暗色模式。通過簡單的“反相”,很顯然並不能完美地實現深色模式,當網站內容較簡單時或許可以嘗試。

npm install --save darkmode-js
const options = {
	// ...options
  label: '?',
}

const darkmode = new Darkmode(options);
darkmode.showWidget();

1.2.7 使用服務端指令碼

以 PHP 為例:

<?php
$themeClass = '';
if (isset($_GET['theme']) && $_GET['theme'] == 'dark') {
  $themeClass = 'dark-theme';
}

$themeToggle = ($themeClass == 'dark-theme') ? 'light' : 'dark';
?>

<!DOCTYPE html>
<html lang="en">
<!-- etc. -->
<body class="<?php echo $themeClass; ?>">
  <a href="?theme=<?php echo $themeToggle; ?>">Toggle Dark Mode</a>
  <!-- etc. -->
</body>
</html>

實現切換單獨的 css 檔案:

<?php
$themeStyleSheet = 'light-theme.css';
if (isset($_GET['theme']) && $_GET['theme'] == 'dark') {
  $themeStyleSheet = 'dark-theme.css';
}

$themeToggle = ($themeStyleSheet == 'dark-theme.css') ? 'light' : 'dark';
?>

<!DOCTYPE html>
<html lang="en">
<head>
  <!-- etc. -->
  <link href="<?php echo $themeStyleSheet; ?>" rel="stylesheet">
</head>

<body>
  <a href="?theme=<?php echo $themeToggle; ?>">Toggle Dark Mode</a>
  <!-- etc. -->
</body>
</html>

這種方法有一個明顯的缺點:需要重新整理頁面才能進行切換。但是,像這樣的伺服器端解決方案對於跨頁面重新載入持久化使用者的主題選擇非常有用。

1.3 CSS 自定義屬性的粒度

在定義 CSS 自定義屬性時應該儘可能掌控粒度。粒度太大不好掌控細節,太小會導致程式碼量巨大,不易維護。總之,視專案具體而定。如果專案較小,頁面較簡單,通常抽象化地宣告 CSS 自定義變數:

:root {
    --primary: #0097ff;
    --secondary: #6c757d;
    --success: #28a745;
    --info: #17a2b8;
    --warning: #ffc107;
    --danger: #dc3545;
    --color-basic-50: #ffffff;
    --color-basic-75: #fafafa;
    --color-basic-100: #f5f5f5;
    --color-basic-200: #eaeaea;
    --color-basic-300: #e1e1e1;
    --color-basic-400: #cacaca;
    --color-basic-500: #b3b3b3;
    --color-basic-600: #8e8e8e;
    --color-basic-700: #6e6e6e;
    --color-basic-800: #4b4b4b;
    --color-basic-900: #2c2c2c;
}

如果專案複雜,,像上面這些命名抽象的自定義屬性無法兼顧每個細節,就需要宣告更加細化(具體)的自定義屬性。例如:

:root{
    --color-counter-text: #24292e;
    --color-counter-bg: rgba(209,213,218,0.5);
    --color-counter-primary-text: #fff;
    --color-counter-primary-bg: #6a737d;
    --color-counter-secondary-text: #6a737d;
    --color-counter-secondary-bg: rgba(209,213,218,0.5);
    --color-input-bg: #fff;
    --color-input-contrast-bg: #fafbfc;
    --color-input-border: #e1e4e8;
    --color-input-shadow: inset 0 1px 2px rgba(27,31,35,0.075);
    --color-input-disabled-bg: #f6f8fa;
    --color-avatar-bg: #fff;
    --color-avatar-border: transparent;
    --color-avatar-stack-fade: #d1d5da;
    --color-avatar-stack-fade-more: #e1e4e8;
    --color-avatar-child-shadow: -2px -2px 0 hsla(0,0%,100%,0.8);
    // ...
}

CSS 自定義屬性的屬性名稱規範十分重要,即使粒度足夠小,也能利於維護。通常格式是:

--[attribute]-[element]-[elementAttribute]-[x]: [value]
// eg:
--color-label-border: #e1e4e8;

當專案足夠複雜,推薦的方式是,同時宣告具體的 CSS 自定義屬性和抽象化的 CSS 自定義屬性,具體的 CSS 自定義屬性的值引用抽象化的 CSS 自定義屬性。

:root {
    --color-text-primary: #555;
    // ...
}
:root {
    --color-notifications-button-hover-text: var(--color-text-primary);
    // ...
}

1.3 細節處理

1.3.1 圖片處理

大多數網站不僅僅只有文字,還有圖片。使用 CSS3 filter 屬性來處理圖片。filter 同樣不支援 IE 11。
image.png

img.dark{
  filter: brightness(.8) contrast(1.2);
}
  • brightness 使影像看起來更亮或更暗
  • contrast 調整影像的對比度

1.3.2 shadow 處理

值得一提的是,不要天真地顛倒 box-shadow 顏色以適配深色模式,這一部分將在後文 “設計”部分解釋。

1.4 切換過渡動畫

從深色模式切換到淺色模式,或者從淺色模式切換到深色模式,或許需要新增一個過渡動畫,這能改善體驗。

1.4.1 不建議使用 transition

你或許立即想到了 CSS3 transition 屬性,但是會發現大多數支援深色模式的網站在切換主題時並沒有過渡效果,這樣做時有些不足之處。如果使用 css transition 屬性,在您切換模式時應該給頂層元素一個 class,例如 mode-change ,在切換完成之後再將它移除。SCSS 程式碼大至如下:

.mode-change {
  selector1,
  selector2,
  // ...... All children seloctors {
        transition: all 0.3s cubic-bezier(1, 0.05, 0.29, 0.99);
  }
}

假如使用 transition 新增過渡效果,您需要給所有元素新增過渡效果才能使整體擁有過渡動效,顯然,這將帶來巨大的硬體開銷以及維護上的困難。

1.4.2 試試“障眼法”

障眼法是個巧妙的方法,使用它實現很多令人驚歎的效果。現在,甚至用它來優化 CSS。不妨迴歸最初,擁護過渡動效的目的是什麼?在切換時給使用者一個過渡效果。實現原理是使用 CSS 偽元素建立一個帶有過渡效果的蒙層,思路是在切換時給文件的根元素新增一個 class,通過給此 class 新增偽元素以建立帶有過渡動畫的蒙層。例如,在淺色切換到深色時 class 為 light-to-dark,反之,為 dark-to-light

$mode: () !default;
$mode: map-merge(
    (
        bg-light: #fff,
        bg-dark: #252528,
    ),
    $mode
);

$bg-light: map-get($mode, bg-light);
$bg-dark: map-get($mode, bg-dark);

.dark-to-light:after {
    content: '';
    width: 100vw;
    height: 100vh;
    position: fixed;
    z-index: 99999;
    left: 0;
    top: 0;
    margin-left: 0;
    background-color: $bg-dark;
    opacity: 0.7;
    animation: toLight 1s linear 0s forwards;
    // pointer-events: none;
}

.light-to-dark:after {
    content: '';
    width: 100vw;
    height: 100vh;
    position: fixed;
    z-index: 99999;
    left: 0;
    top: 0;
    margin-left: 0;
    background-color: $bg-light;
    opacity: 0.7;
    animation: toDark 1s linear 0s forwards;
    // pointer-events: none;
}
@keyframes toLight {
    0% {
        background-color: $bg-dark;
        opacity: 0.7;
    }
    100% {
        background-color: $bg-light;
        opacity: 0;
    }
}

@keyframes toDark {
    0% {
        background-color: $bg-light;
        opacity: 0.7;
    }
    100% {
        background-color: $bg-dark;
        opacity: 0;
    }
}

Video_2021-04-29_132403.gif
在切換模式時,將會在頁面頂層展示帶有對應過渡效果的蒙層。在過渡效果顯示時,使用者的滑鼠無法點選頁面的元素,這樣做同時實現了類似防抖的效果。如果想移除這個效果,只需給蒙層加上 pointer-events: none;

1.5 儲存狀態

僅僅通過點選按鈕切換主題還不夠,應該將主題儲存起來。否則,使用者重新整理頁面或者再此進入頁面將回到初始主題。在切換主題或者初始化時都應該使用狀態儲存。

1.5.1 localStorage

localStorage.setItem("theme", <"dark" | "light">); // 儲存
localStorage.getItem("theme"); // 讀取
$_COOKIE['theme'] == 'dark'

1.6 Vuex 實踐

思路是,使用者第一次進入應用時讀取系統設定並跟隨系統設定;如果 localStorage 已經儲存了標識,證明使用者手動設定過(使用者偏好),根據 localStorage 設定深色、淺色模式。這是核心實現,你還可以根據需求擴充套件功能。

state

export type State = {
    theme: string
}
export const state: State = {
    theme: '',
}

actions

import { ActionContext, ActionTree } from 'vuex'
import { Mutations, MutationType } from './mutations'
import { State } from './state'

export enum ActionTypes {
    InitTheme = 'INIT_THEME',
    ToggleTheme = 'TOGGLE_THEME',
}

type ActionArgs = Omit<ActionContext<State, State>, 'commit'> & {
    commit<k extends keyof Mutations>(
        key: k,
        payload: Parameters<Mutations[k]>[1]
    ): ReturnType<Mutations[k]>
}

export type Actions = {
    [ActionTypes.InitTheme](context: ActionArgs): void
    [ActionTypes.ToggleTheme](context: ActionArgs): void
}

export const actions: ActionTree<State, State> & Actions = {
    [ActionTypes.InitTheme]({ commit }) {
        // 匹配系統設定,初始化深色模式或亮色模式。
        const cachedTheme = localStorage.theme ? localStorage.theme : false
        const userPrefersDark = window.matchMedia(
            '(prefers-color-scheme: dark)'
        ).matches

        if (cachedTheme) commit(MutationType.SetTheme, cachedTheme)
        else if (userPrefersDark) commit(MutationType.SetTheme, 'dark')
        else commit(MutationType.SetTheme, 'light')
    },
    [ActionTypes.ToggleTheme]({ commit }) {
        switch (localStorage.theme) {
            case 'light':
                commit(MutationType.SetTheme, 'dark')
                break
            default:
                commit(MutationType.SetTheme, 'light')
                break
        }
    },
}

getters

import { GetterTree } from 'vuex'
import { State } from './state'

export type Getters = {
    getTheme(state: State): State['theme']
}

export const getters: GetterTree<State, State> & Getters = {
    getTheme: state => {
        return state.theme
    },
}

使用

store.dispatch(ActionTypes.ToggleTheme)

1.7 深色模式設計

要實現使用者體驗良好的深色模式並不是一件容易的事情,在設計上也有許多考量。

1.7.1 飽和度

就像淺色模式下儘量避開“純白”一樣,在深色模式下也要儘量避開“純黑”。試著回想以下,你曾使用過的“電子書”軟體,其背景大多不是“純白”。無論“純黑”還是“純白”,使用者長時間瀏覽可能導致不適。良好的深色是灰色系與不飽和顏色相結合,Web 內容可訪問性指南 (WCAG) AA 標準,建議至少 4.5:1。

1.7.1 對比度

在深色模式下,選擇合適對比度是最低保障。如果沒有選擇合適的對比度,導致文字難以閱讀,使用者難以提取資訊。谷歌 Material Design 的建議是文字和其背景的對比度為 15.8:1,在 IOS 規範中,建議對比度至少是 7:1。

1.7.3 層次

一些常見的前端元件庫(Vuetify、MD...)中有 elevation (海拔)屬性,因為它所傳達的是“高度”,我們可以理解為深度、層次。elevation 屬性即給當前元件新增 box-shadow,但在深色模式下,它不那麼優雅。
image.png
顯然,將淺色模式下的 box-shadow 顏色顛倒並不能很好的適配深色模式。正確的做法是,使距離更遠的元素顏色更“重”,距離較近的元素顏色更“輕”。
image.png
即顏色越“深”,傳達給使用者的深度越“深”,反之越“淺”。
image.png

1.8 主題色的適配

image.png

利用 CSS 自定義屬性實現強主題色的切換。通常,將主題色宣告為 --color-primary ,然後通過 JavaScript 替換 --color-primary . 問題出現了,一個應用常常使用一個色系作為強調色,而為了便於使用,使用者只能選擇一個強調色。a.png
可以定義一個 JavaScript 函式來生成這些顏色:

/**
 * 將 16 進位制顏色轉成 rgb 或 rgba
 * @param {string} hex
 * @param {number} opacity
 */
export function hexToRgba(hex: string, opacity: number): string {
    const rgbReg = /^rgb\(/
    if (rgbReg.test(hex)) return hex
    const hexReg = /^#([0-9a-fA-f]{3}|[0-9a-fA-f]{6})$/
    if (!hexReg.test(hex)) return hex
    const red = parseInt('0x' + hex.slice(1, 3))
    const green = parseInt('0x' + hex.slice(3, 5))
    const blue = parseInt('0x' + hex.slice(5, 7))
    const rgb = `rgb(${red},${green},${blue})`
    if (!opacity) return rgb
    return `rgba(${red},${green},${blue},${opacity})`
}

示例:這是一個簡潔的瀏覽器外掛,使用者可以在新增搜尋引擎時選擇強調色,以區分它們。
image.png
通過 hexToRgba 生成一個第二位的強調色,顯示為輸入框的 ring,這給應用一些增色。
Video_2021-04-29_160137.gif
試著想象,當你的應用複雜,需要使用一個色系中的多個顏色作為強調色,使用 JavaScript 實現起來就不那麼優雅了。使用相對 CSS 語法可以生成一個顏色表,相對顏色語法是 CSS Color Module Level 5 的一部分,限於篇幅以及當前特性正式版瀏覽器都還沒有支援,感興趣的小夥伴可以點選連結看看。

參考資料

相關文章