面试官:如何实现网站自定义主题切换

哈喽大家好,我是Fine。

很多网站或者管理系统都支持主题切换,包括明暗风格的切换,皮肤切换,甚至自定义主题切换。主题的切换可以带来更好的用户体验和提升个性化,增强可视化效果和情感感受。也是常用的功能之一了。

那如何去实现一个自定义主题的网站呢?今天这篇文章给你带来详细的解密!

以下是正文:


最近准备用 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 目录:

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

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

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

export default App;
image.png

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

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

3) 绑定 dark 类

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

.dark .dark\:text-yellow {
    --un-text-opacity1;
    colorrgb(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

4) 使用 CSS 变量同步颜色

新建 main.css

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

html {
  /* 使用 CSS 变量 */
  background-colorvar(--c-bg);
  colorvar(--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-colorvar(--c-scrollbar) var(--c-bg);
}

::-webkit-scrollbar {
  width6px;
}

::-webkit-scrollbar:horizontal {
  height6px;
}

::-webkit-scrollbar-track,
::-webkit-scrollbar-corner {
  backgroundvar(--c-bg);
  border-radius10px;
}

::-webkit-scrollbar-thumb {
  backgroundvar(--c-scrollbar);
  border-radius10px;
}

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

引入 main.tsx

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

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

Kapture 2024-01-30 at 16.39.13.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-30 at 16.37.09.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-30 at 16.36.11.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-30 at 16.28.23.gif

四、总结技术要点

  • window.matchMedia API
  • Antd ConfigProvider
  • useLocalStorageState
  • CSS 变量

完整代码见 GitHub(https://github.com/zb81/my-theme)。

原文地址:https://juejin.cn/post/7329352197836914697

侵删

最后

还没有使用过我们刷题网站(https://fe.ecool.fun/)或者前端面试题宝典的同学,如果近期准备或者正在找工作,千万不要错过,我们的题库主打无广告和更新快哦~。

老规矩,也给我们团队的辅导服务打个广告。