介绍下本文适用的环境:采用Nextjs13/14,使用tailwindcss,使用了typescript,当然使用js也是可以参考下列代码的,只是要把类型部分移除。
然后是使用 next-themes
这样一个库的,因为它几乎是开箱即用的,省去了我们绝大多数写代码或者解决bug的时间
首先例行公事的,我们对next-themes里提供的 ThemeProvider
进行一层封装,封装的目的是方便我们以后如果next-themes停止维护或者有更好的库,我们可以很方便的进行替换,或者是我们可以进行拦截
"use client"; import { ThemeProvider as NextThemeProvider } from "next-themes"; import type { ThemeProviderProps } from "next-themes/dist/types"; export default function ThemeProvider({ children, ...props }: ThemeProviderProps) { return <NextThemeProvider {...props}>{children}</NextThemeProvider>; }
紧接着我们就可以把这个ThemeProvider套在我们的 layout.tsx
上了
export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html suppressHydrationWarning> <body> <ThemeProvider attribute="class"> {children} </ThemeProvider> </body> </html> ); }
值得说明的是,通过查阅文档发现,我们需要在html上套一个suppressHydrationWarning,否则会受到令人难受的水合警告。同时,因为我们使用了tailwindcss,为了更好的与之契合,我们给ThemeProvider添加上 attribute="class"
,这样next-themes就不再是像他默认的一样修改html上的data-theme值,转而去修改class
同时,我们需要在 tailwind.config.ts
文件里的config中,添加这样一行 darkMode: "class"
。默认情况下tailwind是根据系统的首选项来判断是否需要深色模式,但是我们也希望加上手动修改的能力,我们通过加入下面一行让tailwindcss根据html上的css来判断是否为深色模式
// tailwind.config.js module.exports = { ... darkMode: "class", };
之后,我们使用tailwindcss时候多编写一个深色模式特定类。当然这些是需要自己编写的,如果我们已有一个编写完整的模版,那就可以跳到 编写ThemeSwitcher 这一步,继续进行了
<h1 className="text-black dark:text-white"></h1>
然后手动修改html标签上的class值,修改为 class="dark"
就可以看到深色模式的效果了
然后我们来编写一个深色模式的开关,因为总不能一直手动修改吧。一个合理的深色模式,应该是默认跟随系统的设置,并且可以让用户进行手动切换。我注意到很多博客或者网站都是最开始是跟随系统,一旦进行操作就不能恢复到自动的模式下,个人觉得不太合理。我觉得一个比较好的范例是 tailwindcss官网 的开关设计,本文介绍的和本站使用的深色模式开关就是借鉴他的。
下面先放出开关的全部代码
"use client"; import { useTheme } from "next-themes"; import { MdOutlineDarkMode, MdOutlineLightMode } from "react-icons/md"; import { HiMiniComputerDesktop } from "react-icons/hi2"; import classnames from "classnames"; import { useEffect, useRef, useState } from "react"; const themes = [ { name: "light", icon: <MdOutlineLightMode />, label: "浅色模式", }, { name: "dark", icon: <MdOutlineDarkMode />, label: "深色模式", }, { name: "system", icon: <HiMiniComputerDesktop />, label: "跟随系统", }, ]; export default function ThemeSwitcher() { const { theme, setTheme } = useTheme(); const [open, setOpen] = useState(false); const [mounted, setMounted] = useState(false); const dropDownRef = useRef<HTMLDivElement>(null); const changeTheme = (theme: string) => { setTheme(theme); setOpen(false); }; useEffect(() => { const listener = (e: Event) => { if (dropDownRef.current) { if (!dropDownRef.current.contains(e.target as Node)) { setOpen(false); } } }; document.addEventListener("click", (e) => { listener(e); }); setMounted(true); return () => document.removeEventListener("click", listener); }, []); return ( <div className={classnames( "text-2xl btn-hover", { "bg-slate-100 dark:bg-slate-600/30": open, }, "relative px-3 col-center-y" )} onClick={() => setOpen(!open)} ref={dropDownRef} > <div className={classnames( "cursor-pointer transition-colors duration-[350ms]", { "text-sky-500": mounted && theme !== "system", } )} > <div data-hide-on-theme="dark"> <MdOutlineDarkMode /> </div> <div data-hide-on-theme="light"> <MdOutlineLightMode /> </div> </div> {open && ( <ul className="absolute z-50 top-full right-0 bg-white rounded-lg ring-1 ring-slate-900/10 shadow-lg overflow-hidden w-[7.5rem] py-1 text-sm text-slate-700 font-semibold dark:bg-slate-800 dark:ring-0 dark:highlight-white/5 dark:text-slate-300 mt-4" > {themes.map((item) => ( <li key={item.name} onClick={(e) => changeTheme(item.name)} className={classnames( { "text-sky-500": item.name === theme }, "text-xl py-1 px-2 pl-1 flex items-center justify-center btn-hover" )} > {item.icon} <span className="text-[0.94rem] ml-2">{item.label}</span> </li> ))} </ul> )} </div> ); }
因为我们用到了很多hooks,所以第一行加上 use client,然后我们首先看下面这段代码
useEffect(() => { const listener = (e: Event) => { if (dropDownRef.current) { if (!dropDownRef.current.contains(e.target as Node)) { setOpen(false); } } }; document.addEventListener("click", (e) => { listener(e); }); setMounted(true); return () => document.removeEventListener("click", listener); }, []);
这段平平无奇,就是实现一个简单的点击出现下拉菜单,以及点击菜单外面的部分菜单回收的功能。我们注意到这里有一个setMounted,把mounted设置为了true,这是做什么的呢?
首先因为是在useEffect里写的,所以肯定是在客户端渲染的时候才能执行,意味着服务端上mounted一直是false。然后带着这个共识,我们通过搜索mounted,找到了下面的代码:
<div className={classnames( "cursor-pointer transition-colors duration-[350ms]", { "text-sky-500": mounted && theme !== "system", } )} > <div data-hide-on-theme="dark"> <MdOutlineDarkMode /> </div> <div data-hide-on-theme="light"> <MdOutlineLightMode /> </div> </div>
"text-sky-500": mounted && theme !== "system"
theme !== "system"
时候是天蓝色很好理解,这里的设计代表着如果是手动修改的模式就蓝色高亮以示区别。 为什么要mounted &&
呢?
如果我们不加上这个判断,那么在服务端渲染的时候,theme的值是undefined,那就应该是天蓝色,结果在水合的时候,发现theme是system应该不是天蓝色,我们就会收到一个水合错误
app-index.tsx:26 Warning: Prop `className` did not match. Server: "cursor-pointer transition-colors duration-[350ms] text-sky-500" Client: "cursor-pointer transition-colors duration-[350ms]"
而加上mounted,保证了只有在客户端渲染的时候才会变成天蓝色,这样就不会收到水合错误了
"cursor-pointer transition-colors duration-[350ms]"
这段纯粹是加上过渡动画,为了让由黑/白变蓝看上去没那么突兀罢了
最后聊聊下面的代码
<div data-hide-on-theme="dark"> <MdOutlineDarkMode /> </div> <div data-hide-on-theme="light"> <MdOutlineLightMode /> </div>
如果我们继续用 theme==="dark"
或者 theme==="light"
来判断,我们不仅收到了水合错误,而且能明显看到按钮是从无到有出现的或者是闪烁。
而如果使用css来显示和隐藏,就能避免这两个问题,因为theme是在第一次渲染后才有有效的值,而css是在html渲染时生效的,我们看不到变化的过程。
然后我们需要在局部或者全局的css里加入下面的代码,就大功告成了!
/* 在禁用js的情况下隐藏掉dark的icon */ [data-hide-on-theme="dark"] { display: none; } /* 避免在正常情况的深色模式下dark图标不显示 */ .light [data-hide-on-theme="light"], .dark [data-hide-on-theme="dark"] { display: block; } .light [data-hide-on-theme="dark"], .dark [data-hide-on-theme="light"] { display: none; }