实现vue3后台管理系统中的一键换肤功能

动态换肤实现原理

今天被问到了一个动态换肤的问题,我只回答了动态修改scss中代表色值的变量就可以实现,回答的有些笼统,过于简单了,所以回来以后查找资料、动手实践,将这一过程记录下来。

想要实现动态换肤的一个前置条件就是:色值不可以写死!

scss中,我们可以通过$变量名:变量值的方式定义css变量,然后通过该css变量来指定某一块DOM对应的颜色。如果我改变了该css变量的值,那么是不是对应的DOM颜色也会同步发生变化?

当大量的DOM都依赖于这个css变量设置颜色时,我们是不是只需要修改这个css变量,那么所有的DOM颜色也会发生变化,那么动态换肤这个功能是不是就实现了?

这就是动态换肤的实现原理。

动态换肤实现方案分析

明确了上面的实现原理以后我们可以得出一个结论,那就是在实现动态换肤的时候要兼顾两个方面:

  • 动态换肤的关键是修改css变量的值

  • 换肤需要同时兼顾element-plus和非element-plus

那么根据以上关键信息,我们可以得出对应的解决方案:

  1. 创建一个组件ThemeSelect用来处理修改之后的css变量的值

  2. 根据新值修改element-plus的主题颜色

  3. 根据新值修改非element-plus的主题颜色

创建ThemeSelect组件

