暗黑模式的实践

发布于 大漠

在《如何使用CSS实现黑暗模式和高亮模式的切换》和《给网站添加暗黑模式指南》主要围绕着如何使用CSS的技术给Web添加Dark Mode的功能。在这篇文章中我们把重点放在了React环境中,详细介绍了如何在React中实现Dark Mode的效果,另外还向大家介绍了一Darkmode.js这样的JavaScript怎样实现Dark Mode的效果。感兴趣的欢迎继续往下阅读。

颜色色板

暗黑模式要做的第一步是选择一个调色板。使用CSS滤镜filter:invert(100%)获得暗黑模式是一种快速且耍小聪明的方法。你也可以遵循@Daniel Eden的建议,并将其与hue-rotate(180deg)配合起来使用,以获得更好的效果。它弥补了色调反转(invert(100%))。然而还有很多东西需要做,因为色调反转可能会导致意想不到的结果,比如对比度差或者破坏UI元素视觉重要性。使用CSS混合模式(mix-blend-mode: different),存在类似的现象:

我们需要一个更加适合于暗黑模式设计的调色板。特别是设计一个具有可访问性的调色板。WCAG 2.1规范对于颜色的对比度是有一定的要求的,只有符合这个规范对于可访问性才是友好的。下面这些文章都分享了非常宝贵的建议:

在这些文章我学到了一些关于黑色色设计原则的一些经验:

避免纯黑纯白

