1. 1. 什么是「深色模式」
  2. 2. 利用 Media Query 简单实现深色模式
    1. 2.1. CSS Variable 的方法实现深色模式
    2. 2.2. 为深色模式单独编写样式
    3. 2.3. 条件性加载深色模式的 CSS 文件
  3. 3. 「深色模式」的兼容性
  4. 4. 设计一个用户友好的「深色模式」
转载文章
原文标题:你好黑暗,我的老朋友 —— 为网站添加用户友好的深色模式支持

原文链接:https://blog.skk.moe/post/hello-darkmode-my-old-friend/
原文作者:Sukka

前几天为我的 Hexo 主题:Miracle 加入了深色模式,但我的技术还是太辣鸡,经常出现问题。

无意间看到 Sukka 大佬的文章:「你好黑暗,我的老朋友 —— 为网站添加用户友好的深色模式支持」,跟着文章重构了主题深色模式的代码,就转载过来方便学习。

什么是「深色模式」

很多操作系统在日落后会自动切换到「深色模式」、并不意味着「深色模式」就是「夜间模式」。「夜间模式」用于夜晚的弱光环境,主要目的是保护眼睛、减少强光刺激、避免影响睡眠,不难理解为什么 macOS 的 Night Shift 会自动调节屏幕色温、Android(AOSP)到了夜间可以选择启用系统级「琥珀色」滤镜。

「深色模式」更像是一个主题,即使在白天也可以使用。不论是为了在 OLED 屏幕上省电、亦或是减少白光刺激护眼、亦或是暗色模式对色盲用户更加友好,总之 macOS 率先提出了系统级的「暗色模式」、并在 WebKit 中增加了对应的 Media Query,而后 Chromium、Firefox 先后跟进,如今兼容 prefers-color-scheme 的浏览器占有率已经高达 81.82%。

利用 Media Query 简单实现深色模式

CSS 媒体查询 @media 是一个足够强大的特性,可以有条件地将样式应用于文档和各种上下文中。Media Queries Level 5 草案 中提出了深色模式的判断方式 prefers-color-scheme,包含 lightdarkno-preference 三种值。而不支持 Media Queries 5 的浏览器会直接无视 CSS 中的 prefers-color-scheme Media Query,无需额外的代码即可优雅降级。

还记得我刚刚说过「深色模式更像一个主题」么?为网站新增深色模式就如同换肤功能;搭配 prefers-color-scheme,编写深色模式的思路就如同编写响应式一般、无需赘述,结合几段 Code Snippet 一笔带过:

CSS Variable 的方法实现深色模式

:root {
--text: #333;
}

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

body {
color: var(--color-text);
}

通过维护两套 CSS Variable,可以快速切换不同的配色方案。这种方法特点是所需代码较少,缺点是 CSS Variable 的兼容性较差,可能还需要引入额外的 Polyfill。

为深色模式单独编写样式

body {
color: #333;
}

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

直接维护两套样式的方法清晰直观、任何网站都可以基于这种方法进行改造。但会造成冗余代码、较难实现统一的风格、后期不易维护。

条件性加载深色模式的 CSS 文件

/* main.css */
body {
color: #333;
}

/* dark.css */
body {
color: #fff;
}
<link rel="stylesheet" href="main.css">
<link rel="stylesheet" href="dark.css" media="(prefers-color-scheme: dark)">

利用 <link> 标签的 Media Query,甚至可以单独加载暗色模式的 CSS 文件。

需要注意 CSS 选择器的权重,因此作为可选的 dark.css 一定要放在 main.css 之后加载。

除了上述三种方式以外,使用 CSS filtermix-blend-mode 还可以实现对网站整体色调的改变,可以确保配色风格的统一性。

「深色模式」的兼容性

虽然有了优雅的 prefers-color-scheme 可以识别操作系统的显示模式,但是对于用户来说,仅依赖 Media Query 的「深色模式」并不能带来很好的体验。
首先是浏览器兼容性。虽然支持该特性的浏览器的市场占有率非常喜人,但是从版本号上来看却并不乐观:

考虑到使用 Chormium 70 内核甚至 Tencent X5 内核的国产浏览器,大部分用户并没有机会体验到深色模式。除此以外,操作系统级别的「深色模式」实现也会受到 OEM 厂商的影响 —— 虽然 Android 10(AOSP)提供「深色模式」,但是一加的 OxygenOS 却将其深藏在系统主题设置里,没有自动切换、在 Quick Settings 里也没有快速的切换开关。

设计一个用户友好的「深色模式」

受限于兼容性和复杂的操作系统,大部分网站依然在使用更传统的「开关」切换 —— 通过 toggle <html>
<body> 的 class 属性实现在两套样式之间切换、并将开关的状态记忆在 localStorage 中的方法虽然有效,却是无奈之举,手动切换开关相比 prefers-color-scheme 也不够优雅。如果将「开关」和 prefers-color-scheme 结合起来,就可以带来更好的用户体验:

  • 对于不兼容的浏览器或操作系统,访客依然可以通过开关手动切换显示模式
  • 对于兼容的浏览器或操作系统,Media Query 能够实现在两种显示模式之间切换
  • 在兼容的浏览器或操作系统上,用户还可以通过开关 override 当前的显示模式

在将两者组合在一起时,不能简单地用「开关」覆盖 prefers-color-scheme,否则用户触发开关、状态被永久记忆在 localStorage 之后,就变成了僵硬的手动模式。
举个例子。访客可能在操作系统还没有自动切换到「深色模式」时通过网站上的开关切换显示模式,经过一个夜晚后到了次日白天、访客再度访问网站时,自然希望不需要再切换开关、网站就能以常规的浅色模式显示。因此设计思路是当 prefers-color-scheme 的值发生改变(从 与用户需要的显示模式不同 变成 相同)时清空 localStorage 中储存的开关状态,此时显示模式切换回基于 Media Query 的「自动」模式。

