JackyLove 的技术人生

人生艰难,唯有一技傍身才能慢慢走向通途。个人博客,记录生活,分享技术,记录成长。专注全栈技术,next技术。

第54章—实战篇-博客-深色模式(DarkMode)

首次发表于 2024-07-29, 更新于 2024-07-29

前言

本篇我们来实现深色模式。它也被叫做暗黑模式、黑夜模式等等。这里我们选用的是苹果官方文档的翻译:

image.png

1. 深色模式

“深色模式”本质上是一套采用深色背景、浅色文案的配色方案。其实很早就有。现在越来越流行,是因为苹果在 2018 年提出了这一概念。

先是 2018 年的 macOS Mojave 率先支持了深色外观,然后 2019 年 IOS 13 正式支持深色外观。这些都是系统级别的支持,可以将整个系统的界面切换为深色模式。由此深色模式开始进入人们的视野,成为设计师、前端工程师的学习内容……

使用深色模式也确实有一些好处:

  1. 保护视力。毕竟晚上的时候,浅色有些刺眼。
  2. 增加沉浸感。阅读浏览类 App 或是内容创作型 App(比如代码编辑器)往往会使用深色模式,它们会借助黑底白字的高对比度特性让用户视线保持集中。所以切换深色模式后,可以增加沉浸感,提升使用欲望。
  3. 省电。根据谷歌的官方数据,采用 OLED 屏幕的手机在「深色模式」下,耗电可下降达63%。

image.png

2. 如何适配?

如果我们开发页面,该如何支持深色模式呢?

2.1. CSS 媒体查询

CSS 提供了 prefers-color-scheme 媒体查询特性,用于检测用户是否有将系统的主题色设置为浅色或者深色。

比如 Next.js 脚手架默认创建项目的 app/globals.css中,就有这样一段代码:

:root {
  --foreground-rgb: 0, 0, 0;
  --background-start-rgb: 214, 219, 220;
  --background-end-rgb: 255, 255, 255;
}

@media (prefers-color-scheme: dark) {
  :root {
    --foreground-rgb: 255, 255, 255;
    --background-start-rgb: 0, 0, 0;
    --background-end-rgb: 0, 0, 0;
  }
}

这就是一段根据系统的主题色调整网页基础样式的代码。此时页面会根据系统的外观模式进行调整,效果如下:

1.gif

注:浏览器设置里也有设置模式的地方:

image.png

但这里设置的是浏览器的外观,不会影响具体页面的外观模式。

2.2. JS 查询

CSS 查询会自动跟随系统设置,但如果你要自定义外观模式,就比如很多博客页面右上角都有个外观模式按钮,点击可以切换到 light / dark 模式,这种就需要依赖 JS 了。

Web API 提供了 Window.matchMedia() 方法,它会返回一个新的 MediaQueryList 对象,表示指定的媒体查询字符串解析后的结果。返回的 MediaQueryList 可被用于判定 Document 是否匹配媒体查询,或者监控一个 document 来判定它匹配了或者停止匹配了此媒体查询。

判断浏览器是否支持深色模式

if (window.matchMedia('(prefers-color-scheme)').media !== 'not all') {
  console.log('🎉 Dark mode is supported');
}

其实支持率还蛮好的:

image.png

监听深色模式变化

const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

darkModeMediaQuery.addEventListener('change', (e) => {
  const darkModeOn = e.matches;
  console.log(`Dark mode is ${darkModeOn ? '🌒 on' : '☀️ off'}.`);
});

自定义 React hook

如果使用 React,可以自定义一个 hook,新建 app/theme.js,代码如下:

import { useEffect, useState } from "react"

function useTheme() {
 
  const [theme, setTheme] = useState('light')

  useEffect(() => {

    const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    setTheme(darkModeMediaQuery.matches ? 'dark' : 'light')
    const listener = (event) => {
      setTheme(event.matches ? 'dark' : 'light');
    };

    darkModeMediaQuery.addEventListener('change', listener);
    return () => {
      darkModeMediaQuery.removeEventListener('change', listener);
    };
    
  }, [])
  
  return {
    theme,
    isDarkMode: theme === "dark",
    isLightMode: theme === "light",
  }
}

export default useTheme

修改 app/page.js,代码如下:

'use client'

import Image from "next/image";
import useTheme from "./theme";

export default function Home() {
  
  const {theme} = useTheme()

  return (
    <div>Hello World! {theme}</div>
  );
}

浏览器效果如下:

3.gif

2.3. 测试不同的模式

如果要测试页面的浅色/深色模式,修改系统的外观模式会有些麻烦,Chrome 提供了快速切换的方式:

2.gif

其步骤为:

  1. 打开浏览器开发者工具
  2. Command + Shift + P 打开命令
  3. 输入 dark 或者 light 搜索命令
  4. 回车确定

注:这只是用于测试,效果是暂时的,关闭开发者工具,就会退出设置的样式。

3. 实战应用

基础知识我们就说这么多,只是帮助大家理解深色模式的概念和实现的基本原理。

