web深色模式适配

前置内容

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

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

在上面的代码片段中,使用 CSS 自定义属性替换原来的颜色值,效果依然相同。

image

如果不考虑兼容 IE 浏览器,可以使用它,已经有大量的网站使用 CSS 自定义属性。要兼容 IE 也有办法,postcss-css-variables 插件将 CSS 自定义属性 (CSS 变量) 语法转换为静态表示形式。

跟随系统设置

使用 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
}

你可能已经发现,一些应用在你切换模式后提示你重启。但你可以借助事件监听实现应用运行时跟随系统设置。

function handleFollowSystemTheme () {
    const userPrefersDark = window.matchMedia (
        '(prefers-color-scheme: dark)'
    ).matches
    changeTheme (userPrefersDark ? 'dark' : 'light')
}

深色、浅色模式的实现

有多种方式实现深色模式。

采用原生 HTML 支持

 <meta name="color-scheme" content="dark">

使用 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
  }
}

给 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" ,就能切换到该主题。

使用 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');
})

仅使用 class

如果你的项目需要兼容 IE,仅使用 class 作为标识也可以实现效果。通过 JavaScript 改变 body 上的 class 来决定网站使用的主题。

<body class="dark || light">
body {
  color: #222;
  background: #fff;
}

body.dark {
  color: #eee;
  background: #121212;
}
const btn = document.querySelector ('.toggle');

btn.addEventListener ('click', function () {
  document.body.classList.toggle ('dark');
})

试想以下,用户设置深色模式的操作系统并不意味着他们希望将深色模式应用到网站上。如果有此需求,可以先使用媒体查询覆盖深色模式。

:root {
  --color: #000000;
}

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

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

使用单独的 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";
  }
});

使用 darkmode.js

GitHub 开源项目 Darkmode.js,通过 CSS 属性 mix-blend-mode 暴力实现深色模式,现在它有 2.2k Star。mix-blend-mode 描述当前元素的内容应该与当前元素的直系父元素的内容和元素的背景如何混合,值为 difference 时即 “反相”。

image

<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

使 .mix-mask 显示

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

image

darkmode.js 示例:

image

我对它的源码添加了注释,你可以了解它

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

亮色模式状态下:

image

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

深色模式状态下:

image

与浅色模式状态对比,明显之处就是藏在按钮下方的小方块展开了,覆盖了整个页面。这个展开的小方块这就是混合层,这个层包含 CSS 属性 mix-blend-mode: difference。正是如此实现的暗色模式。通过简单的 “反相”,很显然并不能完美地实现深色模式,当网站内容较简单时或许可以尝试。

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

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

使用服务端脚本

以 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>

这种方法有一个明显的缺点:需要刷新页面才能进行切换。但是,像这样的服务器端解决方案对于跨页面重新加载持久化用户的主题选择非常有用。

细节处理

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

图片处理

大多数网站不仅仅只有文本,还有图片。使用 CSS3 filter 属性来处理图片。filter 同样不支持 IE 11。
image.png

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

shadow 处理

值得一提的是,不要天真地颠倒 box-shadow 颜色以适配深色模式,这一部分将在后文 “设计” 部分解释。

favicon 处理

大多数现代浏览器在深色与浅色模式下的网页标签背景色不同,这可能导致你的网站 favicon 无法被清晰展示。通过媒体查询给你的 favicon 填充不同的颜色。

favicon.svg

<svg 
  t="1662262046840" 
  class="icon" 
  viewBox="0 0 1024 1024" 
  version="1.1" 
  xmlns="http://www.w3.org/2000/svg" 
  p-id="3971" 
  width="64" 
  height="64"
>
  <style>
    path {
      fill: #000;
    }
    @media (prefers-color-scheme: dark) {
      path {
        fill: #fff;
      }
    }
  </style>
  <path d="M554.688 500.352v256H469.312v-256h-128L512 314.24l170.688 186.24h-128zM1024 640.192C1024 782.912 919.872 896 787.648 896h-512C123.904 896 0 761.6 0 597.504 0 451.968 94.656 331.52 226.432 302.976 284.16 195.456 391.808 128 512 128c152.32 0 282.112 108.416 323.392 261.12C941.888 413.44 1024 519.04 1024 640.192z m-259.2-205.312c-24.448-129.024-128.896-222.72-252.8-222.72-97.28 0-183.04 57.344-224.64 147.456l-9.28 20.224-20.928 2.944c-103.36 14.4-178.368 104.32-178.368 214.72 0 117.952 88.832 214.4 196.928 214.4h512c88.32 0 157.504-75.136 157.504-171.712 0-88.064-65.92-164.928-144.96-171.776l-29.504-2.56-5.888-30.976z" p-id="3972"></path>