Talk is cheap, here goes the code.

首先是 CSS:

:root {
--color-mode: 'light';
--text: #333;
}

@media (prefers-color-scheme: dark) {
:root {
--color-mode: 'dark';
}

:root:not([data-user-color-scheme]) {
--text: #eff;
}
}

[data-user-color-scheme='dark'] {
--text: #eff;
}

body {
color: var(--color-text);
}

真是令人看的头大,让我们逐行来看都是些什么:

  • :root 下定义了一个 CSS Variable --color-mode: light 和在浅色模式下用到的 CSS Variable(比如使用深色 #333 作为主要字体颜色)。
  • 使用 prefers-color-scheme 的 Media Query 定义深色模式下的 CSS Variable: --color-mode: light 。深色模式的样式(如浅色 #eff 作为主要字体颜色)要定义在 :not([data-user-color-scheme]) 伪类下以避免「开关」的行为覆盖浏览器的样式。
  • [data-user-color-scheme='dark'] 再定义一遍深色模式下用到的样式。
    有了这段 CSS,不难理解深色模式何时会生效:当操作系统使用「深色模式」且 <html><body> 标签上没有 data-user-color-scheme 属性时、或者存在 data-user-color-scheme 属性且值为 dark 时。

然后是困难的部分了:编写 JavaScript 为「开关」添加行为。

先定义一些常量:

const rootElement = document.documentElement; // <html>
const darkModeStorageKey = 'user-color-scheme'; // 作为 localStorage 的 key
const darkModeMediaQueryKey = '--color-mode';
const rootElementDarkModeAttributeName = 'data-user-color-scheme';
const darkModeTogglebuttonElement = document.getElementById(/* element id */);

接下来,用 try {} catch (e) {} 封装一下 localStorage 的操作,以应对 HTML5 Storage 被禁用、localStorage 被写满、localStorage 实现不完整的情况:

const setLS = (k, v) => {
try {
localStorage.setItem(k, v);
} catch (e) { }
}

const removeLS = (k) => {
try {
localStorage.removeItem(k);
} catch (e) { }
}

const getLS = (k) => {
try {
return localStorage.getItem(k);
} catch (e) {
return null // 与 localStorage 中没有找到对应 key 的行为一致
}
}

我们还需要一个函数读取当前 prefers-color-scheme 的方法。由于已经在 CSS 中定义了 --color-mode,所以在 JS 中直接读取就好了:

const getModeFromCSSMediaQuery = () => {
const res = getComputedStyle(rootElement).getPropertyValue(darkModeMediaQueryKey);
if (res.length) return res.replace(/\"/g, '').trim();
return res === 'dark' ? 'dark' : 'light';

// 使用 matchMedia API 的写法会优雅的多
// return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}

还记得我们需要自动取消手动模式回到 prefers-color-scheme 么?意味着我们需要一个函数清掉 LS、删掉 <html> 存在的 data-user-color-scheme 属性:

const resetRootDarkModeAttributeAndLS = () => {
rootElement.removeAttribute(rootElementDarkModeAttributeName);
removeLS(darkModeStorageKey);
}

接下来是起主要作用的函数了,负责为 <html> 标签修改 data-user-color-scheme 属性:

const validColorModeKeys = {
'dark': true,
'light': true
}

const applyCustomDarkModeSettings = (mode) => {
// 接受从「开关」处传来的模式,或者从 localStorage 读取
const currentSetting = mode || getLS(darkModeStorageKey);

if (currentSetting === getModeFromCSSMediaQuery()) {
// 当用户自定义的显示模式和 prefers-color-scheme 相同时重置、恢复到自动模式
resetRootDarkModeAttributeAndLS();
} else if (validColorModeKeys[currentSetting]) { // 相比 Array#indexOf,这种写法 Uglify 后字节数更少
rootElement.setAttribute(rootElementDarkModeAttributeName, currentSetting);
} else {
// 首次访问或从未使用过开关、localStorage 中没有存储的值,currentSetting 是 null
// 或者 localStorage 被篡改,currentSetting 不是合法值
resetRootDarkModeAttributeAndLS();
}
}

当然,「开关」还需要一个函数,这个函数负责获取相反的显示模式,同时还要将新的模式写入 localStorage 存储起来:

const invertDarkModeObj = {
'dark': 'light',
'light': 'dark'
}

const toggleCustomDarkMode = () => {
let currentSetting = getLS(darkModeStorageKey);

if (validColorModeKeys[currentSetting]) {
// 从 localStorage 中读取模式,并取相反的模式
currentSetting = invertDarkModeObj[currentSetting];
} else if (currentSetting === null) {
// localStorage 中没有相关值,或者 localStorage 抛了 Error
// 从 CSS 中读取当前 prefers-color-scheme 并取相反的模式
currentSetting = invertDarkModeObj[getModeFromCSSMediaQuery()];
} else {
// 不知道出了什么幺蛾子,比如 localStorage 被篡改成非法值
return; // 直接 return;
}
// 将相反的模式写入 localStorage
setLS(darkModeStorageKey, currentSetting);

return currentSetting;
}

相关的函数都定义完了,是时候添加函数执行了:

// 当页面加载时,将显示模式设置为 localStorage 中自定义的值(如果有的话)
applyCustomDarkModeSettings();

darkModeTogglebuttonElement.addEventListener('click', () => {
// 当用户点击「开关」时,获得新的显示模式、写入 localStorage、并在页面上生效
applyCustomDarkModeSettings(toggleCustomDarkMode());
})

我的博客也使用的这种实现,通过 Navbar 中的按钮体验一下吧!