本篇我们来实现深色模式。它也被叫做暗黑模式、黑夜模式等等。这里我们选用的是苹果官方文档的翻译:
“深色模式”本质上是一套采用深色背景、浅色文案的配色方案。其实很早就有。现在越来越流行,是因为苹果在 2018 年提出了这一概念。
先是 2018 年的 macOS Mojave 率先支持了深色外观,然后 2019 年 IOS 13 正式支持深色外观。这些都是系统级别的支持,可以将整个系统的界面切换为深色模式。由此深色模式开始进入人们的视野,成为设计师、前端工程师的学习内容……
使用深色模式也确实有一些好处:
如果我们开发页面,该如何支持深色模式呢?
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;
}
}
这就是一段根据系统的主题色调整网页基础样式的代码。此时页面会根据系统的外观模式进行调整,效果如下:
注:浏览器设置里也有设置模式的地方:
但这里设置的是浏览器的外观,不会影响具体页面的外观模式。
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');
}
其实支持率还蛮好的:
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,新建 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>
);
}
浏览器效果如下:
如果要测试页面的浅色/深色模式,修改系统的外观模式会有些麻烦,Chrome 提供了快速切换的方式:
其步骤为:
注:这只是用于测试,效果是暂时的,关闭开发者工具,就会退出设置的样式。
基础知识我们就说这么多,只是帮助大家理解深色模式的概念和实现的基本原理。
在 Next.js 项目中,实现手动切换外观模式的效果,通常还要搭配 React Context 或者状态管理库来实现。在实际开发中,为了提高效率,我们会使用 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 点:
此时页面并不有什么特殊效果,因为这步相当于在顶层使用了 React Context,储存了一个用于表示当前主题的值,默认是 "light"
。
此时因为我们设置了 attribute="class"
,当切换主题的时候,它会对应生成 class="dark"
这种属性:
此外,我们还可以看到生成了 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。
"text-black dark:text-white"
展示了如何为一个元素定义不同主题下的样式此时效果如下:
新建 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
这段代码看似很长,但实现的效果其实很简单:
为了方便引入,修改 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
此时文章页面效果如下:
我们成功实现了深色模式!
- 功能实现:博客支持深色模式
- 源码地址:https://github.com/mqyqingfeng/next-app-demo/tree/next-blog-3
- 下载代码:
git clone -b next-blog-3 git@github.com:mqyqingfeng/next-app-demo.git