nullcache Space

介绍下本文适用的环境:采用Nextjs13/14,使用tailwindcss,使用了typescript,当然使用js也是可以参考下列代码的,只是要把类型部分移除。

然后是使用 next-themes 这样一个库的,因为它几乎是开箱即用的,省去了我们绝大多数写代码或者解决bug的时间

ThemeProvider的准备

首先例行公事的,我们对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" 就可以看到深色模式的效果了

编写ThemeSwitcher

然后我们来编写一个深色模式的开关,因为总不能一直手动修改吧。一个合理的深色模式,应该是默认跟随系统的设置,并且可以让用户进行手动切换。我注意到很多博客或者网站都是最开始是跟随系统,一旦进行操作就不能恢复到自动的模式下,个人觉得不太合理。我觉得一个比较好的范例是 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;
}
avatar
nullcache
「此处应有一句格言」
2
文章数
github
掘金