</svg>

合理使用 transtion

常常使用 transtion 属性为元素添加过渡效果,你应当在伪类选择器中(例如::hover)使用它,而不是直接为元素添加 transtion。最好在 transtion 的值中指定要过渡的单一或多个属性,而不是全部(all)。不合理使用 transition 属性会导致切换深色、浅色模式时的 “噪点”。

image

切换过渡效果

从深色模式切换到浅色模式,或者从浅色模式切换到深色模式,或许需要添加一个过渡动画,这可以改善体验。非常重要的一点,这种做法使切换动画 “可控”。当用户打开应用时,通常会读取本地存储初始化对应的模式。在夜间非常不希望看到应用首次打开时的瞬间白屏,很可能就是这个短暂的过渡动画导致的。

不建议通过 transition 实现。你或许立刻想到了使用 CSS3 transition 属性,如果使用 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 添加过渡效果,你需要给所有元素添加过渡效果才能使整体拥有过渡动效,显然,这将带来巨大的硬件开销以及维护上的困难。初始化应用时还可能导致瞬间 “白屏”。

不如试试 “障眼法”。“障眼法” 是个巧妙的办法,许多令人惊叹的 CSS 效果都基于这个的思想。现在,甚至用它来优化 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;
    }
}

image

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

设置浏览器配色方案

操作系统配色方案的常见选择是 “亮” 和 “暗”,或者是 “白天模式” 和 “夜间模式”。当用户选择其中一种配色方案时,操作系统会对用户界面进行调整。这包括表单控件、滚动条和 CSS 系统颜色的使用值。通过 CSS 属性 color-scheme 指示元素在浏览器中呈现的配色方案。以浏览器中的滚动条为例,在网页的深色模式下,滚动条颜色却可能是浅色:

根据你实现深色模式技术方案,以在 HTML 元素上添加 class 为例:

html.dark {
  color-scheme: dark;
}

储存状态

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

localStorage

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

实践

深色模式的实现是比较容易的,但是给应用添加完善的深色模式体验绝非易事。一个完善的深色模式逻辑上应该满足以下几点:

提供深色 - 浅色模式切换按钮,当切换模式时,整个应用的模式都将改变,用户下次打开应用时应该读取此配置并应用。

提供跟随系统设置开关,该设置优先级最高,打开此开关将使模式切换开关进入禁用状态且应用应该立即切换到系统设置对应主题。此时应用还应监听系统设置更改,系统设置更改时,应用随时切换到对应模式。应用再次进入时,由于跟随系统设置的优先级最高,不应再跟随本地存储的模式标识。当关闭跟随系统时,应该立即应用深色 - 浅色模式切换按钮中选中的状态。其中在打开跟随系统时将切换模式的开关禁用,这本身是比较合理的,还能避开更复杂的逻辑状态更新带来的问题。

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)

多状态的示例

这里有一个我写的应用,样式库使用 windicss(类 tailwindcss),前端组件库使用 naive-ui,需要同时处理这两者的状态,并提供友好的设置项。点击这里

深色模式设计

要实现用户体验良好的深色模式绝非易事,在设计上也有许多考量。

饱和度

就像浅色模式下尽量避开 “纯白” 一样,在深色模式下也要尽量避开 “纯黑”。试着回想以下,你曾使用过的 “电子书” 软件,其背景大多不是 “纯白”。无论 “纯黑” 还是 “纯白”,用户长时间浏览可能导致不适。良好的深色是灰色系与不饱和颜色相结合,Web 内容可访问性指南 (WCAG) AA 标准,建议至少 4.5:1。

对比度

在深色模式下,选择合适对比度是最低保障。如果没有选择合适的对比度,导致文本难以阅读,用户难以提取信息。谷歌 Material Design 的建议是文本和其背景的对比度为 15.8:1,在 IOS 规范中,建议对比度至少是 7:1。

层次

一些常见的前端组件库(Vuetify、MD...)中有 elevation (中文 “海拔”)的概念,因为它所传达的是 “高度”,可以理解为深度、层次。elevation 属性即给当前组件添加 box-shadow,但在深色模式下,它不那么优雅。

image.png

显然,将浅色模式下的 box-shadow 颜色颠倒并不能很好的适配深色模式。正确的做法是,使距离更远的元素颜色更 “重”,距离较近的元素颜色更 “轻”。

image.png

即颜色越 “深”,传达给用户的深度越 “深”,反之越 “浅”。

image.png

主题色的适配

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 的一部分,限于篇幅以及当前特性正式版浏览器都还没有支持,感兴趣的小伙伴可以点击链接看看。

参考资料

posted @ 2020-08-19 12:24  guangzan  阅读(4177)  评论(5编辑  收藏  举报