从零搭建一个自定义明暗主题系统

最近准备用 React + Antd + UnoCSS 开发一个和 NestJS Admin 配套的系统,想加个自定义主题功能,效果如下图,也可以点击 这里这里 体验。

一、需求

  • 用户可以自定义主题颜色,需要实时响应;
  • 用户可以切换明暗模式,需要实时改变背景和文字颜色;
  • 当用户切换系统主题时,网页需要作出响应;
  • 主题颜色和明暗模式需要缓存至 localStorage

二、准备工作

这里使用的是 pnpm,用 npm 或 yarn 等包管理工具的记得替换命令。

1. 创建项目

首先,拉取 vite 模板:

sh 复制代码
pnpm create vite my-theme --template react-ts

清空 src 目录:

tsx 复制代码
// main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)
tsx 复制代码
// App.tsx
function App() {
  return (
    <div>App</div>
  )
}

export default App

启动项目:

sh 复制代码
pnpm run dev

2. 安装 Antd

sh 复制代码
pnpm add antd
tsx 复制代码
// App.tsx
import { Button } from 'antd'

function App() {
  return (
    <div>
      <Button type="primary">123</Button>
      <Button>zzz</Button>
    </div>
  )
}

export default App

3. 安装并配置 UnoCSS

开始之前,先推荐两个 VSCode 插件:

  • UnoCSS

    这个插件会读取 uno.config.ts ,提供了类名的提示以及预览:

  • Iconify IntelliSense

    这个插件提供了图标名称的提示和预览功能:

1) 安装并引入

因为后续会用的 CSS 图标,这里顺带安装一下图标库。(体积很大,70M,你想要的 SVG 图标 这里 都有)

sh 复制代码
pnpm add unocss @iconify/json -D

配置 vite.config.ts

ts 复制代码
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import UnoCSS from 'unocss/vite'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), UnoCSS()],
})

main.tsx 中引入:

tsx 复制代码
// main.tsx
import 'virtual:uno.css'

2) 配置文件

在项目根目录创建 uno.config.ts 配置文件:

ts 复制代码
// uno.config.ts
import { defineConfig, presetIcons, presetUno } from 'unocss'

export default defineConfig({
  presets: [
    presetUno(),
    presetIcons({
      extraProperties: {
        'display': 'inline-block',
        'height': '1.2em',
        'width': '1.2em',
        'vertical-align': 'middle',
      },
      warn: true,
    }),
  ],
})

更多配置选项,请阅读 UnoCSS 文档

关于图标的使用方法和配置,请看 这里

3) 样式重置

sh 复制代码
pnpm add @unocss/reset

main.tsx 中引入:

tsx 复制代码
import '@unocss/reset/tailwind-compat.css'

4) 测试

App.tsx 中随便写点代码:

tsx 复制代码
// App.tsx
import { Button } from 'antd'

function App() {
  return (
    <div>
      <Button type="primary">123</Button>
      <Button>zzz</Button>

      <h1 className='mt-5 text-[red] text-10'>Hello, world!</h1>
      <div className='text-[green] text-20'>
        <div className='i-mdi:vuejs'></div>
        <div className='i-mdi:twitter'></div>
      </div>
    </div>
  )
}

export default App

三、需求实现

1. 自定义主题颜色

1) 组件引入并绑定状态

tsx 复制代码
// App.tsx
import { Button, ColorPicker } from "antd";
import { useState } from "react";

function App() {
  const [primaryColor, setPrimaryColor] = useState("#01bfff");

  return (
    <div className="p-4 flex items-center gap-x-3 mb-4">
      <ColorPicker
        value={primaryColor}
        onChange={(_, c) => setPrimaryColor(c)}
      />

      <span>{primaryColor}</span>

      <Button type="primary">123</Button>
      <Button>zzz</Button>
    </div>
  );
}

export default App;

2) 和 Antd 组件同步

新版本的 Antd 采用了 CSS-in-JS 方案以及 梯度变量演变 算法,只需要提供一个基础变量 colorPrimary ,主题相关的其它配色就能推算出来,比如按钮点击的波纹颜色等等。

