vue3 admin 保姆教学指南|element-plus如何实现主题切换和暗黑模式
效果展示
在开始之前,我们先来看看效果图
暗黑模式:
主题切换:
系统设置弹窗
由于我们的弹窗组件和设置按钮是夸层级组件,所以我们的emit事件就不能按照vue的传统方式来写了。这里我们借助mitt
来进行事件通信。
在src/utils
下新建mittBus.ts
,对它做个简单的封装,方便我们使用:
import mitt from 'mitt'
const mittBus = mitt()
export default mittBus
在src/layouts/NavBar/components/Settings/index.vue
中,我们通过mittBus.emit('openThemeDrawer')
打开弹窗:
<template>
<div class="btn">
<el-tooltip effect="dark" content="系统设置">
<el-button circle @click="onSetting">
<IconifyIcon icon="ep:setting" height="16" />
</el-button>
</el-tooltip>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import mittBus from '@/utils/mittBus'
export default defineComponent({
setup() {
const onSetting = () => {
// 采用事件监听的方式打开ThemeDrawer
mittBus.emit('openThemeDrawer')
}
return { onSetting }
},
})
</script>
<style scoped lang="scss">
.btn {
margin-right: 20px;
cursor: pointer;
transition: all 0.3s;
}
</style>
在src/layouts/index.vue
中我们引入ThemeDrawer
组件,这个就是设置主题和暗黑模式的弹窗。在ThemeDrawer
组件中,监听openThemeDrawer
事件的触发,然后设置drawerVisible
的值:
<template>
<el-drawer title="主题设置" v-model="drawerVisible" size="300px">
</el-drawer>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import mittBus from '@/utils/mittBus'
// 打开主题设置
const drawerVisible = ref(false)
mittBus.on('openThemeDrawer', () => {
drawerVisible.value = true
})
</script>
这样就实现了跨组件的事件通信。下面我们先来实现暗黑模式。
实现暗黑模式
首先,这个操作属于系统操作,我们需要把操作的数据保存在pinia中去。
在src/store/modules/settings.ts
中,创建一个useSettingsStore
import { defineStore } from 'pinia'
import { DEFAULT_PRIMARY } from '@/config/config'
import type { SettingsState, ThemeConfigProps } from './model/settingsModel'
export const useSettingsStore = defineStore({
id: 'app-settings',
state: (): SettingsState => ({
collapse: false,
refresh: false, // 刷新页面
themeConfig: {
primary: DEFAULT_PRIMARY,
isDark: false,
},
}),
actions: {
changeCollapse() {
this.collapse = !this.collapse
},
setRefresh() {
this.refresh = !this.refresh
},
setThemeConfig(themeConfig: ThemeConfigProps) {
this.themeConfig = themeConfig
},
},
persist: true,
})
其中,themeConfig
就是我们的系统设置需要的字段,这里我们把主题切换也放在一块了。暗黑模式通过isDark
字段来控制。
在ThemeDrawer
组件中,我们引入了一个SwitchDark
组件,用来控制暗黑模式的开启关闭。在SwitchDark
中,是这样实现的:
<template>
<el-switch
v-model="themeConfig.isDark"
@change="onAddDarkChange"
inline-prompt
:active-icon="Sunny"
:inactive-icon="Moon"
/>
</template>
<script setup lang="ts" name="SwitchDark">
import { computed } from 'vue'
import { useSettingsStore } from '@/store/modules/settings'
import { Sunny, Moon } from '@element-plus/icons-vue'
import { useTheme } from '@/hooks/useTheme'
const settingsStore = useSettingsStore()
const { switchDark } = useTheme()
const themeConfig = computed(() => settingsStore.themeConfig)
const onAddDarkChange = () => {
switchDark()
}
</script>
可以看到,这里有个切换的时候调用了switchDark
这个方法,这里其实是个hooks的封装。我们把主题切换和暗黑模式的切换都统一封装到了useTheme
这个hooks里面。
在useTheme
里面这样实现:
import { computed } from 'vue'
import { useSettingsStore } from '@/store/modules/settings'
import { DEFAULT_PRIMARY } from '../config/config'
import { ElMessage } from 'element-plus'
import { getLightColor, getDarkColor } from '@/utils/color'
export const useTheme = () => {
const settingsStore = useSettingsStore()
const themeConfig = computed(() => settingsStore.themeConfig)
// 切换暗黑模式
const switchDark = () => {
const body = document.documentElement as HTMLElement
if (themeConfig.value.isDark) body.setAttribute('class', 'dark')
else body.setAttribute('class', '')
}
// 初始化主题
const initTheme = () => {
switchDark()
}
return {
initTheme,
switchDark,
}
}
可以看到,当我们开启关闭暗黑模式的时候,其实就是添加了一个class属性dark
,这个dark是加在了html
元素上面,不要被上面的body
误导了,因为document.documentElement
获取的是html
元素。在控制台也能看到我们dark
是加在了html
元素上面的:
那么既然有了这个dark,我们是不是可以通过另外一套暗黑模式的样式,覆盖之前的样式呢?事实上,element-plus
也是这样做的,它给我们提供了一套完整的暗黑模式的主题色。具体可参考Element-plus暗黑模式介绍。
那么接下来我们就需要在项目中引入这套主题。
在src/styles
下面新建文件theme.scss
,这里面我们把element-plus的默认暗黑主题引入进来
/** element内置黑暗主题 */
@use 'element-plus/theme-chalk/src/dark/css-vars.scss' as *;
然后把theme.scss
在src/styles/index.scss
文件中引入即可:
@import './reset';
@import './theme';
@import './element';
这里注意我还引入了样式重置和element的重置样式。
最后我们把index.scss
在main.ts
中引入即可。
// element默认主题
import 'element-plus/dist/index.css'
// 公共样式,包含自定义暗黑模式,element重置样式
import '@/styles/index.scss'
这里注意element的默认主题和index.scss
的位置。如果不出意外,你页面现在的样式就可以使用暗黑模式了。但是还有一些页面你自定义的页面元素样式,比如你把某个背景色写死了,或者某个文字颜色写死了,当你切换了暗黑模式的时候,它在element-plus中找不到暗黑模式的样式,那怎么办呢?当然是自己写一份自定义的暗黑模式主题了。同样我们在theme.scss
中书写样式:
/** element内置黑暗主题 */
@use 'element-plus/theme-chalk/src/dark/css-vars.scss' as *;
/** 自定义黑暗主题 */
html.dark {
// * admin
--v-bg-color: #141414;
--v-main-bg-color: #0d0d0d;
--v-border-light: 1px solid #4c4c4d;
// scroll-bar
::-webkit-scrollbar {
background-color: var(--el-scrollbar-bg-color) !important;
}
::-webkit-scrollbar-thumb {
background-color: var(--el-border-color-darker) !important;
}
.layout-sidebar-container {
background-color: var(--v-bg-color) !important;
}
.app-main-container {
background-color: var(--v-main-bg-color) !important;
.card {
background-color: var(--v-bg-color) !important;
}
}
.layout-footer-container {
color: var(--el-text-color-regular) !important;
background-color: var(--v-main-bg-color) !important;
}
.fold-unfold {
color: #fff !important;
}
.el-menu,
.el-sub-menu,
.el-menu-item,
.el-sub-menu__title {
background-color: var(--v-bg-color) !important;
&:not(.is-active) {
color: #bdbdc0 !important;
}
&.is-active {
color: #fff !important;
background-color: #000 !important;
}
}
.el-menu-item:not(.is-active):hover {
background-color: var(--v-main-bg-color) !important;
}
.nav-bar-container {
background-color: var(--v-bg-color) !important;
}
.table-header {
.el-icon {
color: #fff;
}
}
.tabs-bar-container {
background-color: var(--v-bg-color) !important;
border-top: 1px solid var(--el-border-color-light) !important;
.tabs-action {
.el-icon {
color: #fff !important;
}
}
.fold-unfold {
color: #fff;
}
.el-tabs__item.is-active {
color: var(--el-color-primary);
background-color: var(--v-main-bg-color) !important;
.el-icon {
color: var(--el-color-primary) !important;
}
}
.el-tabs__item:not(.is_active):hover {
background-color: var(--v-main-bg-color) !important;
.el-icon {
color: var(--el-color-primary) !important;
}
}
}
}
上面是我自定义的暗黑模式下的一些主题样式,注意,所有的样式必须写在html.dark
中,如果样式权重不够的直接加个!important
。到此,我们的暗黑模式就实现了。我们接着来实现主题的切换。
实现主题切换
首先我们来看看主题切换实现的思路。在element-plus中,有一套基础样式的css变量:
--el-color-primary: #409eff;
它的默认主题都是基于这个变量去做的。比如el-button
的默认主题色是这样设置的:
那么我们就是不是只要修改--el-color-primary
的值就可以了?思路就是这么简单。接下来我们来设置一下这个值。
element-plus为我们提供了一个el-color-picker
组件,利用这个组件我们随意的更改颜色。在src/layouts/NavBar/components/ThemeDrawer/index.vue
文件中,我们利用这个组件切换--el-color-primary
的值:
<template>
<el-drawer title="主题设置" v-model="drawerVisible" size="300px">
<el-divider class="divider" content-position="center">全局主题</el-divider>
<div class="theme-item">
<span>主题颜色</span>
<el-color-picker
v-model="themeConfig.primary"
:predefine="colorList"
@change="changePrimary"
/>
</div>
<div class="theme-item">
<span>暗黑模式</span>
<SwitchDark />
</div>
</el-drawer>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import mittBus from '@/utils/mittBus'
import { DEFAULT_PRIMARY } from '@/config/config'
import { useSettingsStore } from '@/store/modules/settings'
import { useTheme } from '@/hooks/useTheme'
const { changePrimary } = useTheme()
// 预定义主题颜色
const colorList = [
DEFAULT_PRIMARY,
'#DAA96E',
'#0C819F',
'#722ed1',
'#27ae60',
'#ff5c93',
'#e74c3c',
'#fd726d',
'#f39c12',
'#9b59b6',
]
const settingsStore = useSettingsStore()
const themeConfig = computed(() => settingsStore.themeConfig)
// 打开主题设置
const drawerVisible = ref(false)
mittBus.on('openThemeDrawer', () => {
drawerVisible.value = true
})
</script>
<style scoped lang="scss">
.theme-item {
display: flex;
align-items: center;
justify-content: space-between;
margin: 14px 0;
}
</style>
这里我们提供一套默认的主题色colorList
,样子长下面这样:
发生change事件的时候,我们就能拿到修改的颜色。然后通过changePrimary
这个方法去修改--el-color-primary
的值。
同样的,这个方法我们封装在useTheme
这个hooks当中了。
import { computed } from 'vue'
import { useSettingsStore } from '@/store/modules/settings'
import { DEFAULT_PRIMARY } from '../config/config'
import { ElMessage } from 'element-plus'
export const useTheme = () => {
const settingsStore = useSettingsStore()
const themeConfig = computed(() => settingsStore.themeConfig)
// 切换暗黑模式
const switchDark = () => {
const body = document.documentElement as HTMLElement
if (themeConfig.value.isDark) body.setAttribute('class', 'dark')
else body.setAttribute('class', '')
changePrimary(themeConfig.value.primary)
}
// 修改主题颜色
const changePrimary = (val: string | null) => {
if (!val) {
val = DEFAULT_PRIMARY
ElMessage({
type: 'success',
message: `主题颜色已重置为 ${DEFAULT_PRIMARY}`,
})
}
settingsStore.setThemeConfig({ ...themeConfig.value, primary: val })
document.documentElement.style.setProperty(
'--el-color-primary',
themeConfig.value.primary,
)
}
// 初始化主题
const initTheme = () => {
switchDark()
changePrimary(themeConfig.value.primary)
}
return {
initTheme,
switchDark,
changePrimary,
}
}
当触发changePrimary
事件修改颜色的时候,拿到传过来的val值(十六进制颜色值),第一步判断如果是空值,就设置为默认的主题色DEFAULT_PRIMARY
,第二步,val不为空,把这个主题色存储到pinia
中。第三步,通过setProperty
修改--el-color-primary
的值。现在我们去页面看看效果。
当我们修改主题的时候已经能够改变默认颜色了:
但是发现一个问题,当我们鼠标滑过按钮的时候,它应该会有一个颜色变浅的样式出来,发现跟我们的主题色不对付啊:
这是为什么呢?原来当鼠标滑过的时候,它又是另一套主题:
从button
的样式也可以看出来,它在各种状态下使用css变量是不一样的:
而我们只设置了--el-color-primary
的值:
那我们还需要设置--el-color-primary-light-{1-9}
的值。
在changePrimary
方法中,我们用一个循环来设置--el-color-primary-light-{1-9}
的值:
// 颜色加深或变浅
for (let i = 1; i <= 9; i++) {
document.documentElement.style.setProperty(
`--el-color-primary-light-${i}`,
themeConfig.value.isDark
? `${getDarkColor(themeConfig.value.primary, i / 10)}`
: `${getLightColor(themeConfig.value.primary, i / 10)}`,
)
}
同样的,也是利用setProperty
来设置--el-color-primary-light-{1-9}
的值,这里暗黑模式下和非暗黑模式下计算方式有所区别。
接下来,最重要的就是怎么计算--el-color-primary-light-{1-9}
每个值的颜色了,因为它是十六进制的。
我们先看看getLightColor
函数做了什么事情。
getLightColor
函数封装在了src/utils/color.ts
这个文件下面:
export function getLightColor(color: string, level: number) {
const reg = /^#?[0-9A-Fa-f]{6}$/
if (!reg.test(color)) return ElMessage.warning('输入错误的hex颜色值')
const rgb = hexToRgb(color)
for (let i = 0; i < 3; i++)
rgb[i] = Math.round(255 * level + rgb[i] * (1 - level))
return rgbToHex(rgb[0], rgb[1], rgb[2])
}
它接受两个值color, level
,分别表示16进制和等级,我们打印一下就知道了:
可以看到这个值我们选择的颜色值:
在getLightColor
函数中,首先会判断color
值是否合法,也就是是不是十六进制的。
然后会调用hexToRgb
函数,我们再看看这个函数干了啥?
在hexToRgb
函数中,接受的是我们传过来的color值,也就是上面我们看到的#409EFF
。
export function hexToRgb(str: any) {
let hexs: any = ''
const reg = /^#?[0-9A-Fa-f]{6}$/
if (!reg.test(str)) return ElMessage.warning('输入错误的hex')
str = str.replace('#', '')
hexs = str.match(/../g)
for (let i = 0; i < 3; i++) hexs[i] = parseInt(hexs[i], 16)
return hexs
}
同样的,它也会校验是不是一个十六进制的值。然后通过repalce
把#
去掉,str的值就会变成409EFF
了。下面一行会把这个十六进制按照两位分割成一个数组:
hexs = str.match(/../g)
解释一下这行代码的意思:通过match匹配任意两个字符最终返回一个数组,看下面执行结果:
hexs
最终的结果为这样的:
接来来会循环这个数组,然后把里面的每一项都变成十进制的整数。
for (let i = 0; i < 3; i++) hexs[i] = parseInt(hexs[i], 16)
这里解释一下parseInt
这个方法,它的作用是解析一个字符串并返回指定基数的十进制整数,比如说parseInt('9E', 16)
的意思就是把16进制的9E
转化成一个十进制的整数。
这样,经过parseInt
转化,hexs
的值就变成下面这样了:
经过hexToRgb
函数一顿操作,最终我们把十六进制的409EFF
转化成了十进制的数组[64, 158, 255]
。
我们回到getLightColor
函数继续往下看。rgb
现在的值是[64, 158, 255]
,然后经过下面循环处理:
for (let i = 0; i < 3; i++) {
rgb[i] = Math.round(255 * level + rgb[i] * (1 - level))
}
rgb
就会变成这个样子了:
我们拆开来看,
rgb[0] = Math.round(255 * 0.1 + 64 * (1 - 0.1)) = 83
rgb[1] = Math.round(255 * 0.1 + 158 * (1 - 0.1)) = 168
rgb[2] = Math.round(255 * 0.1 + 255 * (1 - 0.1)) = 255
刚好跟我打印的结果是一一对应的。其中的Math.round() 函数返回一个数字四舍五入后最接近的整数。
同样的,当level=0.2~0.9
的时候,得到的结果是这样的:
rgb[2] = [102, 177, 255]
rgb[3] = [121, 187, 255]
rgb[4] = [140, 197, 255]
rgb[5] = [160, 207, 255]
rgb[6] = [179, 216, 255]
rgb[7] = [198, 226, 255]
rgb[8] = [217, 236, 255]
rgb[9] = [236, 245, 255]
经过这么一计算,得到一组其实由深到浅的色系。
得到这个结果以后,再次经过rgbToHex
函数进行一个转化,我们继续看看rgbToHex
干了啥?
export function rgbToHex(r: any, g: any, b: any) {
const reg = /^\d{1,3}$/
if (!reg.test(r) || !reg.test(g) || !reg.test(b))
return ElMessage.warning('输入错误的rgb颜色值')
const hexs = [r.toString(16), g.toString(16), b.toString(16)]
for (let i = 0; i < 3; i++) if (hexs[i].length == 1) hexs[i] = `0${hexs[i]}`
return `#${hexs.join('')}`
}
在rgbToHex
函数中,接受三个参数: r,g,b
,其实就是红、绿、蓝
三色。首先同样的会检测颜色值是否合法。然后通过toString
方法把十进制的数字转化成十六进制的字符串。
注意:这里toString
方法其实是Number.prototype.toString([radix])
,它会回指定Number对象的字符串表示形式,其中adix指定要用于数字到字符串的转换的进制数 (从 2 到 36)。如果未指定 radix 参数,则默认值为 10。
我们上面的传进来的83,168,255
经过转化以后,得到的是一个驻足hexs=['53', 'a8', 'ff']
。
继续往下执行代码:
for (let i = 0; i < 3; i++) if (hexs[i].length == 1) hexs[i] = `0${hexs[i]}`
这一步是为了防止有一位数的情况出现,如果只有一位,前面用0
补上。
最后return得时候把这个hexs数组转化成了六位数十六进制的格式,也就是#53a8ff
,最终我们回到了src/hooks/useTheme.ts
这个文件来,经过第一次循环,getLightColor(themeConfig.value.primary, 1 / 10)
得到的值是#53a8ff
,也就是说,--el-color-primary-light-1
的值现在是#53a8ff
。同样的,执行了9次循环,后面--el-color-primary-{2-9}
的值都会得到对应的十六进制六位数颜色值。最终在控制台看到的效果就是这样的:
可以看到,颜色值是越来越浅。这其实也是根据颜色系统的设置来的,算是一个颜色系列。
最后回顾一下整个计算过程:
- 先拿到十六进制的六位数颜色值
- 通过
hexToRgb
函数转化成rgb十进制数组 - 通过
Math.round
和level
四舍五入计算得到新的一组rgb十进制数组(这里也是计算最为关键的地方) - 最后,通过
rgbToHex
函数把十进制的rgb数组转化成一个十六进制的六位数颜色值 - 把这个颜色赋值给
--el-color-primary-light-{1-9}
最后还有暗黑模式下主题计算方式,其实就是在
for (let i = 0; i < 3; i++) {
rgb[i] = Math.round(20.5 * level + rgb[i] * (1 - level))
}
这一步,计算的时候把255换成了20.5,色系偏暗了一点。其他的逻辑都是一模一样的。最终得到的一套色系是这样的:
这里给大家推荐一个字节配色工具
,可以在上面设置一个主题色,就可以得到自己喜欢的色系了
到此,我们的暗黑模式跟主题切换的功能实现完毕。
代码地址
文章教程系列
- vue3 admin 保姆教学指南 | 项目规范集成教程,看完秒懂项目中各种奇怪的文件和配置
- vue3 admin 保姆教学指南 | 一文让你彻底上手 vue3 全家桶,集成 pinia+element-plus+vue-router@4
- vue3 admin 保姆教学指南|关于使用 typescript 二次封装 Axios 的特别说明
- vue3 admin 保姆教学指南|关于 pinia 的使用
- vue3 admin 保姆教学指南|登录和菜单权限的实现
- vue3 admin 保姆教学指南|后台管理系统的 Layout 实现
- vue3 admin 开发中的奇淫巧技|在 vue 中如何刷新当前页面
转载自:https://juejin.cn/post/7215485221830852665