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;
}

深色模式适配和主题切换-LMLPHP

接下来,使用 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);
}

深色模式适配和主题切换-LMLPHP
在上面的代码片段中,使用 CSS 自定义属性替换原来的颜色值,效果依然相同。
深色模式适配和主题切换-LMLPHP
如果不考虑兼容 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 时即“反相”。
深色模式适配和主题切换-LMLPHP
尝试写个例子:

<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;
}

深色模式适配和主题切换-LMLPHP
使 .mix-mask 显示

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

深色模式适配和主题切换-LMLPHP

官网的示例:
深色模式适配和主题切换-LMLPHP
它的源码十分简单,感兴趣可以了解下

// 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");
  }
}

亮色模式状态下:
深色模式适配和主题切换-LMLPHP

  • 按钮:右下角黑色小方块,效果图中就是点击切换它切换暗色\亮色模式。
  • 页面内容:图中蓝色部分。即该实例中的文本所在的层,包含其父级容器。
  • 混合层:按钮下方小块。混合层亮色模式下不可见,通过上面的效果图你能明白该层在切换到夜间时经过过渡动画覆盖整个页面,除了 button。
  • 自定义背景层:图中绿色边框所在层。用户自定义背景色,插件创建的层。

深色模式状态下:
深色模式适配和主题切换-LMLPHP
与浅色模式状态对比,明显之处就是藏在按钮下方的小方块展开了,覆盖了整个页面。这个展开的小方块这就是混合层,这个层包含 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。
深色模式适配和主题切换-LMLPHP

img.dark{
  filter: brightness(.8) contrast(1.2);
}
  • brightness 使图像看起来更亮或更暗
  • contrast 调整图像的对比度

深色模式适配和主题切换-LMLPHP

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;
    }
}

深色模式适配和主题切换-LMLPHP
在切换模式时,将会在页面顶层展示带有对应过渡效果的蒙层。在过渡效果显示时,用户的鼠标无法点击页面的元素,这样做同时实现了类似防抖的效果。如果想移除这个效果,只需给蒙层加上 pointer-events: none;

1.5 储存状态

仅仅通过点击按钮切换主题还不够,应该将主题保存起来。否则,用户刷新页面或者再此进入页面将回到初始主题。在切换主题或者初始化时都应该使用状态储存。

1.5.1 localStorage

localStorage.setItem("theme", <"dark" | "light">); // 存储
localStorage.getItem("theme"); // 读取

1.5.2 PHP Cookie

$_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,但在深色模式下,它不那么优雅。
深色模式适配和主题切换-LMLPHP
显然,将浅色模式下的 box-shadow 颜色颠倒并不能很好的适配深色模式。正确的做法是,使距离更远的元素颜色更“重”,距离较近的元素颜色更“轻”。
深色模式适配和主题切换-LMLPHP
即颜色越“深”,传达给用户的深度越“深”,反之越“浅”。
深色模式适配和主题切换-LMLPHP

1.8 主题色的适配

深色模式适配和主题切换-LMLPHP

利用 CSS 自定义属性实现强主题色的切换。通常,将主题色声明为 --color-primary ,然后通过 JavaScript 替换 --color-primary . 问题出现了,一个应用常常使用一个色系作为强调色,而为了便于使用,用户只能选择一个强调色。深色模式适配和主题切换-LMLPHP
可以定义一个 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})`
}

示例:这是一个简洁的浏览器插件,用户可以在新增搜索引擎时选择强调色,以区分它们。
深色模式适配和主题切换-LMLPHP
通过 hexToRgba 生成一个第二位的强调色,显示为输入框的 ring,这给应用一些增色。
深色模式适配和主题切换-LMLPHP
试着想象,当你的应用复杂,需要使用一个色系中的多个颜色作为强调色,使用 JavaScript 实现起来就不那么优雅了。使用相对 CSS 语法可以生成一个颜色表,相对颜色语法是 CSS Color Module Level 5 的一部分,限于篇幅以及当前特性正式版浏览器都还没有支持,感兴趣的小伙伴可以点击链接看看。

参考资料

05-01 09:03