所以,我们只需要将 primaryColor 通过 ConfigProvider 提供给 Antd 就可以了:

tsx 复制代码
// App.tsx
import { Button, ColorPicker, ConfigProvider, ThemeConfig } from "antd";
import { useState } from "react";

function App() {
  const [primaryColor, setPrimaryColor] = useState("#01bfff");

  const antdTheme: ThemeConfig = {
    token: { colorPrimary: primaryColor },
  };

  return (
    <ConfigProvider theme={antdTheme}>
      <div className="p-4">
        <div className="flex items-center gap-x-3 mb-4">
          <ColorPicker
            value={primaryColor}
            onChange={(_, c) => setPrimaryColor(c)}
          />

          <span>{primaryColor}</span>

          <Button type="primary">123</Button>
          <Button>zzz</Button>
        </div>
      </div>
    </ConfigProvider>
  );
}

export default App;

3) 和其他颜色同步

这里使用 CSS 变量的方案来保持颜色同步:

  • 给根元素添加一个 CSS 变量 --primary-color;
  • 给 UnoCSS 添加一个颜色 primary: 'var(--primary-color)'
  • 添加一个副作用,让 primaryColro--primary-color 保持同步。
ts 复制代码
// uno.config.ts
import { defineConfig, presetIcons, presetUno } from 'unocss'

export default defineConfig({
  theme: {
    colors: {
      primary: 'var(--primary-color)', // 这里定义了一个颜色,通过 `text-primary` 的方式使用
    },
  },
  // ... rest config
})
tsx 复制代码
// App.tsx
import { Button, ColorPicker, ConfigProvider, ThemeConfig } from "antd";
import { useEffect, useState } from "react";

function App() {
  const [primaryColor, setPrimaryColor] = useState("#01bfff");

  useEffect(() => {
    document.documentElement.style.setProperty("--primary-color", primaryColor);
  }, [primaryColor]);

  const antdTheme: ThemeConfig = {
    token: { colorPrimary: primaryColor },
  };

  return (
    <ConfigProvider theme={antdTheme}>
      <div className="p-4 flex items-center gap-x-3 mb-4">
        <ColorPicker
          value={primaryColor}
          onChange={(_, c) => setPrimaryColor(c)}
        />

        {/* 这里使用了在 UnoCSS 中定义的 primary */}
        <span className="p-2 text-primary border border-primary">{primaryColor}</span>

        <Button type="primary">123</Button>
        <Button>zzz</Button>
      </div>
    </ConfigProvider>
  );
}

export default App;

2. 明暗模块切换

安装 classnames 方便组装类名:

sh 复制代码
pnpm add classnames

1) 封装切换组件

先给图标按钮加个 shortcut 组合类:

ts 复制代码
// uno.config.ts
import { defineConfig, presetIcons, presetUno } from 'unocss'

export default defineConfig({
  shortcuts: {
    btn: 'p-2 font-semibold rounded-lg select-none cursor-pointer hover:bg-[#8882] dark:hover:bg-[#fff2]',
  },
  // ... rest config
})

创建组件 ToggleTheme.tsx

tsx 复制代码
// ToggleTheme.tsx
import { Popover } from "antd";
import classnames from "classnames";

function upperFirst(str: string) {
  return `${str[0].toUpperCase()}${str.slice(1)}`;
}

export type ColorMode = "light" | "dark" | "auto";

const iconMap = {
  light: <div className="i-material-symbols:light-mode-outline" />,
  dark: <div className="i-material-symbols:dark-mode-outline" />,
  auto: <div className="i-material-symbols:desktop-windows-outline-rounded" />,
};

const modes = ["light", "dark", "auto"] as const;

interface Props {
  mode: ColorMode;
  onChange: (mode: ColorMode) => void;
}

