在浏览器中检测是否为深色模式

前言

深色模式(Dark Mode), 也叫暗黑模式, 顾名思义, 它给人最直观的感受, 就是黑.

但「深色模式」要实现理想的视觉体验, 不只是简单地将底色变黑, 将文字变白这么简答. Google 在 Material Design 的设计指南中对于「深色模式」列出的设计规范中, 第一条就是『不要使用 100% 的纯黑』.

前端工程师需要和设计师沟通, 如何做出一个对用户体验良好、实现成本合理的「黑色模式」方案;另一方面也需要关心, 如何合理使用 CSS 组合来实现「深色模式」.那么, 前端如何检测当前运行环境出于「深色模式」呢?, 并根据当前的颜色模式, 编写对应的 CSS 呢?

「深色模式」相关的 CSS MediaQuery

当前的浏览器或者其它 WebView 大都支持了关于深色模式的 CSS MediaQuery prefers-color-scheme, 它的格式如下:

@media (prefers-color-scheme: <light|dark>)

其中, light 表示「浅色模式」, dark 表示「深色模式」.

在 CSS 中使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* Light mode */
@media (prefers-color-scheme: light) {
body {
color: black;
background-color: white;
}
}

/* Dark mode */
@media (prefers-color-scheme: dark) {
body {
color: white;
background-color: black;
}
}

对应的, 你也可以在 styled-components 这类 CSS in JS 方案中使用这样.

通过 JS 检测当前环境是否处于深色模式

在我的开发工作中, 我涉及到了在基于 WebKit 引擎中检测「当前环境是否为深色模式」的需求.如何通过 JS 来检测当前环境是否为「深色模式」呢?既然 CSS 是通过 MediaQuery 来判断. 自然而然地, 我们会想到使用 matchMedia API 来判断.

window.matchMedia('<mqString>') 返回一个 listenable-like 对象 MediaQueryList, 它继承自 EventTarget, 这意味着你可以通过直接它获得最新的 MediaQuery 检测情况:

1
2
3
4
// detect if on light mode
var isLight = window.matchMedia('(prefers-color-scheme: light)').matches;
// detect if on dark mode
var isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;

对于主流浏览器而言, matchMedia API 的支持较好, Chrome >=9, Safari >=5.1 即支持. 更多浏览器的支持情况可参考 Can I use matchMedia

监听当前环境颜色模式的变化

同时, window.matchMedia('<mqString>') 返回的 MediaQueryList 具有 addEventListener/removeEventListener 接口. 在环境的 MediaQuery 特性发生变化时, MediaQueryList 会 emit change 事件. 这意味着你可以监听它相关的 MediaQuery 的最新情况:

1
2
3
4
5
6
7
8
9
10
// listenable-like object [MediaQueryList]
var mqList = window.matchMedia('<mqString>');

mqList.addEventListener('change', (event) => {
// matched prefer
if (event.matches) {
} else {
// not match prefer
}
});

于是, 你可以在环境的颜色模式变化时第一时间检测到:

1
2
3
4
5
6
7
8
9
var mqList = window.matchMedia('(prefers-color-scheme: dark)');

mqList.addEventListener('change', (event) => {
// is dark mode
if (event.matches) {
} else {
// not dark mode
}
});

MediaQueryList 对象的兼容性问题

参考兼容性列表, 我们能发现一些问题. 比如, IE 和 Opera 不支持在 addEventListener(...) 时, 使用对象作为 EventListener 传入

Safari

引用 MDN 上兼容性列表的 note:

Before Safari 14, MediaQueryList is based on EventTarget, so you must use addListener() and removeListener() to observe media query lists.

对于 Safari < 14 而言, MediaQueryList 有以下兼容性问题,

Internet Explorer

处理兼容性问题

容易想到, 我们只需要需要将上文的代码稍作更改, 就可以在 Safari < 14, IE >= 10 的环境下顺利运行:

1
2
3
4
5
6
7
8
9
var mqList = window.matchMedia('<mqString>');

// 检测 MediqQueryList 对象上是否存在 addEventListener 方法
if (mqList.addEventListener) {
mqList.addEventListener('change', (event) => {});
} else if (mqList.addListener) {
// 否则, 检测 MediqQueryList 对象上是否存在 addListener 方法
mqList.addListener((event) => {});
}

removeEventListener/removeListener 的处理类似. 我们可以使用一个函数 observeMediaChange(medieQuery, listener) 来隐藏处理细节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* @param {MediaQueryList} mqList
* @param {((this: MediaQueryList, ev: MediaQueryListEvent) => any)} listener
*/
function observeMediaChange(mqList, listener) {
let disposeFunc = () => {};
if (mqList.addEventListener && mqList.removeEventListener) {
mqList.addEventListener('change', listener);

disposeFunc = () => {
mqList.removeEventListener('change', listener);
};
} else if (mqList.addListener && mqList.removeListener) {
mqList.addListener(listener);

disposeFunc = () => {
mqList.removeListener(listener);
};
}

return disposeFunc;
}

var mqList = window.matchMedia('<mqString>');

observeMediaChange(mqList, (event) => {
// is dark mode
if (event.matches) {
} else {
// not dark mode
}
});

React Hooks 封装

我们可以基于 window.matchMedia('<mqString>') 返回的 MediaQueryList 来封装一个即时而灵敏的 useIsDarkMode Hooks:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { useState, useEffect } from 'react';

function checkIsDarkMode() {
try {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
} catch (err) {
return false;
}
}

function useIsDarkMode() {
const [isDarkMode, setIsDarkMode] = useState(checkIsDarkMode());

useEffect(() => {
const mqList = window.matchMedia('(prefers-color-scheme: dark)');

/**
*
* @param {MediaQueryListEvent} event
*/
const listener = (event) => {
setIsDarkMode(event.matches);
};

mqList.addEventListener('change', listener);

return () => {
mqList.removeEventListener('change', listener);
};
}, []);

return isDarkMode;
}

在 React Functional Component 中可以这样使用:

1
2
3
4
5
function Foo() {
const isDarkMode = useIsDarkMode();

return <div className={isDarkMode ? 'theme-dark' : 'theme-light'}>...</div>;
}

注意 上述 useIsDarkMode 实现未考虑浏览器兼容性问题, 如果需要在 Safari < 14 这样的环境中运行, 可使用 observeMediaChange 来改造 useIsDarkMode 内部的实现

扩展阅读

工作机会

若你是校招生正在寻求实习, 或者你考虑换工作, 可将简历发到 richardo2016#gmail.com. 我可协助内推以下公司:

  • 阿里巴巴(深圳, 杭州, 北京, 上海, 成都)
  • 网易(杭州)
  • 腾讯(成都, 深圳)
  • 字节跳动(杭州)
首页归档简历