如何實現(xiàn)網(wǎng)站自定義主題切換
共 18536字,需瀏覽 38分鐘
·
2024-07-10 08:49
大廠技術(shù) 高級前端 Node進階
點擊上方 程序員成長指北,關(guān)注公眾號
回復(fù)1,加入高級Node交流群
哈嘍大家好,我是Koala。
很多網(wǎng)站或者管理系統(tǒng)都支持主題切換,包括明暗風(fēng)格的切換,皮膚切換,甚至自定義主題切換。主題的切換可以帶來更好的用戶體驗和提升個性化,增強可視化效果和情感感受。也是常用的功能之一了。
那如何去實現(xiàn)一個自定義主題的網(wǎng)站呢?今天這篇文章給你帶來詳細的解密!
以下是正文:
最近準備用 React + Antd + UnoCSS 開發(fā)一個和 NestJS Admin 配套的系統(tǒng),想加個自定義主題功能,這里(https://zb81.github.io/my-theme)體驗。
一、需求
-
用戶可以自定義主題顏色,需要實時響應(yīng); -
用戶可以切換明暗模式,需要實時改變背景和文字顏色; -
當用戶切換系統(tǒng)主題時,網(wǎng)頁需要作出響應(yīng); -
主題顏色和明暗模式需要緩存至 localStorage。
二、準備工作
這里使用的是 pnpm,用 npm 或 yarn 等包管理工具的記得替換命令。
1. 創(chuàng)建項目
首先,拉取 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,提供了類名的提示以及預(yù)覽:image.png -
Iconify IntelliSense
icon-ext.png 這個插件提供了圖標名稱的提示和預(yù)覽功能:
image.png
1) 安裝并引入
因為后續(xù)會用的 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) 配置文件
在項目根目錄創(chuàng)建 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
三、需求實現(xiàn)
1. 自定義主題顏色
1) 組件引入并綁定狀態(tài)
// 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 方案以及梯度變量演變算法,只需要提供一個基礎(chǔ)變量 colorPrimary ,主題相關(guān)的其它配色就能推算出來,比如按鈕點擊的波紋顏色等等。
所以,我們只需要將 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
})
創(chuàng)建組件 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 黑暗模式的算法
我們需要根據(jù) 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. 監(jiān)聽系統(tǒng)主題
剛剛我們實現(xiàn)了手動切換明暗模式,現(xiàn)在來實現(xiàn)根據(jù)當前的系統(tǒng)主題使用對應(yīng)的模式。
1) 獲取并監(jiān)聽系統(tǒng)主題
CSS 提供了媒體查詢 prefers-color-scheme: dark 用來監(jiān)聽系統(tǒng)明暗模式,如果我們想讀取,需要調(diào)用window.matchMedia該方法需要傳入一個查詢字符串,并返回一個MediaQueryList對象:
-
matches 布爾值 -
addEventListener 添加監(jiān)聽事件處理函數(shù)
為了更好的邏輯封裝和復(fù)用,創(chuàng)建一個自定義 hook usePreferredDark.ts,返回系統(tǒng)是否處于黑暗模式:
import { useState } from 'react'
export function usePreferredDark() {
// 使用系統(tǒng)當前的明暗模式作為初始值
const [matches, setMatches] = useState(window.matchMedia('(prefers-color-scheme: dark)').matches)
// 監(jiān)聽系統(tǒng)的明暗模式變化
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) 結(jié)合 mode
監(jiān)聽系統(tǒng)主題我們實現(xiàn)了,現(xiàn)在需要把 preferredDark 和 mode 結(jié)合起來判斷當前網(wǎng)頁是否處于黑暗模式,封裝一個自定義 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() // 當前系統(tǒng)是否是黑暗模式
const isDark = useMemo(() => {
return mode === 'dark' || (preferredDark && mode !== 'light') // 簡化后的邏輯
}, [mode, preferredDark])
return isDark
}
邏輯解釋:
-
因為 mode 是用戶選擇的,所以它優(yōu)先級最高,如果 mode === 'dark',直接短路返回 true; -
如果 mode === 'light',返回 false -
如果 mode === 'auto',返回當前系統(tǒng)是否處于黑暗模式
測試:
// 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;
效果如下:
四、總結(jié)技術(shù)要點
-
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學(xué)習(xí)感興趣的話(后續(xù)有計劃也可以),我們可以一起進行Node.js相關(guān)的交流、學(xué)習(xí)、共建。下方加 考拉 好友回復(fù)「Node」即可。
最后不要忘了點個贊再走噢