function ToggleTheme({ mode, onChange }: Props) {
  const modeList = (
    <ul>
      {modes.map((m) => (
        <li
          key={m}
          // 这里使用了 shortcut `btn`
          className={classnames("btn flex items-center", {
            "text-primary": m === mode,
          })}
          onClick={() => onChange(m)}
        >
          {iconMap[m]}
          <span className="ml-2">{upperFirst(m)}</span>
        </li>
      ))}
    </ul>
  );

  return (
    <Popover
      placement="bottom"
      arrow={false}
      content={modeList}
      trigger="click"
    >
      {/* 这里也使用了 shortcut `btn` */}
      <a className="btn">{iconMap[mode!]}</a>
    </Popover>
  );
}

export default ToggleTheme;

2) 引入组件

tsx 复制代码
// App.tsx
import { Button, ColorPicker, ConfigProvider, ThemeConfig } from "antd";
import { useEffect, useState } from "react";
import ToggleTheme, { ColorMode } from "./ToggleTheme";

function App() {
  // 其他代码

  // 保存明暗模式
  const [mode, setMode] = useState<ColorMode>("light");

  return (
    <ConfigProvider theme={antdTheme}>
      <div className="p-4 flex items-center gap-x-3 mb-4">
        {/* 其他代码 */}

        <ToggleTheme mode={mode} onChange={(m) => setMode(m)} />
      </div>
    </ConfigProvider>
  );
}

export default App;

3) 绑定 dark 类

目前常用的黑暗模式方案是给根元素添加一个 dark 类,然后在代码中通过 dark:text-yellow 指定黑暗模式下的样式:

css 复制代码
.dark .dark\:text-yellow {
    --un-text-opacity: 1;
    color: rgb(250 204 21 / var(--un-text-opacity));
}

使用 useEffect 同步 dark 类:

tsx 复制代码
// App.tsx
import { Button, ColorPicker, ConfigProvider, ThemeConfig } from "antd";
import { useEffect, useState } from "react";
import ToggleTheme, { ColorMode } from "./ToggleTheme";

function App() {
  const [mode, setMode] = useState<ColorMode>("light");
  useEffect(() => {
    document.documentElement.classList.toggle("dark", mode === "dark");
  }, [mode]);

  return (
    <ConfigProvider theme={antdTheme}>
			{/* ... */}
      {/* 使用 dark:text-yellow 指定黑暗模式下的文字颜色 */}
      <h1 className="dark:text-yellow m-4 text-10">Light or dark</h1>
    </ConfigProvider>
  );
}

export default App;

4) 使用 CSS 变量同步颜色

新建 main.css

css 复制代码
/* main.css */
:root {
  /* 明亮模式的颜色 */
  --c-bg: #fff;
  --c-scrollbar: #eee;
  --c-scrollbar-hover: #bbb;
  --c-text-color: #333; /* 字体颜色可以继承 */
}

html {
  /* 使用 CSS 变量 */
  background-color: var(--c-bg);
  transition: background-color .25s;
  color: var(--c-text-color);
  overflow-x: hidden;
  overflow-y: scroll;
}

html.dark {
  /* 黑暗模式的颜色 */
  --c-bg: #333;
  --c-scrollbar: #111;
  --c-scrollbar-hover: #222;
  --c-text-color: #fff;
}

* {
  scrollbar-color: var(--c-scrollbar) var(--c-bg);
}

::-webkit-scrollbar {
  width: 6px;
}

::-webkit-scrollbar:horizontal {
  height: 6px;
}

::-webkit-scrollbar-track,
::-webkit-scrollbar-corner {
  background: var(--c-bg);
  border-radius: 10px;
}

::-webkit-scrollbar-thumb {
  background: var(--c-scrollbar);
  border-radius: 10px;
}

::-webkit-scrollbar-thumb:hover {
  background: var(--c-scrollbar-hover);
}

引入 main.tsx

tsx 复制代码
// main.tsx
import './main.css'

效果如下:

5) 同步 Antd

Antd 暴露的 theme 提供了几种颜色算法,我们需要用到这两种:

  • defaultAlgorithm 默认算法
  • darkAlgorithm 黑暗模式的算法

我们需要根据 modeConfgProvider 提供不同的算法:

tsx 复制代码
// App.tsx
import { /* ... */ theme } from "antd"; // 引入 theme
import { useEffect, useState } from "react";
import ToggleTheme, { ColorMode } from "./ToggleTheme";

