Dark Mode 实践踩坑记录

400次阅读  |  发布于2年以前

只能说,实现 Dark Mode 的尽头是手写。

手机 QQ 最近火急火燎地整改,暗黑模式的支持就是其中的一个整改项。由于腾讯课堂在手机 QQ 有一个常驻入口,因此我们也要按照它们的要求实现真正意义上的 dark mode 支持 (而不是目前手机 QQ 强制给加的一层灰色蒙层)。

大学时候有个 项目 也是自己设计和实现的 dark mode 支持,当时是手写的,依稀记得后面从哪些文章里看到说可以一行代码实现暗黑模式云云,于是企图在这次实践过程中应用下这些奇技淫巧,然而经过一天的实践,我发现这些方法有绕不过的坑,最后只得推翻重来手写一把,下面来细说一下。

常见实践

在开始写代码前先 Google 了一下,CSS Tricks 的 A Complete Guide to Dark Mode on the Web 比较推荐阅读,里面写的还挺全的。

开启方式

一般来说会有两种开启方式,一种会在页面 (通常右上角) 使用一个 switch 开关控制页面是 light 还是 dark,一种会根据系统或者应用的 Preference 来自动切换。

Manually toggle

对于手动选择的模式,我们要如何让开关和样式关联上呢?肯定要给这个开关加个事件处理函数了,里面可以去改变页面根元素的类名,通过类名控制样式,如下。

const btn = document.querySelector('.btn-toggle');

btn.addEventListener('click', function() {
  document.body.classList.toggle('dark-theme');  
})
// 方法 1: 通过改变类,并使用不同的样式
body {
  color: #222;
  background: #fff;

  a {
    color: #0033cc;
  }
}

body.dark-theme {
  color: #eee;
  background: #121212;

  a {
    color: #809fff;
  }
}
// 方法 2: 通过改变类,并使用不同的 CSS 变量
body {
  --text-color: #222;
  --bkg-color: #fff;
  --anchor-color: #0033cc;
}

body.dark-theme {
  --text-color: #eee;
  --bkg-color: #121212;
  --anchor-color: #809fff;
}

body {
  color: var(--text-color);
  background: var(--bkg-color);

  a {
    color: var(--anchor-color);
  }
}

也可以去改变加载的样式文件,通过不同的 css 文件来控制样式。

<html lang="en">
  <head>
    <link href="light-theme.css" rel="stylesheet" id="theme-link">
  </head>
</html>
const btn = document.querySelector(".btn-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";
  }
});

/* light-theme.css 文件写一份样式 */

/* dark-theme.css 文件写一份样式 */

Follow system

对于要自动适配系统的模式,我们要如何判断系统的偏好并编写样式呢?一种方法是用 CSS 所支持的 prefers-color-scheme 这个 media query 来包含样式,另一种类似,也是通过对这个 query 的匹配来判断继而添加类名和样式。

/* 通过 media query 直接写 */
@media (prefers-color-scheme: dark) { }
@media (prefers-color-scheme: light) { }
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
  document.body.classList.add('dark-theme');
} else {
  document.body.classList.remove('dark-theme');
}

用户偏好

这个功能其实是有真实的需求的,比如如果我们不记住用户偏好,那么肯定只能有一种默认值,再在加载的过程中判断是偏好 light 或 dark。这种情况下,我们势必会得罪一方 (eg. 默认 light 那么对于 dark 偏好的用户来说,肯定会先闪过白色样式再加载到正确的黑色样式,反之亦然),这种现象叫做 “flash of incorrect theme” (FOIT)。

想要记住用户偏好,可以把这个偏好值存储在 localStorage 里,不过这对于「follow system」的用户来说不适用,总不能给 system preference 添加监听函数,它一改我就去改这个偏好值吧,系统偏好不在我的管辖范围内,在页面设置偏好倒是可行。对于前后端不分离的类型,还可以把偏好值放到 cookie 里,让 server 获取到偏好从而返回对应的 HTML。

奇技淫巧

有一个方法可以一行代码搞定 dark mode,而且乍一看效果还可以。

html {
  filter: invert(1) hue-rotate(180deg);
}

解释一下这里的样式,filter 其实是滤镜,它本身提供了很多处理的接口,参考,比如模糊 blur()、灰度 grayscale()、对比度 contrast() 等。

其中 invert 的作用是反转颜色通道数值,接收的值在 0~1。可以把 invert(param) 想象成一个函数 f(value, param) = param * (255 - value) + (1 - param) * value,当 param 为 0 时这个公式退化为 f (value) = value 也就是不变色,当 param 为 1 时这个公式退化为 f(value) = 255 - value。比如一个 rgb(0, 0, 255) 在被套用 invert (1) 后会变成 rgba(255, 255, 0);一个 rgb(255, 0, 0) 在被套用 invert (0.85) 后会变成 rgb(38, 217, 217)(套公式,0.85*(255-255) + (1-0.85)* 255 = 38.25), 参考 。

其中 hue-rotate 的作用是转动色盘,接收的值在 0deg~360deg。这个其实更好理解,如下图是色盘,比如一个纯红色在被套用 hue-rotate(90deg) 后会变成绿色,相当于我的取色点针顺时针转了 90°,具体的计算和矩阵运算相关, 参考 。感觉这个转换还蛮复杂的,参考 stackoverflow 的讨论 ,还有个将 black 通过 filter 转换成想要颜色的 工具 。

在二者叠加的效果下,就会有很神奇的暗色模式了。但我们可以很明显地看到,这里的图片也被反色了,这不是我们预期的效果,一个常见的做法是给 img 标签再使用这个 filter 给反回去,它是生效的,如下图。

html {
  filter: invert(1) hue-rotate(180deg);

  // 图片反转再反转,就和原先一样了  
  img {
    filter: invert(1) hue-rotate(180deg);
  }
}

PS: 对于 invert 非 1 的,无法通过两次 revert 来反转到初始值, 参考 。

暗黑模式の坑

根据目标色反推源头色

通过 Background url 设置的图片无法反色

Filter 影响其他元素

直出页面无法设置 Dark Mode 类名

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8