使用 React 和 ThemeProvider 创建一个暗黑模式切换按钮

Avatar of Maks Akymenko
Maks Akymenko

DigitalOcean 为您的旅程的每个阶段提供云产品。 立即开始使用 $200 免费积分!

我喜欢网站有暗黑模式选项。 暗黑模式 使我更容易阅读网页,并让我的眼睛感觉更放松。 包括 YouTube 和 Twitter 在内的许多网站已经实现了它,我们也开始看到它逐渐出现在其他许多网站上。

在本教程中,我们将使用来自 styled-components 库的 <ThemeProvider 包装器,构建一个允许用户在明暗模式之间切换的按钮。 我们将创建一个 useDarkMode 自定义钩子,它支持 prefers-color-scheme 媒体查询,以根据用户的 OS 颜色方案设置设置模式。

如果听起来很难,我保证它并不难! 让我们深入研究并使其成为现实。

查看 CodePen 上的笔
使用 React 和 ThemeProvider 创建日/夜模式切换按钮
由 Maks Akymenko (@maximakymenko)
CodePen 上。

让我们开始设置

我们将使用 create-react-app 初始化一个新项目

npx create-react-app my-app
cd my-app
yarn start

接下来,打开一个单独的终端窗口并安装 styled-components

yarn add styled-components

接下来要做的就是创建两个文件。 第一个是 global.js,它将包含我们的基础样式,第二个是 theme.js,它将包含我们的暗黑和明亮主题的变量

// 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)',
}

随意自定义您想要的变量,因为此代码仅用于演示目的。

// global.js
// Source: https://github.com/maximakymenko/react-day-night-toggle-app/blob/master/src/global.js#L23-L41

import { createGlobalStyle } from 'styled-components';

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

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

转到 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>
        <footer>
        </footer>
      </>
    </ThemeProvider>
  );
}

export default App;

这将导入我们的明亮和暗黑主题。 ThemeProvider 组件也被导入,并在其内部传递了明亮主题 (lightTheme) 样式。 我们还导入 GlobalStyles 以将所有内容集中在一个地方。

以下是到目前为止我们所拥有的内容

现在,切换功能

还没有主题之间的神奇切换,所以让我们实现切换功能。 我们只需要几行代码就可以使它工作。

首先,从 react 中导入 useState 钩子

// App.js
import React, { useState } from 'react';

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

// App.js
const [theme, setTheme] = useState('light');

// The function that toggles between themes
const toggleTheme = () => {
  // if the theme is not light, then set it to dark
  if (theme === 'light') {
    setTheme('dark');
  // otherwise, it should be light
  } else {
    setTheme('light');
  }
}

之后,剩下的就是将此函数传递给我们的按钮元素并有条件地更改主题。 看一看

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

// The function that toggles between themes
function App() {
  const [theme, setTheme] = useState('light');
  const toggleTheme = () => {
    if (theme === 'light') {
      setTheme('dark');
    } else {
      setTheme('light');
    }
  }
  
  // Return the layout based on the current theme
  return (
    <ThemeProvider theme={theme === 'light' ? lightTheme : darkTheme}>
      <>
        <GlobalStyles />
        // Pass the toggle functionality to the button
        <button onClick={toggleTheme}>Toggle theme</button>
        <h1>It's a light theme!</h1>
        <footer>
        </footer>
      </>
    </ThemeProvider>
  );
}

export default App;

它是如何工作的?

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

在我们之前的 GlobalStyles 中,我们将 backgroundcolor 属性分配给了 theme 对象中的值,因此,每次我们切换按钮时,值都会根据我们传递给 ThemeProviderdarkThemelightTheme 对象进行更改。 transition 属性允许我们使这种更改比使用关键帧动画更平滑。

现在我们需要切换组件

我们基本上已经完成了,因为您现在已经了解了如何创建切换功能。 但是,我们总是可以做得更好,所以让我们通过创建一个自定义 Toggle 组件并使我们的切换功能可重用,来改进应用程序。 这是在 React 中构建它的主要优势之一,对吧?