function App() {
	// ...

  const antdTheme: ThemeConfig = {
    token: { colorPrimary: primaryColor },
    // 黑暗模式使用 darkAlgorithm
    algorithm: mode === "dark" ? theme.darkAlgorithm : theme.defaultAlgorithm,
  };

  return (
    <ConfigProvider theme={antdTheme}>
      {/* ... */}
    </ConfigProvider>
  );
}

export default App;

效果如下:(注意看 zzz 按钮的背景颜色)

3. 监听系统主题

刚刚我们实现了手动切换明暗模式,现在来实现根据当前的系统主题使用对应的模式。

1) 获取并监听系统主题

CSS 提供了媒体查询 prefers-color-scheme: dark 用来监听系统明暗模式,如果我们想读取,需要调用 window.matchMedia,该方法需要传入一个查询字符串,并返回一个 MediaQueryList对象:

  • matches 布尔值
  • addEventListener 添加监听事件处理函数

为了更好的逻辑封装和复用,创建一个自定义 hook usePreferredDark.ts,返回系统是否处于黑暗模式:

ts 复制代码
import { useState } from 'react'

export function usePreferredDark() {
  // 使用系统当前的明暗模式作为初始值
  const [matches, setMatches] = useState(window.matchMedia('(prefers-color-scheme: dark)').matches)

  // 监听系统的明暗模式变化
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
    setMatches(e.matches)
  })

  return matches
}

测试:

tsx 复制代码
// App.tsx
import { usePreferredDark } from "./usePreferredDark";

function App() {
	// ...
  const preferredDark = usePreferredDark()

  useEffect(() => {
    document.documentElement.classList.toggle("dark", preferredDark);
  }, [preferredDark]);

  const antdTheme: ThemeConfig = {
    token: { colorPrimary: primaryColor },
    algorithm: preferredDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
  };

  return (
    <ConfigProvider theme={antdTheme}>
      {/* ... */}
    </ConfigProvider>
  );
}

export default App;

效果如下:

2) 结合 mode

监听系统主题我们实现了,现在需要把 preferredDarkmode 结合起来判断当前网页是否处于黑暗模式,封装一个自定义 hook useDark.ts,如果是黑暗模式,返回 true:

ts 复制代码
// useDark.ts
import { useMemo } from 'react'
import { usePreferredDark } from './usePreferredDark'
import type { ColorMode } from './ToggleTheme'

export function useDark(mode: ColorMode) { // 外部传入,用户选择的明暗模式
  const preferredDark = usePreferredDark() // 当前系统是否是黑暗模式
  const isDark = useMemo(() => {
    return mode === 'dark' || (preferredDark && mode !== 'light') // 简化后的逻辑
  }, [mode, preferredDark])

  return isDark
}

逻辑解释:

  • 因为 mode 是用户选择的,所以它优先级最高,如果 mode === 'dark',直接短路返回 true;
  • 如果 mode === 'light',返回 false
  • 如果 mode === 'auto',返回当前系统是否处于黑暗模式

测试:

tsx 复制代码
// App.tsx
import { useDark } from "./useDark";

function App() {
 	// ...
  const [mode, setMode] = useState<ColorMode>("light");
  // 当前是否处于黑暗模式
  const isDark = useDark(mode);
  useEffect(() => {
    document.documentElement.classList.toggle("dark", isDark);
  }, [isDark]);

  const antdTheme: ThemeConfig = {
    token: { colorPrimary: primaryColor },
    algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
  };

  return (
    <ConfigProvider theme={antdTheme}>
    	{/* ... */}
    </ConfigProvider>
  );
}

export default App;

效果如下:

最后修改 mode 的初始值为 auto

tsx 复制代码
// App.tsx
function App() {
  // ...

  const [mode, setMode] = useState<ColorMode>("auto"); // 这里

  return (
    // ...
  );
}

export default App;

4. 缓存至 localStorage

这个很简单,直接使用 ahooks 提供的 useLocalStorageState 即可。

1) 安装 ahooks

