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

1 年前
/
15

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

theme.gif

theme.gif

一、需求

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

二、准备工作

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

1. 创建项目

首先,拉取 vite 模板:

pnpm create vite my-theme --template react-ts

清空 src 目录:

image.png

image.png
// 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>,
)
// App.tsx
function App() {
  return (
    <div>App</div>
  )
}

export default App

启动项目:

pnpm run dev

2. 安装 Antd

pnpm add antd
// App.tsx
import { Button } from 'antd'

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

export default App
image.png

image.png

3. 安装并配置 UnoCSS

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

  • UnoCSS

    unocss-ext.png

    unocss-ext.png

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

    image.png

    image.png
  • Iconify IntelliSense

    icon-ext.png

    icon-ext.png

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

    image.png

    image.png

1) 安装并引入

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

pnpm add unocss @iconify/json -D

配置 vite.config.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 中引入:

// main.tsx
import 'virtual:uno.css'

2) 配置文件

在项目根目录创建 uno.config.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) 样式重置

pnpm add @unocss/reset

main.tsx 中引入:

import '@unocss/reset/tailwind-compat.css'

4) 测试

App.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
image.png

image.png

三、需求实现

1. 自定义主题颜色

1) 组件引入并绑定状态

// 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>{}</span>

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

export default App;
image.png

image.png

2) 和 Antd 组件同步

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

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

// 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;
antd.gif

antd.gif

3) 和其他颜色同步

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

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

export default defineConfig({
  theme: {
    colors: {
      primary: 'var(--primary-color)', // 这里定义了一个颜色,通过 `text-primary` 的方式使用
    },
  },
  // ... rest config
})
// 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;
primary-color.gif

primary-color.gif

2. 明暗模块切换

安装 classnames 方便组装类名:

pnpm add classnames

1) 封装切换组件

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

// 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

// 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) 引入组件

// 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;
image.png

image.png

3) 绑定 dark 类

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

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

使用 useEffect 同步 dark 类:

// 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;
dark-text.gif

dark-text.gif

4) 使用 CSS 变量同步颜色

新建 main.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

// main.tsx
import './main.css'

效果如下:

bg-dark.gif

bg-dark.gif

5) 同步 Antd

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

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

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

// 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 按钮的背景颜色)

dark-antd.gif

dark-antd.gif

3. 监听系统主题

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

1) 获取并监听系统主题

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

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

为了更好的逻辑封装和复用,创建一个自定义 hook usePreferredDark.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
}

测试:

// 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;

效果如下:

Kapture 2024-01-29 at 16.19.04.gif

Kapture 2024-01-29 at 16.19.04.gif

2) 结合 mode

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

// 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',返回当前系统是否处于黑暗模式

测试:

// 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;

效果如下:

Kapture 2024-01-29 at 16.36.32.gif

Kapture 2024-01-29 at 16.36.32.gif

最后修改 mode 的初始值为 auto

// App.tsx
function App() {
  // ...

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

  return (
    // ...
  );
}

export default App;

4. 缓存至 localStorage

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

1) 安装 ahooks

pnpm add ahooks

2) 替换 useState

// 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;

效果如下:

Kapture 2024-01-29 at 16.48.44.gif

Kapture 2024-01-29 at 16.48.44.gif

3) 背景闪烁

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

解决方案:在 index.htmlhead 中插入一段脚本:

<!-- 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 变量

完整代码见 GitHub

  • Loading...
  • Loading...
  • Loading...
  • Loading...