为了简单起见,我们将所有内容都保存在一个文件中,所以让我们创建一个名为 Toggle.js 的新文件并添加以下内容

// Toggle.js
import React from 'react'
import { func, string } from 'prop-types';
import styled from 'styled-components';
// Import a couple of SVG files we'll use in the design: https://www.flaticon.com
import { ReactComponent as MoonIcon } from 'icons/moon.svg';
import { ReactComponent as SunIcon } from '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;

您可以从 这里这里 下载图标。 此外,如果我们想将图标用作组件,请记住有关 将它们作为 React 组件导入 的内容。

我们在其中传递了两个道具:theme 将提供当前主题(明亮或暗黑),toggleTheme 函数将用于在它们之间切换。 在下面,我们创建了一个 isLight 变量,它将根据我们的当前主题返回布尔值。 我们将在稍后将其传递给我们的样式组件。

我们还从 styled-components 中导入了一个 styled 函数,所以让我们使用它。 随意将其添加到文件顶部,在导入之后,或者像我下面一样为其创建一个专用文件(例如 Toggle.styled.js)。 同样,这纯粹用于演示目的,因此您可以根据自己的需要设置组件的样式。

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

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

将图标作为组件导入允许我们直接更改 SVG 图标的样式。 我们正在检查 lightTheme 是否是活动主题,如果是,我们将在可见区域之外移动相应的图标——有点像白天月亮消失,晚上太阳消失。

不要忘记在 Toggle.js 中使用 ToggleContainer 组件替换按钮,无论您是在单独的文件中设置样式还是直接在 Toggle.js 中设置样式。 确保将 isLight 变量传递给它以指定当前主题。 我将道具命名为 lightTheme,以便它能清楚地反映其目的。

最后要做的就是将我们的组件导入 App.js 并向其传递所需的道具。 此外,为了增加一些交互性,我在主题更改时向标题传递了一个条件,以在“明亮”和“暗黑”之间切换

// App.js
<Toggle theme={theme} toggleTheme={toggleTheme} />
<h1>It's a {theme === 'light' ? 'light theme' : 'dark theme'}!</h1>

不要忘记为提供图标的 flaticon.com 作者致谢。

// App.js
<span>Credits:</span>
<small><b>Sun</b> icon made by <a href="https://www.flaticon.com/authors/smalllikeart">smalllikeart</a> from <a href="https://www.flaticon.com">www.flaticon.com</a></small>
<small><b>Moon</b> icon made by <a href="https://www.freepik.com/home">Freepik</a> from <a href="https://www.flaticon.com">www.flaticon.com</a></small>

现在好多了

useDarkMode 钩子

在构建应用程序时,我们应该牢记应用程序必须是可扩展的,这意味着可重用的,因此我们可以在许多地方甚至不同的项目中使用它。

这就是为什么如果我们将切换功能移到一个单独的位置会很棒的原因——所以,为什么不为此创建一个专门的帐户钩子呢?

让我们在项目 src 目录中创建一个名为 useDarkMode.js 的新文件,并将我们的逻辑移到这个文件中,并进行一些调整

// 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]
};

我们在这里添加了一些东西。 我们希望我们的主题在浏览器会话之间持久存在,因此如果有人选择了暗黑主题,那么他们将在下次访问应用程序时获得该主题。 这是一个巨大的 UX 改进。 出于这些原因,我们使用 localStorage

我们还实现了 useEffect 钩子来检查组件挂载。 如果用户之前选择了主题,我们将其传递给我们的 setTheme 函数。 最后,我们将返回我们的 theme,它包含选择的 themetoggleTheme 函数,用于在模式之间切换。