在 Next.js 项目中,实现手动切换外观模式的效果,通常还要搭配 React Context 或者状态管理库来实现。在实际开发中,为了提高效率,我们会使用 next-themes 这个包来实现。

3.1. next-themes

安装依赖项:

npm install next-themes @headlessui/react

其中 @headlessui/react 是 UI 库,十分适合搭配 Tailwind.css。

新建 app/theme-providers.js,代码如下:

'use client'

import { ThemeProvider } from 'next-themes'

export function ThemeProviders({ children }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="light" enableSystem>
      {children}
    </ThemeProvider>
  )
}

修改 app/layout.js,添加代码如下:

import siteMetadata from '@/data/siteMetadata'
import "./globals.css";
import { ThemeProviders } from './theme-providers'

export const metadata = {
  // ...
}

export default function RootLayout({ children }) {
  return (
    <html lang={siteMetadata.locale} suppressHydrationWarning>
      <body>
        <ThemeProviders>
          {children}
        </ThemeProviders>
      </body>
    </html>
  );
}

修改的代码有 2 点:

  1. html 使用 suppressHydrationWarning 取消水合错误警告,这是因为 next-theme 会修改 html 元素的属性。因为 suppressHydrationWarning 只作用于一层,所以不用担心它会影响整个应用的水合错误警告。
  2. 使用 ThemeProviders 组件包裹 children

此时页面并不有什么特殊效果,因为这步相当于在顶层使用了 React Context,储存了一个用于表示当前主题的值,默认是 "light"

此时因为我们设置了 attribute="class",当切换主题的时候,它会对应生成 class="dark"这种属性:

image.png

此外,我们还可以看到生成了 style="color-scheme: dark;"属性,详细介绍参考 MDN color-scheme。简单来说,操作系统会根据用户选择的颜色方案对用户界面进行调整,包括表单控件、滚动条和 CSS 系统颜色的使用值。

因为我们改用了 JS 来手动控制主题值,所以写样式效果的时候,也不能再用 @media (prefers-color-scheme: dark) 这种方式,它会根据系统的主题值而非 Context 中的主题值进行修改。

如果你要写 dark 和 light 两套主题,因为 html 添加了 .dark 类,所以可以这样写:

html,
body {
  color: #000;
  background: #fff;
}

.dark {
  html, body {
    color: #fff;
    background: #000;
  }
}

当然因为我们项目使用了 Tailwind.css,Tailwind.css 也是支持 Dark Mode 的。修改 tailwind.config.js,添加代码如下(如果你按照之前的文章写项目,此时应该已经添加了):

module.exports = {
  darkMode: 'class'
}

修改 app/page.js,代码如下:

'use client'

import { useState, useEffect } from 'react'
import { useTheme } from 'next-themes'

const ThemeSwitch = () => {
  const [mounted, setMounted] = useState(false)
  const { theme, setTheme } = useTheme()

  useEffect(() => {
    setMounted(true)
  }, [])

  if (!mounted) {
    return null
  }

  return (
    <>
      <h1 className="text-black dark:text-white">Hello World! {theme}</h1>
      <select value={theme} onChange={e => setTheme(e.target.value)}>
        <option value="system">System</option>
        <option value="dark">Dark</option>
        <option value="light">Light</option>
      </select>
    </>

  )
}

export default ThemeSwitch

在这段代码中,展示了如何使用 useTheme 和 Taildwind.css。

  1. 使用 useTheme 需要是客户端组件,且需要判断环境,否则会导致水合错误
  2. "text-black dark:text-white" 展示了如何为一个元素定义不同主题下的样式

此时效果如下:

4.gif

3.2. 项目开发

新建 components/ThemeSwitch.js,代码如下:

'use client'

import { Fragment, useEffect, useState } from 'react'
import { useTheme } from 'next-themes'
import { Menu, RadioGroup, Transition } from '@headlessui/react'

const Sun = () => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    viewBox="0 0 20 20"
    fill="currentColor"
    className="h-6 w-6 text-gray-900 dark:text-gray-100"
  >
    <path
      fillRule="evenodd"
      d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
      clipRule="evenodd"
    />
  </svg>
)
const Moon = () => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    viewBox="0 0 20 20"
    fill="currentColor"
    className="h-6 w-6 text-gray-900 dark:text-gray-100"
  >
    <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
  </svg>
)
const Monitor = () => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    viewBox="0 0 20 20"
    fill="none"
    stroke="currentColor"
    strokeWidth="2"
    strokeLinecap="round"
    strokeLinejoin="round"
    className="h-6 w-6 text-gray-900 dark:text-gray-100"
  >
    <rect x="3" y="3" width="14" height="10" rx="2" ry="2"></rect>
    <line x1="7" y1="17" x2="13" y2="17"></line>
    <line x1="10" y1="13" x2="10" y2="17"></line>
  </svg>
)

