如何实现网站自定义主题切换
共 18536字,需浏览 38分钟
·
2024-07-10 08:49
    
      
     大厂技术  高级前端  Node进阶
点击上方 程序员成长指北,关注公众号
回复1,加入高级Node交流群
   哈喽大家好,我是Koala。
很多网站或者管理系统都支持主题切换,包括明暗风格的切换,皮肤切换,甚至自定义主题切换。主题的切换可以带来更好的用户体验和提升个性化,增强可视化效果和情感感受。也是常用的功能之一了。
那如何去实现一个自定义主题的网站呢?今天这篇文章给你带来详细的解密!
以下是正文:
最近准备用 React + Antd + UnoCSS 开发一个和 NestJS Admin 配套的系统,想加个自定义主题功能,这里(https://zb81.github.io/my-theme)体验。
一、需求
- 
     
用户可以自定义主题颜色,需要实时响应;  - 
     
用户可以切换明暗模式,需要实时改变背景和文字颜色;  - 
     
当用户切换系统主题时,网页需要作出响应;  - 
     
主题颜色和明暗模式需要缓存至 localStorage。 
二、准备工作
这里使用的是 pnpm,用 npm 或 yarn 等包管理工具的记得替换命令。
1. 创建项目
首先,拉取 vite 模板:
pnpm create vite my-theme --template react-ts
   清空 src 目录:
// 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
   3. 安装并配置 UnoCSS
开始之前,先推荐两个 VSCode 插件:
- 
     
UnoCSS
unocss-ext.png 这个插件会读取
uno.config.ts,提供了类名的提示以及预览:image.png  - 
     
Iconify IntelliSense
icon-ext.png 这个插件提供了图标名称的提示和预览功能:
image.png  
1) 安装并引入
因为后续会用的 CSS 图标,这里顺带安装一下图标库。(体积很大,70M)
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 文档(https://unocss.dev/config/)。
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
   三、需求实现
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>{primaryColor}</span>
      <Button type="primary">123</Button>
      <Button>zzz</Button>
    </div>
  );
}
export default App;
   2) 和 Antd 组件同步
新版本的 Antd 采用了 CSS-in-JS 方案以及梯度变量演变算法,只需要提供一个基础变量 colorPrimary ,主题相关的其它配色就能推算出来,比如按钮点击的波纹颜色等等。
所以,我们只需要将 primaryColor 通过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;
   3) 和其他颜色同步
这里使用 CSS 变量的方案来保持颜色同步:
- 
     
给根元素添加一个 CSS 变量 --primary-color; - 
     
给 UnoCSS 添加一个颜色 primary: 'var(--primary-color)'; - 
     
添加一个副作用,让 primaryColor和--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;
   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;
   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;
   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);
  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'
   5) 同步 Antd
Antd 暴露的 theme 提供了几种颜色算法,我们需要用到这两种:
- 
     
defaultAlgorithm 默认算法  - 
     
darkAlgorithm 黑暗模式的算法  
我们需要根据 mode 给 ConfgProvider 提供不同的算法:
// 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,返回系统是否处于黑暗模式:
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;
   效果如下:
2) 结合 mode
监听系统主题我们实现了,现在需要把 preferredDark 和 mode 结合起来判断当前网页是否处于黑暗模式,封装一个自定义 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;
   效果如下:
最后修改 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;
   效果如下:
四、总结技术要点
- 
     
window.matchMediaAPI - 
     
Antd ConfigProvider - 
     
useLocalStorageState - 
     
CSS 变量  
完整代码见 GitHub(https://github.com/zb81/my-theme)。
原文地址:https://juejin.cn/post/7329352197836914697
zb81
- 
    
 Node 社群
我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。
最后不要忘了点个赞再走噢