我们在页面右上方有一个类似于衣服的icon图标,鼠标悬浮上去会显示提示文字“更改主题”。这里用到的组件是element-plus中的下拉菜单el-dropdown组件。实现过程如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<template>
<el-dropdown
v-bind="$attrs"
trigger="click"
class="theme"
@command="handleSetTheme"
>
<div>
<el-tooltip :content="$t('msg.navBar.themeChange')">
<svg-icon icon="change-theme" />
</el-tooltip>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="color">
{{ $t('msg.theme.themeColorChange') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 展示弹出层 -->
<div></div>
</template>

<script setup>
// 点击菜单项触发的事件回调
const handleSetTheme = command => {}
</script>

<style lang="scss" scoped></style>

组件封装好以后,在navbar组件中使用它。如下代码所示:

1
2
3
<div class="right-menu">
<theme-picker class="right-menu-item hover-effect"></theme-picker>
</div>
1
import ThemePicker from '@/components/ThemeSelect/index'

创建SelectColor组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
<template>
<el-dialog title="提示" :model-value="modelValue" @close="closed" width="22%">
<div class="center">
<p class="title">{{ $t('msg.theme.themeColorChange') }}</p>
<el-color-picker
v-model="mColor"
:predefine="predefineColors"
></el-color-picker>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="closed">{{ $t('msg.universal.cancel') }}</el-button>
<el-button type="primary" @click="comfirm">{{
$t('msg.universal.confirm')
}}</el-button>
</span>
</template>
</el-dialog>
</template>

<script setup>
import { defineProps, defineEmits, ref } from 'vue'
defineProps({
modelValue: {
type: Boolean,
required: true
}
})
const emits = defineEmits(['update:modelValue'])

// 预定义色值
const predefineColors = [
'#ff4500',
'#ff8c00',
'#ffd700',
'#90ee90',
'#00ced1',
'#1e90ff',
'#c71585',
'rgba(255, 69, 0, 0.68)',
'rgb(255, 120, 0)',
'hsv(51, 100, 98)',
'hsva(120, 40, 94, 0.5)',
'hsl(181, 100%, 37%)',
'hsla(209, 100%, 56%, 0.73)',
'#c7158577'
]
// 默认色值
const mColor = ref('#00ff00')

/**
* 关闭
*/
const closed = () => {
emits('update:modelValue', false)
}
/**
* 确定
* 1. 修改主题色
* 2. 保存最新的主题色
* 3. 关闭 dialog
*/
const comfirm = async () => {
// 3. 关闭 dialog
closed()
}
</script>

<style lang="scss" scoped>
.center {
text-align: center;
.title {
margin-bottom: 12px;
}
}
</style>

这里会用到element-plus中的colorPicker取色器组件。在选择好颜色的色值以后,在确认事件的回调里面会做三件事,修改主题色,保存主题色,关闭dialog弹框。在封装好了这个组件以后呢,我们可以在前面的ThemeSelect组件中使用它,让用户点击下拉菜单的时候显示这个取色器:

1
2
3
4
5
6
7
<template>
...
<!-- 展示弹出层 -->
<div>
<select-color v-model="selectColorVisible"></select-color>
</div>
</template>
1
2
3
4
5
6
7
8
9
10
11
<script setup>
import SelectColor from './components/SelectColor.vue'
import { ref } from 'vue'

// 创建变量,用来双向绑定包含取色器的弹框
const selectColorVisible = ref(false)
// 显示取色器组件
const handleSetTheme = command => {
selectColorVisible.value = true
}
</script>

显示取色器组件没问题以后,我们就要开始思考如何将用户选择的新色值保存在我们的本地做一个缓存。在vue项目中常用的缓存方式有两种,第一种是放在vuex里面,第二种是本地存储比如localStorage。

创建theme相关的store模块

首先我们在constants/index下面创建两个常量用来表示主题色的key和默认色值:

1
2
3
4
5
// 主题色的key
export const MAIN_COLOR = 'mainColor'

// 默认色值
export const DEFAULT_COLOR = '#409eff'

store目录下创建主题相关的模块store/modules/theme,这里面存放的都是主题相关的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 引入封装好的本地存储相关的工具方法
import { getItem, setItem } from '@/utils/storage'
// 引入主题色和默认色值的常量
import { MAIN_COLOR, DEFAULT_COLOR } from '@/constant'
export default {
// 命名空间
namespaced: true,
// 状态
state: () => ({
// 主题色 从本地存储中获取,如果没有的话就取设定好的默认色值
mainColor: getItem(MAIN_COLOR) || DEFAULT_COLOR
}),
mutations: {
/**
* 设置主题色
*/
setMainColor(state, newColor) {
// 修改state中的mainColor
state.mainColor = newColor
// 将新的色值进行一个本地存储
setItem(MAIN_COLOR, newColor)
}
}
}

为了方便访问,我们将mainColorgetters指定一下:

1
mainColor: state => state.theme.mainColor

store/index中导入theme

1
2
3
4
5
6
7
8
9
10
11
...
import theme from './modules/theme.js'

export default createStore({
getters,
modules: {
...
theme
}
})

SelectColor组件中引入store

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script setup>
import { defineProps, defineEmits, ref } from 'vue'
import { useStore } from 'vuex'
...
const store = useStore()
// 默认色值
const mColor = ref(store.getters.mainColor)
...
/**
* 确定
* 1. 修改主题色
* 2. 保存最新的主题色
* 3. 关闭 dialog
*/
const comfirm = async () => {
// 2. 保存最新的主题色
store.commit('theme/setMainColor', mColor.value)
// 3. 关闭 dialog
closed()
}
</script>

处理element-plus主题变更原理与步骤分析

实现原理

在之前我们分析主题变更的实现原理时,我们说过,核心的原理是:通过修改 scss 变量 的形式修改主题色完成主题变更。

但是对于 element-plus 而言,我们怎么去修改这样的主题色呢?

其实整体的原理非常简单,分为三步:

  • 获取当前 element-plus 的所有样式
  • 找到我们想要替换的样式部分,通过正则完成替换
  • 把替换后的样式写入到 style 标签中,利用样式优先级的特性,替代固有样式

实现步骤

那么明确了原理之后,我们的实现步骤也就呼之欲出了,对应原理总体可分为四步:

  1. 获取当前 element-plus 的所有样式
  2. 定义我们要替换之后的样式
  3. 在原样式中,利用正则替换新样式
  4. 把替换后的样式写入到 style 标签中

处理element-plus主题变更

创建utils/theme工具类,写入两个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 写入新样式到 style
* @param {*} elNewStyle element-plus 的新样式
* @param {*} isNewStyleTag 是否生成新的 style 标签
*/
export const writeNewStyle = elNewStyle => {

}

/**
* 根据主色值,生成最新的样式表
*/
export const generateNewStyle = primaryColor => {

}

那么接下来我们先实现第一个方法 generateNewStyle,在实现的过程中,我们需要安装两个工具类:

rgb-hex:转换RGB(A)颜色为十六进制
css-color-function:在CSS中提出的颜色函数的解析器和转换器

然后还需要写入一个 颜色转化计算器 formula.json

创建 constants/formula.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"shade-1": "color(primary shade(10%))",
"light-1": "color(primary tint(10%))",
"light-2": "color(primary tint(20%))",
"light-3": "color(primary tint(30%))",
"light-4": "color(primary tint(40%))",
"light-5": "color(primary tint(50%))",
"light-6": "color(primary tint(60%))",
"light-7": "color(primary tint(70%))",
"light-8": "color(primary tint(80%))",
"light-9": "color(primary tint(90%))",
"subMenuHover": "color(primary tint(70%))",
"subMenuBg": "color(primary tint(80%))",
"menuHover": "color(primary tint(90%))",
"menuBg": "color(primary)"
}

准备就绪后,我们来实现 generateNewStyle 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import color from 'css-color-function'
import rgbHex from 'rgb-hex'
import formula from '@/constant/formula.json'
import axios from 'axios'

/**
* 根据主色值,生成最新的样式表
*/
export const generateNewStyle = async primaryColor => {
const colors = generateColors(primaryColor)
let cssText = await getOriginalStyle()

// 遍历生成的样式表,在 CSS 的原样式中进行全局替换
Object.keys(colors).forEach(key => {
cssText = cssText.replace(
new RegExp('(:|\\s+)' + key, 'g'),
'$1' + colors[key]
)
})

return cssText
}

/**
* 根据主色生成色值表
*/
export const generateColors = primary => {
if (!primary) return
const colors = {
primary
}
Object.keys(formula).forEach(key => {
const value = formula[key].replace(/primary/g, primary)
colors[key] = '#' + rgbHex(color.convert(value))
})
return colors
}

/**
* 获取当前 element-plus 的默认样式表
*/
const getOriginalStyle = async () => {
const version = require('element-plus/package.json').version
const url = `https://unpkg.com/element-plus@${version}/dist/index.css`
const { data } = await axios(url)
// 把获取到的数据筛选为原样式模板
return getStyleTemplate(data)
}

/**
* 返回 style 的 template
*/
const getStyleTemplate = data => {
// element-plus 默认色值
const colorMap = {
'#3a8ee6': 'shade-1',
'#409eff': 'primary',
'#53a8ff': 'light-1',
'#66b1ff': 'light-2',
'#79bbff': 'light-3',
'#8cc5ff': 'light-4',
'#a0cfff': 'light-5',
'#b3d8ff': 'light-6',
'#c6e2ff': 'light-7',
'#d9ecff': 'light-8',
'#ecf5ff': 'light-9'
}
// 根据默认色值为要替换的色值打上标记
Object.keys(colorMap).forEach(key => {
const value = colorMap[key]
data = data.replace(new RegExp(key, 'ig'), value)
})
return data
}


接下来处理 writeNewStyle 方法:

1
2
3
4
5
6
7
8
9
10
11
/**
* 写入新样式到 style
* @param {*} elNewStyle element-plus 的新样式
* @param {*} isNewStyleTag 是否生成新的 style 标签
*/
export const writeNewStyle = elNewStyle => {
const style = document.createElement('style')
style.innerText = elNewStyle
document.head.appendChild(style)
}

最后在SelectColor.vue中导入这两个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
...

<script setup>
...
import { generateNewStyle, writeNewStyle } from '@/utils/theme'
...
/**
* 确定
* 1. 修改主题色
* 2. 保存最新的主题色
* 3. 关闭 dialog
*/

const comfirm = async () => {
// 1.1 获取主题色
const newStyleText = await generateNewStyle(mColor.value)
// 1.2 写入最新主题色
writeNewStyle(newStyleText)
// 2. 保存最新的主题色
store.commit('theme/setMainColor', mColor.value)
// 3. 关闭 dialog
closed()
}
</script>

处理element-plus新主题刷新页面不生效的问题

到目前我们已经完成了 element-plus 的主题变更,但是当前的主题变更还有一个小问题,那就是:在刷新页面后,新主题会失效

那么出现这个问题的原因,非常简单:因为没有写入新的 style

所以我们只需要在 应用加载后,写入 style 即可

那么写入的时机,我们可以放入到 app.vue

1
2
3
4
5
6
7
8
9
10
11
<script setup>
import { useStore } from 'vuex'
import { generateNewStyle, writeNewStyle } from '@/utils/theme'

const store = useStore()
generateNewStyle(store.getters.mainColor).then(newStyleText => {
writeNewStyle(newStyleText)
})
</script>


自定义主题变更

自定义主题变更相对来说比较简单,因为 自己的代码更加可控。

目前在我们的代码中,需要进行 自定义主题变更 为 menu 菜单背景色

而目前指定 menu 菜单背景色的位置在 layout/components/sidebar/SidebarMenu.vue

1
2
3
4
5
6
7
8
9
10
<el-menu
:default-active="activeMenu"
:collapse="!$store.getters.sidebarOpened"
:background-color="$store.getters.cssVar.menuBg"
:text-color="$store.getters.cssVar.menuText"
:active-text-color="$store.getters.cssVar.menuActiveText"
:unique-opened="true"
router
>

此处的 背景色是通过 getters 进行指定的,该 cssVar 的 getters 为:

1
cssVar: state => variables,

所以,我们想要修改 自定义主题 ,只需要从这里入手即可。

根据当前保存的 mainColor 覆盖原有的默认色值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import variables from '@/styles/variables.scss'
import { MAIN_COLOR } from '@/constant'
import { getItem } from '@/utils/storage'
import { generateColors } from '@/utils/theme'

const getters = {
...
cssVar: state => {
return {
...variables,
// 工具函数,根据主题色生成新的颜色,覆盖原有的默认色值
...generateColors(getItem(MAIN_COLOR))
}
},
...
}
export default getters

但是我们这样设定之后,整个自定义主题变更,还存在两个问题:

  1. menuBg 背景颜色没有变化

这个问题是因为咱们的 sidebar 的背景色未被替换,所以我们可以在 layout/index 中设置 sidebarbackgroundColor

1
2
3
4
5
6
<sidebar
id="guide-sidebar"
class="sidebar-container"
:style="{ backgroundColor: $store.getters.cssVar.menuBg }"
/>

  1. 主题色替换之后,需要刷新页面才可响应

这个是因为 getters 中没有监听到 依赖值的响应变化,所以我们希望修改依赖值:

store/modules/theme中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
...
import variables from '@/styles/variables.scss'
export default {
namespaced: true,
state: () => ({
...
variables
}),
mutations: {
/**
* 设置主题色
*/
setMainColor(state, newColor) {
...
state.variables.menuBg = newColor
...
}
}
}

getters中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
....

const getters = {
...
cssVar: state => {
return {
...state.theme.variables,
...generateColors(getItem(MAIN_COLOR))
}
},
...
}
export default getters

动态换肤方案总结

那么到这里整个自定义主题我们就处理完成了。

对于 自定义主题而言,核心的原理其实就是 修改scss变量来进行实现主题色变化

明确好了原理之后,对后续实现的步骤就具体情况具体分析了。

对于 element-plus:因为 element-plus 是第三方的包,所以它 不是完全可控 的,那么对于这种最简单直白的方案,就是直接拿到它编译后的 css 进行色值替换,利用 style 内部样式表 优先级高于 外部样式表 的特性,来进行主题替换
对于自定义主题:因为自定义主题是 完全可控 的,所以我们实现起来就轻松很多,只需要修改对应的 scss变量即可
那么在之后大家遇到 自定义主题 的处理时,就可以按照我们所梳理的方案进行处理了。