const ThemeSwitch = () => {
  const [mounted, setMounted] = useState(false)
  const { theme, setTheme, resolvedTheme } = useTheme()

  // When mounted on client, now we can show the UI
  useEffect(() => setMounted(true), [])

  if (!mounted) return null

  return (
    <div className="mr-5">
      <Menu as="div" className="relative inline-block text-left">
        <div>
          <Menu.Button>{resolvedTheme === 'dark' ? <Moon /> : <Sun />}</Menu.Button>
        </div>
        <Transition
          as={Fragment}
          enter="transition ease-out duration-100"
          enterFrom="transform opacity-0 scale-95"
          enterTo="transform opacity-100 scale-100"
          leave="transition ease-in duration-75"
          leaveFrom="transform opacity-100 scale-100"
          leaveTo="transform opacity-0 scale-95"
        >
          <Menu.Items className="absolute right-0 mt-2 w-32 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-gray-800">
            <RadioGroup value={theme} onChange={setTheme}>
              <div className="p-1">
                <RadioGroup.Option value="light">
                  <Menu.Item>
                    <button className="group flex w-full items-center rounded-md px-2 py-2 text-sm">
                      <div className="mr-2">
                        <Sun />
                      </div>
                      Light
                    </button>
                  </Menu.Item>
                </RadioGroup.Option>
                <RadioGroup.Option value="dark">
                  <Menu.Item>
                    <button className="group flex w-full items-center rounded-md px-2 py-2 text-sm">
                      <div className="mr-2">
                        <Moon />
                      </div>
                      Dark
                    </button>
                  </Menu.Item>
                </RadioGroup.Option>
                <RadioGroup.Option value="system">
                  <Menu.Item>
                    <button className="group flex w-full items-center rounded-md px-2 py-2 text-sm">
                      <div className="mr-2">
                        <Monitor />
                      </div>
                      System
                    </button>
                  </Menu.Item>
                </RadioGroup.Option>
              </div>
            </RadioGroup>
          </Menu.Items>
        </Transition>
      </Menu>
    </div>
  )
}

export default ThemeSwitch

这段代码看似很长,但实现的效果其实很简单:

image.png

为了方便引入,修改 jsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/data/*": ["data/*"],
      "@/components/*": ["components/*"],
      "contentlayer/generated": ["./.contentlayer/generated"]
    }
  },
  // ...
}

我们将组件添加到根布局 app/layout.js中:

import siteMetadata from '@/data/siteMetadata'
import "./globals.css";
import { ThemeProviders } from './theme-providers'
import ThemeSwitch from '@/components/ThemeSwitch';

// ...

export default function RootLayout({ children }) {
  return (
    <html lang={siteMetadata.locale} suppressHydrationWarning>
      <body>
        <ThemeProviders>
          <header className="flex justify-end">
            <ThemeSwitch />
          </header>
          {children}
        </ThemeProviders>
      </body>
    </html>
  );
}

这样所有页面的右上角都会有这个切换主题的按钮。

现在要做的就是为元素设置不同主题的样式。根据上节的描述有两种方式可以设置,一种是直接使用 CSS 进行设置:

.dark {
  // ...
}

一种是使用 Tailwind.css,添加 dark:开头的类名:

<h1 className="text-3xl font-bold dark:text-white">{post.title}</h1>

比较麻烦的地方在于文章页面,因为文章的内容渲染是由 MDX 生成,不能直接添加类名。

但其实 tailwindcss-typography 同样提供了 dark mode 支持,你只要在外层添加一个 dark:prose-invert 类名:

<article class="prose dark:prose-invert">{{ markdown }}</article>

所以我们修改 app/posts/[id]/page.js,添加代码如下:

// ...

const Page = ({ params }) => {
  const post = allPosts.find((post) => post._raw.flattenedPath === params.id)
  if (!post) notFound()
  const MDXContent = useMDXComponent(post.body.code)
  const jsonLd = post.structuredData
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
        />
      <article className="mx-auto max-w-xl py-8 prose prose-slate dark:prose-invert">
        <div className="mb-8 text-center">
          <time dateTime={post.date} className="mb-1 text-xs text-gray-600 dark:text-white">
            {dayjs(post.date).format('DD/MM/YYYY')}
          </time>
          <h1 className="text-3xl font-bold dark:text-white">{post.title}</h1>
        </div>
        <MDXContent />
      </article>
    </>
  )
}

export default Page

此时文章页面效果如下:

5.gif

我们成功实现了深色模式!

项目源码

  1. 功能实现:博客支持深色模式
  2. 源码地址:https://github.com/mqyqingfeng/next-app-demo/tree/next-blog-3
  3. 下载代码:git clone -b next-blog-3 git@github.com:mqyqingfeng/next-app-demo.git

参考链接

  1. https://support.apple.com/zh-cn/guide/mac-help/mchl52e1c2d2/mac
  2. https://juejin.cn/post/7298997940019085366
  3. https://developer.mozilla.org/zh-CN/docs/Web/CSS/@media/prefers-color-scheme
  4. https://web.dev/articles/prefers-color-scheme?hl=zh-cn#reacting_on_dark_mode_changes
  5. https://web.dev/articles/color-scheme?hl=zh-cn
  6. https://www.uisdc.com/dark-mode-history
  7. https://juejin.cn/post/7062986403205873701
© Copyright 2025 JackyLove 的技术人生. Powered with by CreativeDesignsGuru