如何实现网站自定义主题切换
共 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
这个插件会读取
uno.config.ts
,提供了类名的提示以及预览: -
Iconify IntelliSense
这个插件提供了图标名称的提示和预览功能:
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.matchMedia
API -
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」即可。
最后不要忘了点个赞再走噢