1. 前言
  2. 「深色模式」相关的 CSS MediaQuery
    1. 在 CSS 中使用
  3. 通过 JS 检测当前环境是否处于深色模式
    1. 监听当前环境颜色模式的变化
  4. MediaQueryList 对象的兼容性问题
    1. Safari
    2. Internet Explorer
    3. 处理兼容性问题
  5. React Hooks 封装
  6. 扩展阅读
  7. 工作机会

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

前言

深色模式(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. 我可协助内推以下公司:

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