现在,让我们实现 useDarkMode 钩子。 转到 App.js,导入新创建的钩子,从钩子中解构我们的 themetoggleTheme 属性,并将它们放到它们应该在的位置

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

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

  return (
    <ThemeProvider theme={themeMode}>
      <>
        <GlobalStyles />
        <Toggle theme={theme} toggleTheme={toggleTheme} />
        <h1>It's a {theme === 'light' ? 'light theme' : 'dark theme'}!</h1>
        <footer>
          Credits:
          <small>Sun icon made by smalllikeart from www.flaticon.com</small>
          <small>Moon icon made by Freepik from www.flaticon.com</small>
        </footer>
      </>
    </ThemeProvider>
  );
}

export default App;

几乎完美地工作了,但我们可以做一件小事来改善我们的体验。 切换到暗黑主题并重新加载页面。 您是否看到太阳图标在月亮图标之前短暂地加载了一瞬间?

之所以发生这种情况,是因为我们的 useState 钩子最初初始化了 light 主题。 之后,useEffect 运行,检查 localStorage,然后才将 theme 设置为 dark

到目前为止,我找到了两种解决方案。 第一种是在我们的 useState 中检查 localStorage 中是否有值

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

但是,我不确定在 useState 中进行这样的检查是否是一个好习惯,所以让我向您展示我正在使用的第二种解决方案。

这个会稍微复杂一些。 我们将创建一个另一个状态并将其命名为 componentMounted。 然后,在 useEffect 钩子中,我们在其中检查我们的 localTheme,我们将添加一个 else 语句,如果 localStorage 中没有 theme,我们将添加它。 之后,我们将 setComponentMounted 设置为 true。 最后,我们将 componentMounted 添加到我们的 return 语句中。

// 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 原则,而这里我们有机会使用它。 我们可以创建一个单独的函数来设置我们的状态并将 theme 传递给 localStorage。 我认为,最好的名字是 setTheme,但我们已经使用过它了,所以让我们将其命名为 setMode

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

有了这个函数,我们可以稍微重构一下我们的 useDarkMode.js

// 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 的完整代码

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

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>
        <footer>
          <span>Credits:</span>
          <small><b>Sun</b> icon made by <a href="https://www.flaticon.com/authors/smalllikeart">smalllikeart</a> from <a href="https://www.flaticon.com">www.flaticon.com</a></small>
          <small><b>Moon</b> icon made by <a href="https://www.freepik.com/home">Freepik</a> from <a href="https://www.flaticon.com">www.flaticon.com</a></small>
        </footer>
      </>
    </ThemeProvider>
  );
}

export default App;

使用用户的首选颜色方案

这部分不是必需的,但它将使您获得更好的用户体验。此媒体功能用于检测用户是否已要求页面根据其 OS 设置使用浅色或深色主题。例如,如果用户的手机或笔记本电脑上的默认颜色方案设置为深色,您的网站将相应地更改其颜色方案。值得注意的是,此媒体查询仍在开发中,包含在 媒体查询级别 5 规范中,该规范处于编辑草案阶段。

此浏览器支持数据来自 Caniuse,其中有更多详细信息。数字表示浏览器在该版本及更高版本中支持该功能。

台式机

ChromeFirefoxIEEdgeSafari
76677912.1

移动设备/平板电脑

Android ChromeAndroid FirefoxAndroidiOS Safari
12712712713.0-13.1

实现非常简单。因为我们正在使用媒体查询,所以我们需要在useEffect钩子中检查浏览器是否支持它并设置相应的主题。为此,我们将使用window.matchMedia来检查它是否存在以及是否支持深色模式。我们还需要记住localTheme,因为如果它可用,我们不想用深色值覆盖它,当然,除非该值设置为浅色。

如果所有检查都通过,我们将设置深色主题。

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

如前所述,我们需要记住localTheme的存在 - 这就是为什么我们需要实现我们之前检查它的逻辑。

以下是我们之前的内容

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

让我们混合一下。我用三元运算符替换了 if 和 else 语句,以使内容更易读

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

以下是包含完整代码的 userDarkMode.js 文件

// 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]
};

试试吧!它更改模式,在localStorage中持久保存主题,并在可用时根据 OS 颜色方案设置默认主题。


恭喜,我的朋友!干得好!如果您对实施有任何疑问,请随时 给我发消息