在暗黑模式的主题设计中并不是非黑即白的设计,也就是说不是在纯黑色(#000)的背景上放置纯白色的元素(#fff)。这种强烈的对比会让人很痛苦,也不符合WCAG规范的设计要求。对于深色系我们应该使用更安全的颜色。Material Design指南推荐使用深灰色(#121212)作为主题的面板颜色。因为在深灰色的表面上的浅色文字比黑色的表面上的浅色文字有更少的对比度,另外深灰色的表面可以表达更大范围的颜色、高度和深度,而且它更容易看到灰色的阴影。

如果在组件表面上应用了半透明的白色覆盖层,根据海拔可以有不同的不透明度。

避免在深色主题上使用饱和色

饱和色在浅色的表面上效果会更好,但在深色的表面上会产生视觉上的震撼,让人很难看清。Material Design指南建议使用浅色调(200~500范围内的颜色),因为它们在黑暗的主题表面上具有更好的可读性。较轻的变化不会使用户界面缺乏表现力,但他们帮助你保持适当的对比度,而不会造成用户眼睛疲劳。

符合可访问性色彩对比标准

为了确保你的内容在暗黑模式下仍然清晰可见。深色主题表面必须足够深,以显示白色文本。Material Design指南推荐的文本和背景之间的对比度至少要达到15.8:1。WCAG要求文本和背景之间的对比度要达到AA级别(正常文本是4.5:1,大文本是3:1)。

可以使用颜色对比度工具来进行检测。

文本使用“On”颜色

“On”颜色是出现在组件和关键面板的“On”上的颜色。它们通常用于文本。黑色主题的默认文本颜色是纯白色(#fff)。但纯白色是一种明亮的颜色,它会在黑暗的背景下产生视觉上的“震动”。Material Design指南推荐使用稍暗的白色:

  • 高度强调的文本应该有87%的不透明度(基于#fff
  • 中等强调的文本应该有60%的不透明度(基于#fff
  • 禁用文本应该有38%的不透明度(基于#fff

减少大块色块

在亮色主题中,我们经常使用大块亮色。这通常是好的:“我们最重要的元素可能会更亮”。但在暗色主题中,这是行不通的:“大块的颜色从我们最重要的元素中提取焦点”。例如,屏幕中的提示框。在亮色主题中,粉红色的覆盖不会分散用户注意力;但在暗色主题中,同样的覆盖会分散用户注意力。如果完全去掉覆盖层,这样就可以快速、轻松地关注重要的事情。

实现方案

时至今日要实现暗黑模式已经不是很难的事情了。在Web中高亮和暗黑模式的切换已经是非常简单的了,实现的方式方法也有很多种。接下来,我们来一起探讨实际的方案。

方案一:CSS自定义属性

CSS自定义属性已经是非常成熟的CSS技术了,实现暗黑模式,我们就可以借助CSS自定义属性的能力。我们可以利用CSS自定义属性动态属性的特性帮助我们实现高亮和暗色系主题之间的切换,并且帮助我们了解如何使用它们来提高系统的可读性、可维护性和灵活性。

我们可以像下面这样来声明一个自定义属性:

.container {
    --text-color: #fff;
}

然后像下面这样调用已声明的自定义属性:

.container__child{
    color: var(--text-color);
}

另外还有一种使用方式呢有点类似于元素的类的使用:

<button class="button"></button>
<button class="button button--primay"></button>

.button {
    --background-color: #16dbc4;
    background-color: var(--background-color);
}

.button--primary {
    --background-color: #43cbff;
}

但是,CSS自定义有着更强大的能力,我们应该在设计系统中就好好的利用好CSS自定义属性这方面的能力。

首先要做的就是在:root中声明自定义属性。在大多数情况下,它与html元素相同,不同的是:root选择器的权重要高于html选择器

可以在:root中指定主题需要的自定义属性,比如文本颜色、背景颜色等:

:root {
    --primary-background-color: #fff;
    --primary-text-color: #0e0e0e;
    --secondary-background-color: #eee;
    --secondary-text-color: #000;
    --tertiary-background-color: #ff8034;
    --tertiary-text-color: #fff;
}

然后可以使用这些自定义属性来指定元素所需要的颜色值:

.paragraph {
    color: var(--primary-text-color);
}

.label {
    color: var(--secondary-text-color);
}

.box {
    background-color: var(--tertiary-background-color);
    color: var(--tertiary-text-color);
}

一般情况之下,很少Web网站或Web应用需要提供多套主题色系。但是,自iOS13之后,暗黑模式越来越流行,这样一来我们就需要给Web网站或Web应用至少提供两套主题模式:LightDark。针对这样的场景,CSS自定义属性的优势就显现出来了。

首先我们默认加载的主题是亮色系,我们可以在:root中声明亮色系所需要的颜色,比如:

:root {
    --text-color: #444;
    --background-color: #f4f4f4;
}

然后通过媒体查询prefers-color-scheme: dark为暗色系重置所需要的颜色:

@media screen and (prefers-color-scheme: dark) {
    :root {
        --text-color: rgba(255,255,255,.8);
        --background-color: #121212;
    }
}

有关于使用CSS自定义属性完成换肤效果的更多介绍还可以参阅:

方案二:在React中使用ThemeProvider切换暗黑模式

这个方案主要是基于styled-components库的<ThemeProvider>构建一个允许用户在亮模式和暗模式之间切换的切换器。我们将创建一个useDarkMode的自定义钩子函数,它支持prefers-color-scheme媒体查询,可以根据用户的iOS配色方案来设置暗黑模式。

初始化项目

使用create-react-app来初始化项目,初始化完项目之后,安装styled-components

» npx create-react-app react-theming-dark-mode
» cd react-theming-dark-mode
» npm i styled-components -D

接下来在src/目录下创建global.jstheme.js两个文件。在global.js文件中创建主题需要的基本样式,然后在theme,js中创建亮色和暗色系主题需要的变量。

// » src/theme.js

export const lightTheme = {
    body: '#e2e2e2',
    text: '#363537',
    toggleBorder: '#fff',
    gradient: 'linear-gradient(#39598a, #79d7ed)',
}

export const darkTheme = {
    body: '#363537',
    text: '#fafafa',
    toggleBorder: '#6b8096',
    gradient: 'linear-gradient(#091236, #1e215d)',
}

你可以根据自己的喜好来定义变量,上面的代码只是为了演示。

// » src/global.js

import { createGlobalStyle } from 'styled-components'

export const GlobalStyles = createGlobalStyle`
    *,
    *::after,
    *::before {
        box-sizing: border-box;
    }

    body {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        min-height: 100vh;
        background: ${({theme}) => theme.body};
        color: ${({ theme }) => theme.text };
        padding: 0;
        margin: 0;
        font-family: BlinkMacSystemFont, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
        transition: all 0.25s linear;
    }

    a {
        color: ${({ theme }) => theme.text};
    }
`;

接着在App.js中添加我们所需要的代码:

// » src/App.js

import React from 'react';
import { ThemeProvider } from 'styled-components';
import { lightTheme, darkTheme } from './theme';
import { GlobalStyles } from './global';

function App() {
    return (
        <ThemeProvider theme={lightTheme}>
            <>
                <GlobalStyles />
                <button> Toggle Theme</button>
                <h1>It's a light theme!</h1>
            </>
        </ThemeProvider>
    );
}

export default App;

App.js导入了lightThemedarkThemeThemeProvider,并把lightTheme传递给ThemeProvidertheme。另外把全局样式GlobalStyles引入进来,并且集中放置在同一个地方。

这个时候你在浏览器中可以看到上图这样的效果:

实现主题切换功能

到目前为止,主题还无法切换(亮色系和暗色系两主题之间的切换)。我们只需要几行代码就可以实现它。

首先,从react中导入useState钩子函数:

import React, { useState } from 'react';

接下来,使用钩子函数创建一个本地状态,它将跟踪当前的主题,并添加一个函数来切换主题点击:

// » src/App.js

const [theme, setTheme] = useState('light');

// 创建主题切换函数
const toggleTheme = () => {
    if (theme === 'light') {
        setTheme('dark')
    } else {
        setTheme('light')
    }
}

剩下的就是将这个切换函数toggleTheme()传递给button元素,并有条件地更改主题。

// » src/App.js
import React, { useState } from "react";
import { ThemeProvider } from "styled-components";
import { lightTheme, darkTheme } from "./theme";
import { GlobalStyles } from "./global";

function App() {
    const [theme, setTheme] = useState("light");

    // 创建主题切换函数
    const toggleTheme = () => {
        if (theme === "light") {
            setTheme("dark");
        } else {
            setTheme("light");
        }
    };
    return (
        <ThemeProvider theme={ theme === 'light' ? lightTheme : darkTheme}>
            <>
                <GlobalStyles />
                <button onClick={toggleTheme}> Toggle Theme</button>
                <h1>It's a light theme!</h1>
            </>
        </ThemeProvider>
    );
}

export default App;

这时点击“Toggle Theme”按钮时,就可以看到亮色系和暗色系两主题之间的切换效果:

工作原理

// » src/global.js

background: ${({ theme }) => theme.body};
color: ${({ theme }) => theme.text};
transition: all 0.25s linear;

GlobalStyles对象中,我们把theme对象的bodytext的值分配给backgroundcolor属性。所以,现在我们切换主题时,值会根据传递给ThemeProviderdarkThemelightTheme对象而变化。并且添加了CSS的transition,让主题切换时,颜色有一个平滑的过渡效果。

创建Toggle组件

事实上,我们已经实现了主题切换的功能。但我们可以做得更好一些,比如将这个切换功能抽取出来放到独立的组件,即Toggle组件。这样的好处是,该组件我们可以重复使用。这也是使用React的原因之一,也是其优势之处,对吧。

首先在src/目录下创建一个components目录,并且在该目录中创建一个名为Toggle组件,然后在该目录中创建index.js文件,然后添加下面的代码:

// » src/components/Toggle/index.js

import React from 'react'
import {func, string} from 'prop-types'
import styled from 'styled-components'
import {ReactComponent as MoonIcon} from '../assets/icons/moon.svg'
import {ReactComponent as SunIcon } from '../assets/icons/sun.svg'

const Toggle = ({ theme, toggleTheme }) => {
    const isLight = theme === 'light'
    return (
        <button onClick={toggleTheme}>
            <SunIcon />
            <MoonIcon />
        </button>
    )
}

Toggle.propTypes = {
    theme: string.isRequired,
    toggleTheme: func.isRequired,
}

export default Toggle;

Toggle组件传递了themetoggleTheme两个propstheme将提供当前的主题(lightdark),toggleTheme是个函数,将用于lightdark两主题之间的切换。接着创建了一个isLight变量,它将根据当前主题返回一个布尔值。稍后我们将把它传递给我们的样式组件。

我们还从styled-components中导入了一个styled函数。你可以在导入之后将其添加到你的文件顶部,或者创建一个专用的文件,比如Toggle.styled.js。为了演示该示例,我们直接在文件顶部添加这段示例代码:

// » src/components/Toggle/index.js

import React from 'react'
import { func, string } from 'prop-types';
import styled from 'styled-components';

import {ReactComponent as MoonIcon} from '../../assets/icons/moon.svg'
import {ReactComponent as SunIcon } from '../../assets/icons/sun.svg'

const ToggleContainer = styled.button`
    position: relative;
    display: flex;
    justify-content: space-between;
    background: ${({ theme }) => theme.gradient};
    width: 8rem;
    height: 3.5rem;
    margin: 0 auto;
    border-radius: 30px;
    border: 2px solid ${({ theme }) => theme.toggleBorder};
    font-size: 0.5rem;
    padding: 0.5rem;
    overflow: hidden;
    cursor: pointer;

    svg {
        width: 2.5rem;
        height: auto;
        transition: all 0.3s linear;
        &:first-child {
            transform: ${({ lightTheme }) => lightTheme ? 'translateY(0)' : 'translateY(100px)'};
        }
        &:nth-child(2) {
            transform: ${({ lightTheme }) => lightTheme ? 'translateY(-100px)' : 'translateY(0)'};
        }
    }
`;

const Toggle = ({ theme, toggleTheme }) => {
    const isLight = theme === 'light';

    return (
        <ToggleContainer lightTheme={isLight} onClick={toggleTheme} >
            <SunIcon />
            <MoonIcon />
        </ToggleContainer>
    );
};

Toggle.propTypes = {
    toggleTheme: func.isRequired,
    theme: string.isRequired,
}

export default Toggle;

将Icon图标当作组件导入,这样可以直接更改SVG图标的样式。我们检查lightTheme是否是激活的,如果是,我们将相应的图标移出可见区域——有点像月亮在白天消失,太阳在晚上消失。

不要忘记在Toggle中使用ToggleContainer组件替代button。不管是你是在单独的文件中进行样式化还是在组件文件/Toggle/index.js中进行样式化。另外确保将isLight变量传递给lightTheme这个props,用来指定当前主题。

<ToggleContainer lightTheme={isLight} onClick={toggleTheme} >
    <SunIcon />
    <MoonIcon />
</ToggleContainer>

最后要做的就是在App.js导入Toggle组件,并把需要的props传递给Toggle组件。另外,为了增加一点互动性,将标题中的文本内容根据theme的值为做切换:

// » src/App.js

import React, { useState } from "react";
import { ThemeProvider } from "styled-components";
import { lightTheme, darkTheme } from "./theme";
import { GlobalStyles } from "./global";
import Toggle from './components/Toggle';

function App() {
    const [theme, setTheme] = useState("light");

    // 创建主题切换函数
    const toggleTheme = () => {
        if (theme === "light") {
        setTheme("dark");
        } else {
        setTheme("light");
        }
    };
    return (
        <ThemeProvider theme={ theme === 'light' ? lightTheme : darkTheme}>
            <>
                <GlobalStyles />
                <Toggle theme={theme} toggleTheme={toggleTheme} />
                <h1>It's a {theme === 'light' ? 'light theme' : 'dark theme'}!</h1>
            </>
        </ThemeProvider>
    );
}

export default App;

这个时候你看到的效果如下:

创建useDarkMode钩子函数

在构建应用时,我们应该始终记住应用程序必须是灵活的,可扩展,可复用的,这样我们就可以在许多地方甚至不同的项目中使用它。

这就是为什么我们最好把切换功能抽取出来放到一个单独的地方。在React中通自定义一个钩子函数,可以很好的帮助我们达到这样的目的。接下来,我们来看看怎么通过创建useDarkMode钩子函数,把相关的主题切换的功能抽取到该钩子函数中。

首先在src目录下创建一个名为useDarkMode.js文件,并将所需要的逻辑移到这个文件中:

// » src/useDarkMode.js

import { useEffect, useState } from 'react'

export const useDarkMode = () => {
    const [theme, setTheme] = useState('light')

    const toggleTheme = () => {
        if (theme === 'light') {
            window.localStorage.setItem('theme', 'dark')
            setTheme('dark')
        } else {
            window.localStorage.setItem('theme', 'light')
            setTheme('light')
        }
    }

    useEffect(() => {
        const localTheme = window.localStorage.getItem('theme')
        localTheme && setTheme(localTheme)
    }, [])

    return [theme, toggleTheme]
}

在这里我们添加了一些东西。我们希望主题在浏览器的不同会话之间保持不变,所以如果有人选择了一个暗色主题,他们在次访问应用程序时也会是暗色主题。这是一个用户体验的优化,避免同一用户不同时间访问应用要对主题做切换。由于这个原因,我们在代码中加入了localStorage相关的功能。

我们还使用了useEffect钩子函数来检查组件的挂载。如果用户之前选择了一个主题,我们将把它传递给setTheme()函数。最的,我们将返回我们的主题,它包含所选择的themetoggleTheme

现在我们可以把创建好的useDarkMode钩子导入到App.js中。

// » src/App.js

import React from "react";
import { ThemeProvider } from "styled-components";
import { lightTheme, darkTheme } from "./theme";
import { GlobalStyles } from "./global";
import Toggle from './components/Toggle';
import { useDarkMode } from './useDarkMode'

function App() {
    // const [theme, setTheme] = useState("light");
    
    const [theme, toggleTheme] = useDarkMode()
    const themeMode = theme === 'light' ? lightTheme : darkTheme;

    // 创建主题切换函数
    // const toggleTheme = () => {
    //   if (theme === "light") {
    //     setTheme("dark");
    //   } else {
    //     setTheme("light");
    //   }
    // };

    return (
        // <ThemeProvider theme={ theme === 'light' ? lightTheme : darkTheme}>
        <ThemeProvider theme={ themeMode }>
            <>
                <GlobalStyles />
                <Toggle theme={theme} toggleTheme={toggleTheme} />
                <h1>It's a {theme === 'light' ? 'light theme' : 'dark theme'}!</h1>
            </>
        </ThemeProvider>
    );
}

export default App;

这几乎是完美的,但有一件小事我们可以做,使我们的体验更好。切换到黑色主题并重新加载页面。你看到了?在很短的一段时间里,太阳的图标加载在月亮的图标之前。这是因为useState钩子最初启动了light主题,之后运行useEffect钩子函数检查localStorage,然后才将theme设置为dark

到止前为止,有两个解决方案。第一个是检查useState是否有localStorage值:

// » src/useDarkMode.js

const [theme, setTheme] = useState(window.localStorage.getItem('theme') || 'light');

另外一个方案相对来说更为复杂。将创建另一个状态,并将其称为componentMounted。然后在useEffect钩子中,我们检查localTheme,如果localStorage中没有theme,将会添加它。之后,将setComponentMounted设置为true。最后将componentmount添加到return语句中。

// » src/useDarkMode.js

import { useEffect, useState } from 'react'

export const useDarkMode = () => {
    const [theme, setTheme] = useState('light')
    const [componentMounted, setComponentMounted] = useState(false)

    const toggleTheme = () => {
        if (theme === 'light') {
            window.localStorage.setItem('theme', 'dark')
            setTheme('dark')
        } else {
            window.localStorage.setItem('theme', 'light')
            setTheme('light')
        }
    }

    useEffect(() => {
        const localTheme = window.localStorage.getItem('theme')
        if (localTheme) {
            setTheme(localTheme)
        } else {
            setTheme('light')
            window.localStorage.setItem('theme', 'light')
        }

        setComponentMounted(true)
    }, [])

    return [theme, toggleTheme, componentMounted]
}

你可能已经注意到,我们有一些重复的代码片段。在编写代码时,我们总是试图遵循DRY原则,现在我们有机会使用它。我们可创建一个单独的函数来设置状态(state),并将theme传递给localStorage。我相信它最好的名字是setTheme,但我们已经用过它了,所以我们把它名字设置为setMode

const setMode = mode => {
    window.localStorage.setItem('theme', mode)
    setTheme(mode)
};

重新修改后的useDarkMode.js像下面这样:

// » src/useDarkMode.js

import { useEffect, useState } from 'react'

export const useDarkMode = () => {
    const [theme, setTheme] = useState('light')
    const [componentMounted, setComponentMounted] = useState(false)

    const setMode = mode => {
        window.localStorage.setItem('theme', mode)
        setTheme(mode)
    }

    const toggleTheme = () => {
        if (theme === 'light') {
            setMode('dark')
        } else {
            setMode('light')
        }
    }

    useEffect(() => {
        const localTheme = window.localStorage.getItem('theme')
        if (localTheme) {
            setTheme(localTheme)
        } else {
            setMode('light')
        }

        setComponentMounted(true)
    }, [])

    return [theme, toggleTheme, componentMounted]
}

我们只修改了一点代码,但是它看起来好多了,而且更容易阅读和理解!

虽然如此,但我们还可以做得更好一些。回到componentMounted属性。我们将使用它来检查我们的组件是否已经挂载,因为这是在useEffect钩子函数中发生的事情。如果它还没有发生,我们可以先渲染出一个空的div。只需在App.js文件中加入:

if (!componentMounted) {
    return <div />
};

修改之后的App.js完整代码如下所示:

// » src/App.js

import React from "react";
import { ThemeProvider } from "styled-components";
import { lightTheme, darkTheme } from "./theme";
import { GlobalStyles } from "./global";
import Toggle from './components/Toggle';
import { useDarkMode } from './useDarkMode'

function App() {
    const [theme, toggleTheme, componentMounted] = useDarkMode()
    const themeMode = theme === 'light' ? lightTheme : darkTheme;

    if (!componentMounted) {
        return <div />
    };

    return (
        <ThemeProvider theme={ themeMode }>
            <>
                <GlobalStyles />
                <Toggle theme={theme} toggleTheme={toggleTheme} />
                <h1>It's a {theme === 'light' ? 'light theme' : 'dark theme'}!</h1>
            </>
        </ThemeProvider>
    );
}

export default App;

使用用户喜欢的配色方案

在前面我们知道,CSS的媒体查询特性prefers-color-scheme,可以用来检测用户是否在系统上设置了自己喜好的主题(亮色系还是暗色系):

如果用户在系统级别上设置了默认的配色是暗色系,那么网站或应用就将相应的更改为暗色系主题。实现这个效果非常简单,在CSS中,我们只需要一个媒体查询即可:

@media (prefers-color-scheme: dark) {
    // 暗色系需要的主题样式
}

useDarkMode钩子函数中要实现该功能也不难,只需要在useEffect钩子函数中检查浏览器是否支持该媒体特性,并设置适当的主题。为此,我们将使用window.matchMedia检查它是否存在,是否支持暗黑模式。我们还需要记住localTheme,如果它可用,我们不想用dark值覆盖它,当然,除非该值设置为light。如果我们的检查通过,将设置暗色系主题。

useEffect(() => {
    if (
    window.matchMedia &&
    window.matchMedia('(prefers-color-scheme: dark)').matches && 
    !localTheme
    ) {
        setTheme('dark')
    }
})

正如前面提到的,我们需要记住localTheme的存在——这就是为什么我们需要在检查它的地方实现前面的逻辑。

// Before
useEffect(() => {
    const localTheme = window.localStorage.getItem('theme');
    if (localTheme) {
        setTheme(localTheme);
    } else {
        setMode('light');
    }
})

// After

useEffect(() => {
    const localTheme = window.localStorage.getItem('theme');
    window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches && !localTheme ? setMode('dark') : localTheme ? setTheme(localTheme) : setMode('light');})
})

调整完之后,useDarkMode.js的代码如下:

// » src/useDarkMode.js

import { useEffect, useState } from 'react'

export const useDarkMode = () => {
    const [theme, setTheme] = useState('light')
    const [componentMounted, setComponentMounted] = useState(false)

    const setMode = mode => {
        window.localStorage.setItem('theme', mode)
        setTheme(mode)
    }

    const toggleTheme = () => {
        if (theme === 'light') {
            setMode('dark')
        } else {
            setMode('light')
        }
    }

    useEffect(() => {
        const localTheme = window.localStorage.getItem('theme')

        window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches && !localTheme ? setMode('dark') : localTheme ? setTheme(localTheme) : setMode('light')

        setComponentMounted(true)
    }, [])

    return [theme, toggleTheme, componentMounted]
}

它会更改模式,将theme保存在localStorage中,并根据用户在系统中设置配置方案相应地设置默认主题:

最终的效果如下:

继续优化useDarkMode钩子函数

正如大家所看到的,其实上面我们已经完成了所需要的效果。但在useDarkMode这个钩子函数中所涵盖的功能还是功能复杂,其中包括了本地存储的功能,媒体特性查询的功能。为了得胜更好的维护代码,提高代码的灵活性和可维护性,可复用性的。我们很有必要将集成到useDarkMode钩子中的功能继续细化出来:

  • 面向用户的Toogle组件允许用户对主题色切换。在这种情况之下,我们将返回查询操作系统设置。实现此功能的所有逻辑都将通过React钩子函数从组件中抽象出来
  • useDarkMode钩子通知Toogle组件,用户喜欢哪种配色方案,并提供一个setter函数来更改当前设置
  • useLocalStorage钩子由useDarkMode调用,以便在访问站点期间将用户的首选项保存在localStorage
  • useMediaQuery钩子允许我们检查用户在系统中首选的配色方案

首先在src目录下创建一个hooks目录,并且在该目录下分别创建useDarkMode.jsuseLocalStorage.jsuseMediaQuery.js三个文件,分别放置实现useDarkModeuseLocalStorageuseMediaQuery三个钩子函数功能的代码。

useMediaQuery

具体代如下:

// » /src/hooks/useMediaQuery.js

import { useEffect, useState } from "react";

function useMedia(queries, values, defaultValue) {
    const [value, setValue] = useState(defaultValue);

    const mediaQueryLists = queries.map(q => window.matchMedia(q));

    const getValue = () => {
        const index = mediaQueryLists.findIndex(mql => mql.matches);

        return typeof values[index] !== "undefined" ? values[index] : defaultValue;
    };

    useEffect(() => {

        setValue(getValue);

        const handler = () => setValue(getValue);

        mediaQueryLists.forEach(mql => mql.addListener(handler));

        return () => mediaQueryLists.forEach(mql => mql.removeListener(handler));
    }, [getValue, mediaQueryLists]);

    return value;
}

export default useMedia;
useLocalStorage

具体代码如下:

// » /src/hooks/useLocalStorage.js
import { useState } from 'react'

function useLocalStorage(key, initialValue) {
    const [storedValue, setStoredValue] = useState (() => {
        try {
            const item = window.localStorage.getItem(key)
            return item ? JSON.parse(item) : initialValue
        } catch (error) {
            console.error(error)
            return initialValue
        }
    })

    const setValue = value => {
        try {
            const valueToStore = value instanceof Function ? value(storedValue) : value
            setStoredValue(valueToStore)
            window.localStorage.setItem(key, JSON.stringify(valueToStore))
        } catch (error){
            console.error(error)
        }
    }

    return [storedValue, setValue]
}

export default useLocalStorage
useDarkMode

具体代码如下:

// » /src/hooks/useDarkMode.js

import { useEffect } from 'react'
import useLocalStorage from './useLocalStorage'
import useMedia from './useMediaQuery'

function useDarkMode() {
    const [enabledState, setEnabledState] = useLocalStorage('dark-mode-enabled')

    const prefersDarkMode = usePrefersDarkMode()
    
    const enabled = typeof enabledState !== 'undefined' ? enabledState : prefersDarkMode

    useEffect(() => {
        const className = 'dark-mode'
        const element = window.document.body

        if(enabled) {
            element.classList.add(className)
        } else {
            element.classList.remove(className)
        }
    },[enabled])

    return [enabled, setEnabledState]
}

function usePrefersDarkMode() {
    return useMedia(['(prefers-color-scheme: dark)'], [true], false)
}

export default useDarkMode
DarkModeToggle组件

为了和前面示例中的Toggle组件区分开来,这里在src/components目录下新创建一个DarkModeToggle组件:

//  » /src/components/DarkModeToggle/index.js

import React from 'react'
import {ReactComponent as MoonIcon} from '../../assets/icons/moon.svg'
import {ReactComponent as SunIcon } from '../../assets/icons/sun.svg'

const DarkModeToggle = ({ darkMode, setDarkMode }) => (
    <div className='dark-mode-toggle'>
        <button type="button" onClick={() => setDarkMode(false)}>
            <SunIcon />
        </button>
        <span className="toggle-control">
            <input 
                className='dmcheck' 
                id='dmcheck'
                type='checkbox'
                checked={darkMode}
                onChange={() => setDarkMode(!darkMode) }
            />
            <label htmlFor='dmcheck' />
        </span>
        <button type="button" onClick={() => setDarkMode(true)}>
            <MoonIcon />
        </button>
    </div>
)

export default DarkModeToggle

为了测试上面列出的功能有效,在App.js中引入相应DarkModeToggle组件:

// » /src/App.js

import React from "react";
import DarkModeToggle from './components/DarkModeToggle';
import  useDarkMode  from './hooks/useDarkMode'
import './App.css'

function App() {

    const [darkMode, setDarkMode] = useDarkMode()

    return (
        <>
            <div className="navbar">
                <DarkModeToggle darkMode={darkMode} setDarkMode={setDarkMode} />
            </div>

            <div className="content">
                <h1>It's a {darkMode ? 'dark' : 'light'} theme</h1>
            </div>
        </>
    )
}

export default App;

对应的CSS如下:

// » /src/App.css
.dark-mode .navbar {
    background-color: #1a1919;
}

.navbar {
    display: flex;
    background-color: #95d3ff;
    padding: 20px;
}

.content {
    padding: 20px 30px;
}

h1 {
    font-size: 1.6rem;
    text-align: center;
}

.dark-mode-toggle {
    display: flex;
    margin: 0 auto;
    align-items: center;
}
.dark-mode-toggle > button {
    font-size: 1.2em;
    background: none;
    border: none;
    cursor: pointer;
}

.dark-mode-toggle > button:focus {
    outline: none;
}

.dark-mode-toggle svg {
    width: 1.5em;
    height: 1.5em;
}

.toggle-control {
    position: relative;
    padding: 0 4px;
    display: flex;
    align-items: center;
}

input[type="checkbox"].dmcheck {
    width: 40px;
    height: 10px;
    background: #555;
    position: relative;
    border-radius: 5px;
    -webkit-appearance: none;
    -moz-appearance: none;
    appearance: none;
    cursor: pointer;
    vertical-align: 2px;
    outline: none;
}
input[type="checkbox"]:checked + label {
    left: 30px;
}

input[type="checkbox"]:focus-visible {
    outline: solid 2px white;
}

input[type="checkbox"] + label {
    display: inline-block;
    width: 18px;
    height: 18px;
    border-radius: 50%;
    transition: all 0.3s ease;
    cursor: pointer;
    position: absolute;
    left: 2px;
    background: #fff;
    opacity: 0.9;
    background-color: #f6f6f6;
}

// » /src/index.css
html,
body {
    margin: 0;
    padding: 0;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
        Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}

body {
    background-color: #fff;
    color: #333;
    transition: background-color 0.3s ease;
}

body.dark-mode {
    background-color: #232323;
    color: #dfdfdf;
}

这个时候你有浏览器中能看到如下的效果:

不过在上面的示例中,我们没有使用方案一中提到的 CSS自定义属性。其实我们可以将CSS自定义属性结合进来,那么根据方案一中提到的技术点,将样式提取出相应的自定义属性:

// » /src/index.css

:root {
    --page-bg-color: #fff;
    --page-color: #333;
    --nav-bar-bg-color: #95d3ff;
}

@media screen and (prefers-color-scheme: dark) {
    :root {
        --page-bg-color: #232323;
        --page-color: #dfdfdf;
        --nav-bar-bg-color: #1a1919;
    }
}

body {
    background-color: var(--page-bg-color);
    color: var(--page-color);
}

.navbar {
    background-color: var(--nav-bar-bg-color);
}

事实上这个时候,Dark Mode的切换只会根据系统中的设置来进行切换,换句话说,顶部的切换按钮并不会生效:

在Chrome浏览器中可以对Dark Mode切换设置:

有关于这方面的设置可以阅读 @Dan Klammer的《Quick developer tools tip: simulating dark/light colour mode》一文。

上面提到了,如果在@media screen and (prefers-color-scheme: dark)设置了自定义属性,那么顶部的切换按钮(即DarkModeToggle组件)无效。如桌希望切换能生效的话,我们可以稍作修改。首先对自定义属性做一些调整:

// » /src/index.css

:root {
    --page-bg-color: #fff;
    --page-color: #333;
    --nav-bar-bg-color: #95d3ff;
}

/* @media screen and (prefers-color-scheme: dark) {
    :root {
        --page-bg-color: #232323;
        --page-color: #dfdfdf;
        --nav-bar-bg-color: #1a1919;
    }
} */

.dark-mode {
    --page-bg-color: #232323;
    --page-color: #dfdfdf;
    --nav-bar-bg-color: #1a1919;
}

同时对/src/hooks/下的useDarkMode.js稍作调整:

// » /src/hooks/useDarkMode.js

useEffect(() => {
    const className = 'dark-mode'
    // 将原本添加取body的类名,现在添加到html元素上
    const element = window.document.documentElement

    if(enabled) {
        element.classList.add(className)
    } else {
        element.classList.remove(className)
    }
},[enabled])

这个时候的效果和前面是相同的:

有关于这方面更详细的介绍还可以参阅:

方案三:DarkMode.js

DarkMode.js是众多实现Dark Mode切换效果的JavaScript库之一。这个库使用CSS混合模式,可以将Dark Mode模式添加到任意网站上。其操作非常的简单,只需要将代码复制到相应的项目中,就可以在Web页面上有一个Dark Mode切换的小控件。接下来,我们来看看DarkMode.js怎么给网站添加Dark Mode的切换效果。

其有两种使用方式,第一种就是将darkmode.js引入到Web页面中,并且开启相应的切换换件:

<script src="https://cdn.jsdelivr.net/npm/darkmode-js@1.5.4/lib/darkmode-js.min.js"></script>
<script>
    new Darkmode().showWidget();
</script>

另外一种方式就是通过npm将该功能模块安装到项目中:

» npm i darkmode-js -D

接着在App.js中引入所需要的代码:

import Darkmode from 'darkmode-js';

new Darkmode().showWidget();

这个时候在浏览器下能看到如下这样的效果:

正如其官网上所介绍的,还可以提供一些更细的配置项options

var options = {
    top: '64px',                 // default: '32px'
    right: '32px',               // default: '32px'
    left: 'unset',               // default: 'unset'
    bottom: 'unset',             // default: 'unset'
    time: '0.5s',                // default: '0.3s'
    mixColor: '#fff',            // default: '#fff'
    backgroundColor: '#fff',     // default: '#fff'
    buttonColorDark: '#100f2c',  // default: '#100f2c'
    buttonColorLight: '#fff',    // default: '#fff'
    saveInCookies: false,        // default: true,
    label: 'L/D',                // default: ''
    autoMatchOsTheme: true       // default: true
}

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

DarkMode.js虽然实现Dark Mode切换成本较低,但效果未必能满足项目需求。为什么这么说呢?我们将未例变得更复杂一点,添加一些其他组件进来,不再仅仅是文字内容,效果将会是这样的:

就上图的暗色系效果就是基于高亮色做了一个混合模式处理得到的效果。从效果上来看离我们所要的Dark Mode效果相差甚远。就我个人而言,并不太建议采用该方案。

小结

在《如何使用CSS实现黑暗模式和高亮模式的切换》和《给网站添加暗黑模式指南》主要围绕着如何使用CSS的技术给Web添加Dark Mode的功能。在这篇文章中我们把重点放在了React环境中,详细介绍了如何在React中实现Dark Mode的效果,另外还向大家介绍了一Darkmode.js这样的JavaScript怎样实现Dark Mode的效果。

当然,除了上述所整理的方案之外肯定还有其他的相关技术方案,如果你在这方面有经验或者有更好的技术方案,欢迎在下面的评论中与我们一起分享。