Skip to content
霞露小伙 — HfWang
On this page

实现系统主题切换功能

前言

背景:我想试下前端系统切换主题是怎么实现的,所以在自己的 react demo 项目中进行了尝试需求:

  1. 系统主题色:包括部分大块的背景色、重要文案颜色 (less)
  2. iconpark 图标需要跟随系统主题色变化
  3. antd 主题色的切换
  4. 暗黑主题

项目情况:

我的实操项目地址 tmt-web react iconpark less windicss mobx antd

具体实现

保存主题色

既然要做主题切换,那么就必须要使用到一个变量用来保存主题状态,而且要整个系统都能使用到这个变量,那么就需要借助一些状态管理库了 react 生态中常用的状态管理库有:

这里因为项目全部采用函数组件来实现,所以状态管理上我采用的是 mobx + react-mobx-lite

mobx 的使用这里就不说了, 想了解可以看这篇文章 Mobx 基本使用 或者直接阅读 mobx 6 官网

首先定义一个主题色映射文件 theme.ts

typescript
export const themeMapping = {
  purple: {
    name: '基佬紫',
    color: '#a222f7',
  },
  pink: {
    name: '狠人粉',
    color: '#f472b6',
  },
  // .....
}

新建 src/store/ThemeStore.ts

typescript
// 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 导出

typescript
// 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

iconpark 提供 react / vue 的图标代码复制, 如下(同一图标):

typescript
// 复制为 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 (高阶组件) 来封装这一操作

扩展阅读: 「react 进阶」一文吃透 React 高阶组件(HOC)

tsx
// 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)

组件使用

typescript
import { BookmarkOne } from '@icon-park/react'

export default () => (
  <WithIconPark
    IconComp={BookmarkOne}
    config={{
      size: 24,
      strokeLinecap: 'butt',
    }}></WithIconPark>
)

less 实现切换

首先,在我自己项目开发的时候,我是同时引入了 windicss 和 less,所以在做 less 主题切换的时候也踩了不少的坑 (后面会说)

首先在主题切换的时候,我们给 body 标签加上一个属性

typescript
// value 就是前面我们定义的 themeMapping 中的 key
document.body.setAttribute('system_theme_color', value)

接着我们新建 src/style/theme.less 和 src/style/var.less, 这里我们使用紫色主题和绿色主题作为案例

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你的主题色 和增加判断使用那个主题

less
@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 能力实现主题切换

less
@import '../style/theme.less';

.layout_content {
  width: 100%;
  height: 100%;
  overflow: scroll;
  .layoutBgc;
}

这里没有把 theme.less 全局引用,如果有很多样式文件要用到,建议还是全局引用这个文件, 可以使用插件 style-resources-loader 实现

windicss + less 的坑

接下来说下 windicss + less 在实现主题切换时我踩的坑

先说下场景:

一个组件中可能会出现好几个 dom 结构和样式基本一样的块,这时如果全部采用 windicss 会出现类型下面的场景

tsx
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 用于提取一组样式

less
.box {
  @apply bg-light-200 rounded-xl m-2 w-min-200px h-120px p-4 pb-0 cursor-pointer;
}

注意,这个 apply 不是 css, css 原生的 apply 已弃用

1b856d1d8af1c99e2e7f1eba76dd1d3-2023-02-05-21-07-19

那么上面的样式可以改为

tsx
export default () => (
  <>
    <div className="box"></div>
    <div className="box"></div>
    <div className="box"></div>
  </>
)

然后问题就出现了, 如下:

less
// 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 部分找到了原因:

image-2023-02-05-21-07-40

那么问题就来了:这里说的不能一起使用是指同个 class 不能一起使用?还是父级使用了 apply 子级也不能使用混入?还是 less 文件中使用了 apply ,那么整个文件就不能使用 less 的混入?

带着问题我进行了如下实验:

tsx
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
  1. 直接使用混入不使用 apply
less
@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; // 看这里
}

image-2023-02-05-21-07-56

结果: 混入起效了

  1. 二级节点使用 apply, 一级和三级节点使用 混入
less
@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; // 看这里
}

image-2023-02-05-21-08-15

结果: 1/3 级节点混入的样式无效,只有第二级的节点 apply 引入的样式起效。

结论:只要 less 文件中的任何地方使用了 windicss 的 apply,less 的混入就无效,所以在实际使用中可以将混入样式单独定义为一个文件,然后全局引入思考:windicss 是基于 tailwind 实现的,那么 tailwind 会不会也有一样的问题

antd 实现切换

ant design 5 主题定制

antd5 提供了极其便捷的主题切换方式,不需要自己再去做什么操作,只需要把 mobx 中保存的主题色拿出来传给 antd 就行,具体看代码

typescript
// 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 属性

css
#dark {
  filter: invert(100%) hue-rotate(180deg);

  img,
  video,
  .no-filter {
    filter: invert(100%) hue-rotate(180deg) contrast(100%);
  }
}

用了这个样式后,大白都变包拯

对于 图片、视频以及使用了 no-filter 的元素 颜色取反之后再取反 就变成原来的颜色了

如下面这些就不会被改成黑啊模式

html
<img src="xxxxxxxxxx" />
<video src="xxxxxxxxxx" />
<div className="no-filter"></div>

那么这个 id = ‘dark’又要怎么处理比较好呢?我的答案是封装成一个自定义 hook

typescript
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 套主题色

image-2023-02-05-21-08-41

默认主题:【紫】

image-2023-02-05-21-08-56

看下其他主题色:【粉】

image-2023-02-05-21-09-42

【粉】+ 黑暗模式

image-2023-02-05-21-10-03

【绿】

image-2023-02-05-21-10-19

扩展

之前将尝试能不能使用 windicss + css 变量来完成主题切换的功能,从而达到在项目中全面代替 less + css 变量

本站中引用到的其他资料,如有侵权,请联系本人删除