实现系统主题切换功能
前言
背景:我想试下前端系统切换主题是怎么实现的,所以在自己的 react demo 项目中进行了尝试需求:
- 系统主题色:包括部分大块的背景色、重要文案颜色 (less)
- iconpark 图标需要跟随系统主题色变化
- antd 主题色的切换
- 暗黑主题
项目情况:
我的实操项目地址 tmt-web react iconpark less windicss mobx antd
具体实现
保存主题色
既然要做主题切换,那么就必须要使用到一个变量用来保存主题状态,而且要整个系统都能使用到这个变量,那么就需要借助一些状态管理库了 react 生态中常用的状态管理库有:
这里因为项目全部采用函数组件来实现,所以状态管理上我采用的是 mobx + react-mobx-lite
首先定义一个主题色映射文件 theme.ts
export const themeMapping = {
purple: {
name: '基佬紫',
color: '#a222f7',
},
pink: {
name: '狠人粉',
color: '#f472b6',
},
// .....
}
新建 src/store/ThemeStore.ts
// src/store/ThemeStore.ts
import { themeMapping } from '@/utils/theme'
import { action, makeAutoObservable, observable } from 'mobx'
export class ThemeStore {
theme = '#a222f7'
themeName = 'purple'
constructor() {
makeAutoObservable(this, {
theme: observable,
themeName: observable,
updateTheme: action,
})
}
updateTheme = (theme: string) => {
// 默认主题 基佬紫
this.theme = themeMapping[theme]?.color || themeMapping.purple.color
this.themeName = theme
}
}
src/store/index.ts 导出
// src/store/index.ts
import { createContext } from 'react'
import { UserStore } from './UserStore'
// ...
export const stores = {
themeStore: new ThemeStore(),
// ...
}
export type IStores = typeof stores
export const Store = createContext<IStores>(stores)
切换主题的时候去调用 updateTheme 就行
iconpark 切换
iconpark 提供 react / vue 的图标代码复制, 如下(同一图标):
// 复制为 react 代码
<BookmarkOne theme="outline" size="16" fill="#9013fe" strokeLinecap="square"/>
// 复制为 vue 代码vue
<bookmark-one theme="outline" size="16" fill="#9013fe" strokeLinecap="square"/>
使用的时候最简单的方法就是直接 cv 大法就行
但是这样在主题切换的时候就比较麻烦了,所以需要封装一个 iconpark 提供的图标
在 react 中通常使用 hoc (高阶组件) 来封装这一操作
// src/hoc/WithIconPark.tsx
import { Store } from '@/store'
import { Icon } from '@icon-park/react/lib/runtime'
import { observer } from 'mobx-react-lite'
import { CSSProperties, useContext } from 'react'
interface WithIconParkProps {
IconComp: Icon
config?: {
theme?: 'outline' | 'filled' | 'two-tone' | 'multi-color'
size?: number
fill?: string | string[]
strokeLinecap?: 'square' | 'butt'
strokeLinejoin?: 'miter'
}
className?: string
style?: CSSProperties
[key: string]: any
}
function WithIconPark(props: WithIconParkProps) {
const { IconComp, config = {}, ...arg } = props
// 使用 mobx 保存的主题色变量实现 icon 颜色的动态变化
const {
themeStore: { theme: iconTheme },
} = useContext(Store)
const { theme = 'outline', size = '16', fill = iconTheme, strokeLinecap = 'square', strokeLinejoin } = config
return (
<IconComp
fill={fill}
theme={theme}
size={size}
strokeLinecap={strokeLinecap}
strokeLinejoin={strokeLinejoin}
className={props?.className}
style={props?.style}
{...arg}
/>
)
}
export default observer(WithIconPark)
组件使用
import { BookmarkOne } from '@icon-park/react'
export default () => (
<WithIconPark
IconComp={BookmarkOne}
config={{
size: 24,
strokeLinecap: 'butt',
}}></WithIconPark>
)
less 实现切换
首先,在我自己项目开发的时候,我是同时引入了 windicss 和 less,所以在做 less 主题切换的时候也踩了不少的坑 (后面会说)
首先在主题切换的时候,我们给 body 标签加上一个属性
// value 就是前面我们定义的 themeMapping 中的 key
document.body.setAttribute('system_theme_color', value)
接着我们新建 src/style/theme.less 和 src/style/var.less, 这里我们使用紫色主题和绿色主题作为案例
// src/style/var.css
:root {
.system_theme_purple {
--theme-color: #a222f7;
--active-color: #d946ef;
--common-bgc-01: #e8e8ff;
--common-bgc-02: #fcfcfd;
--common-bgc-03: #e7e0fc;
--common-bgc-04: #f1ebff;
// ....默认主题颜色-基佬紫--
}
.system_theme_purple {
--theme-color: #a222f7;
--active-color: #d946ef;
--common-bgc-01: #e8e8ff;
--common-bgc-02: #fcfcfd;
--common-bgc-03: #e7e0fc;
--common-bgc-04: #f1ebff;
// ....
}
.system_theme_green {
--theme-color: #bef264;
--active-color: #97ffd5;
--common-bgc-01: #ecfccb;
--common-bgc-02: #d9f99d;
--common-bgc-03: #f7fee7;
--common-bgc-04: #a7f3d0;
// ....
}
之后要想新增别的主题,只需要新增 .systemtheme你的主题色 和增加判断使用那个主题
@import './var.css';
// ...
// 大块背景色,具体可以跳转到 【实现效果】部分查看
.layoutBgc() {
background: radial-gradient(circle at 10% 0%, --common-bgc-01, --common-bgc-01, --common-bgc-01, --common-bgc-01);
}
// ...
使用的时候利用 less 提供的 mixin 能力实现主题切换
@import '../style/theme.less';
.layout_content {
width: 100%;
height: 100%;
overflow: scroll;
.layoutBgc;
}
这里没有把 theme.less 全局引用,如果有很多样式文件要用到,建议还是全局引用这个文件, 可以使用插件 style-resources-loader 实现
windicss + less 的坑
接下来说下 windicss + less 在实现主题切换时我踩的坑
先说下场景:
一个组件中可能会出现好几个 dom 结构和样式基本一样的块,这时如果全部采用 windicss 会出现类型下面的场景
export default () => (
<>
<div className="bg-light-200 rounded-xl m-2 w-min-200px h-120px p-4 pb-0 cursor-pointer"></div>
<div className="bg-light-200 rounded-xl m-2 w-min-200px h-120px p-4 pb-0 cursor-pointer"></div>
<div className="bg-light-200 rounded-xl m-2 w-min-200px h-120px p-4 pb-0 cursor-pointer"></div>
</>
)
所以 windicss 为我们提供了 @apply 用于提取一组样式
.box {
@apply bg-light-200 rounded-xl m-2 w-min-200px h-120px p-4 pb-0 cursor-pointer;
}
注意,这个 apply 不是 css, css 原生的 apply 已弃用
那么上面的样式可以改为
export default () => (
<>
<div className="box"></div>
<div className="box"></div>
<div className="box"></div>
</>
)
然后问题就出现了, 如下:
// test.less
.border() {
border: 1px solid #ccc;
}
.box {
.border;
@apply bg-light-200 rounded-xl m-2 w-min-200px h-120px p-4 pb-0 cursor-pointer;
}
这样看样子是使用了 windicss 的 @apply 和 less 的混入,但实际上的效果是 windicss @apply 里面的样式可以生效,但是 less 混入的样式却无效 。。。。我 1 脸 2 脸 3 脸懵逼。。。。
在询问了之前实习时认识的一个大佬(现在在网易),大佬说混入的语法是正确的,那么问题只能出现在 windicss 的 apply 上了,于是我在 windicss 的官网上寻找答案,最终在 windicss-webpack 部分找到了原因:
那么问题就来了:这里说的不能一起使用是指同个 class 不能一起使用?还是父级使用了 apply 子级也不能使用混入?还是 less 文件中使用了 apply ,那么整个文件就不能使用 less 的混入?
带着问题我进行了如下实验:
import './test.less'
const TestLessWindicss = () => (
<div className="bg-light-100">
<div className="div1">
1
<div className="div2-1">
2-1
<div className="div3">3</div>
</div>
<div className="div2-2">2-2</div>
</div>
<div className="div4">4</div>
</div>
)
export default TestLessWindicss
- 直接使用混入不使用 apply
@import '../../style/theme.less';
.div1 {
width: 300px;
height: 300px;
border: 1px #000 solid;
.div2-1 {
width: 150px;
height: 150px;
border: 1px #000 solid;
.layoutBgc; // 看这里
.div3 {
width: 100px;
border: 1px #000 solid;
height: 100px;
}
}
.div2-2 {
border: 1px #000 solid;
width: 150px;
height: 100px;
}
}
.div4 {
width: 150px;
height: 100px;
border: 1px #000 solid;
.layoutBgc; // 看这里
}
结果: 混入起效了
- 二级节点使用 apply, 一级和三级节点使用 混入
@import '../../style/theme.less';
.div1 {
width: 300px;
height: 300px;
border: 1px #000 solid;
.div2-1 {
width: 150px;
height: 150px;
border: 1px #000 solid;
@apply bg-blue-200; // 看这里
.div3 {
width: 100px;
border: 1px #000 solid;
height: 100px;
.layoutBgc; // 看这里
}
}
.div2-2 {
border: 1px #000 solid;
width: 150px;
height: 100px;
}
}
.div4 {
width: 150px;
height: 100px;
border: 1px #000 solid;
.layoutBgc; // 看这里
}
结果: 1/3 级节点混入的样式无效,只有第二级的节点 apply 引入的样式起效。
结论:只要 less 文件中的任何地方使用了 windicss 的 apply,less 的混入就无效,所以在实际使用中可以将混入样式单独定义为一个文件,然后全局引入思考:windicss 是基于 tailwind 实现的,那么 tailwind 会不会也有一样的问题
antd 实现切换
antd5 提供了极其便捷的主题切换方式,不需要自己再去做什么操作,只需要把 mobx 中保存的主题色拿出来传给 antd 就行,具体看代码
// src/App.tsx
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import { observer } from 'mobx-react-lite'
import { useContext, useMemo } from 'react'
import RouterGuard from '@/components/Hoc/RouterGuard'
import { Store } from './store'
dayjs.locale('zh-cn')
function App() {
const store = useContext(Store)
const token = useMemo(
() => ({
colorPrimary: store.themeStore.theme,
}),
[store.themeStore.theme],
)
return (
<ConfigProvider locale={zhCN} theme={{ token }}>
<RouterGuard />
</ConfigProvider>
)
}
export default observer(App)
黑暗模式实现
暗黑模式的实现也比较简单,用的是 css 的 filter 属性
#dark {
filter: invert(100%) hue-rotate(180deg);
img,
video,
.no-filter {
filter: invert(100%) hue-rotate(180deg) contrast(100%);
}
}
用了这个样式后,大白都变包拯
对于 图片、视频以及使用了 no-filter 的元素 颜色取反之后再取反 就变成原来的颜色了
如下面这些就不会被改成黑啊模式
<img src="xxxxxxxxxx" />
<video src="xxxxxxxxxx" />
<div className="no-filter"></div>
那么这个 id = ‘dark’又要怎么处理比较好呢?我的答案是封装成一个自定义 hook
import { Theme } from '@/types/AppLayout.types'
import { useState } from 'react'
export default function useSystemTheme() {
const [theme, setTheme] = useState<Theme>('')
const toggleTheme = () => {
const html = document.getElementsByTagName('html')[0]
const currentTheme = html.id as Theme
const prevTheme = !!currentTheme ? '' : 'dark'
html.setAttribute('id', prevTheme)
setTheme(prevTheme)
}
return {
theme,
toggleTheme,
}
}
实现效果
我给内置了 6 套主题色
默认主题:【紫】
看下其他主题色:【粉】
【粉】+ 黑暗模式
【绿】
扩展
之前将尝试能不能使用 windicss + css 变量来完成主题切换的功能,从而达到在项目中全面代替 less + css 变量