sh 复制代码
pnpm add ahooks

2) 替换 useState

tsx 复制代码
// App.tsx
import { useLocalStorageState } from "ahooks"; // 引入

// 定义 key 常量
const PRIMARY_COLOR_KEY = "app_primary_color";
const COLOR_MODE_KEY = "app_color_mode";

function App() {
  const [primaryColor, setPrimaryColor] = useLocalStorageState(
    PRIMARY_COLOR_KEY,
    {
      defaultValue: "#01bfff",
      serializer: (v) => v, // 因为我们存的本身就是字符串,不需要 JSON 序列化
      deserializer: (v) => v,
    }
  );

  useEffect(() => {
    document.documentElement.style.setProperty(
      "--primary-color",
      primaryColor! // 注意这里非空断言
    );
  }, [primaryColor]);

  const [mode, setMode] = useLocalStorageState<ColorMode>(COLOR_MODE_KEY, {
    defaultValue: "auto",
    serializer: (v) => v,
    deserializer: (v) => v as ColorMode,
  });

  const isDark = useDark(mode!);
  useEffect(() => {
    document.documentElement.classList.toggle("dark", isDark);
  }, [isDark]);

  const antdTheme: ThemeConfig = {
    token: { colorPrimary: primaryColor },
    algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
  };

  return (
    <ConfigProvider theme={antdTheme}>
      <div className="p-4 flex items-center gap-x-3 mb-4">
        <ColorPicker
          value={primaryColor}
          onChange={(_, c) => setPrimaryColor(c)}
        />

        <span className="p-2 text-primary border border-primary">
          {primaryColor}
        </span>

        <Button type="primary">123</Button>
        <Button>zzz</Button>

        {/* 注意这里非空断言 */}
        <ToggleTheme mode={mode!} onChange={(m) => setMode(m)} />
      </div>

      <h1 className="dark:text-yellow m-4 text-10">Light or dark</h1>
    </ConfigProvider>
  );
}

export default App;

效果如下:

3) 背景闪烁

刷新页面的时候,明显可以感觉到背景颜色闪烁了一下。这是因为根元素的 dark 类是通过 JS 设置的,我们的代码会在 html 第一次渲染之后执行。

解决方案:在 index.htmlhead 中插入一段脚本,提前设置 dark 类:

html 复制代码
<!-- index.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + TS</title>
    <script>
      // 这段脚本会先执行
      ;(function () {
        const prefersDark =
          window.matchMedia &&
          window.matchMedia('(prefers-color-scheme: dark)').matches
        const setting = localStorage.getItem('app_color_mode') || 'auto'
        if (setting === 'dark' || (prefersDark && setting !== 'light'))
          document.documentElement.classList.toggle('dark', true)
      })()
    </script>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

四、总结技术要点

  • window.matchMedia API
  • Antd ConfigProvider
  • useLocalStorageState
  • CSS 变量
  • 提前设置根元素的 dark

完整代码见 GitHub

相关推荐
Mintopia28 分钟前
像素的进化史诗:计算机图形学与屏幕的千年之恋
前端·javascript·计算机图形学
Mintopia30 分钟前
Three.js 中三角形到四边形的顶点变换:一场几何的华丽变身
前端·javascript·three.js
归于尽1 小时前
async/await 从入门到精通,解锁异步编程的优雅密码
前端·javascript
陈随易1 小时前
Kimi k2不行?一个小技巧,大幅提高一次成型的概率
前端·后端·程序员
猩猩程序员1 小时前
Rust 动态类型与类型反射详解
前端
杨进军1 小时前
React 实现节点删除
前端·react.js·前端框架
yanlele1 小时前
【实践篇】【01】我用做了一个插件, 点击复制, 获取当前文章为 Markdown 文档
前端·javascript·浏览器
爱编程的喵1 小时前
React useContext 深度解析:告别组件间通信的噩梦
前端·react.js
望获linux2 小时前
【实时Linux实战系列】多核同步与锁相(Clock Sync)技术
linux·前端·javascript·chrome·操作系统·嵌入式软件·软件