首先看一下最终实现的效果,选择一个颜色,系统中的主题色实时变更为所选颜色:
在scss中,可以通过 {变量名: 变量值 的方式定义css变量,然后可以通过该css变量来设置样式,所以我们用一个变量来代表主题色,项目中需要用到该主题色的地方都使用这个变量,当我们修改这个变量对应的颜色时,“换肤”就得以实现了。
项目中我们使用了element-plus这个UI框架,所以我们需要处理两个方面的主题色变更:项目中所使用的element-plus组件的主题色以及我们自己编写的依赖于主题色的样式。
综上,我们需要做几件事情:
- 定义css变量
- 实现一个选择主题色的组件
- 根据所选颜色修改element-plus主题色
- 根据所选颜色修改非element-plus样式的主题色
- 接着看一下具体的实现。
一、定义css变量
根据项目需要定义即可:
// variables.module.scss
{mainColor: #366AFF;
{mainFontColor: #4D4D4D;
//navBar
{navHeight: 56px; // 顶部导航栏高度
{navBg: #ffffff; // 导航栏背景
//sidebar
{sideBarWidth: 148px; // 侧边栏宽度
{sideBarFoldWidth: 64px; // 侧边栏折叠后的宽度
{sidebarBg: #FFFFFF; // 侧边栏背景
// menu
{menuItemHeight: 50px; // 侧边栏每一项菜单的高度
{menuActiveBg: #ECF0FE; // 侧边栏菜单选中时的背景色
{subMenuActiveBg: #f0f4ff; // 侧边栏子菜单选中时的背景色
{menuColor: {mainFontColor; // 侧边栏菜单文字颜色(未选中状态)
{menuActiveColor: {mainColor; // 侧边栏菜单文字颜色(选中状态)
// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
// JS 与 scss 共享变量,在 scss 中通过 :export 进行导出,在 js 中可通过 ESM 进行导入
:export {
mainColor: {mainColor;
mainFontColor: {mainFontColor;
navHeight: {navHeight;
navBg: {navBg;
sideBarWidth: {sideBarWidth;
sideBarFoldWidth: {sideBarFoldWidth;
sidebarBg: {sidebarBg;
menuItemHeight: {menuItemHeight;
menuActiveBg: {menuActiveBg;
subMenuActiveBg: {subMenuActiveBg;
menuColor: {menuColor;
menuActiveColor: {menuActiveColor;
}
这里,通过 :export 导出的变量可以在js或vue中导入使用,比如:
<template>
<div class="app-container">
<navbar
class="navbar-container"
:style="{ backgroundColor: variables.navBg }"
/>
<div class="main-container">
<sidebar
class="sidebar-container"
:style="{
backgroundColor: variables.sidebarBg,
width: appStore.sidebarCollapse
? variables.sideBarFoldWidth
: variables.sideBarWidth
}"
/>
<app-main class="content-container">
<router-view />
</app-main>
</div>
</div>
</template>
<script setup>
import Navbar from './components/Navbar.vue'
import Sidebar from './components/Sidebar'
import AppMain from './components/AppMain.vue'
import variables from '@/styles/variables.module.scss'
import { useAppStore } from '@/store/app'
const appStore = useAppStore()
</script>
这里的variables就是 :export 导出的变量,后续更换主题色时,我们就是要获取这些变量并对主题色相关的变量进行更新。
二、ThemeSelect组件
这个组件包含一个颜色拾取器,用来选择一个主题色,这里我们使用Pinia这个状态管理库来管理这个主题色。另外,我们需要将选择的颜色进行本地存储以供下一次打开页面时使用。
创建一个用来管理主题色的store:
// store/theme.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { getItem, setItem } from '@/utils/storage'
const MAIN_COLOR = 'main_color'
const DEFAULT_COLOR = '#366AFF'
export const useThemeStore = defineStore('theme', () => {
// 从localStorage中获取主题色,没有的话设置为默认颜色
const mainColor = ref(getItem(MAIN_COLOR) || DEFAULT_COLOR)
// 设置主题色,并存入localStorage中
function setMainColor(color) {
mainColor.value = color || DEFAULT_COLOR
setItem(MAIN_COLOR, color)
}
return { mainColor, setMainColor }
})
这里 getItem 和 setItem 是localStorage的存取方法:
// utils/storage
/**
* 存储数据
* @param key
* @param value
*/
export const setItem = (key, value) => {
if (typeof value === 'object') {
value = JSON.stringify(value)
}
window.localStorage.setItem(key, value)
}
/**
* 获取数据
* @param key
* @returns {any|string}
*/
export const getItem = (key) => {
const data = window.localStorage.getItem(key)
try {
return JSON.parse(data)
} catch (e) {
return data
}
}
/**
* 删除指定数据
* @param key
*/
export const removeItem = (key) => {
window.localStorage.removeItem(key)
}
/**
* 删除所有数据
*/
export const removeAllItems = () => {
window.localStorage.clear()
}
创建ThemeSelect组件:
<template>
<div class="theme-select">
<el-color-picker v-model="themeStore.mainColor" @change="onChangeColor" />
</div>
</template>
<script setup>
import { useThemeStore } from '@/store/theme'
const themeStore = useThemeStore()
const onChangeColor = async (color) => {
themeStore.setMainColor(color)
// TODO 替换主题色
}
</script>
<style scoped></style>
这个组件中只有一个element的拾色器,绑定了themeStore中保存的主题色,并且在修改颜色时调用其setMainColor方法。
接下来就要处理主题色的替换了。
三、修改element-plus主题色
处理element-plus的主题色稍稍有些麻烦,因为element-plus的样式中使用的是它自己定义的css变量,我们无法直接用我们定义的变量去进行替换,我们需要去修改element-plus中css变量的值,并且让这些修改后的值生效。具体可以这么做:
- 获取element-plus样式,找到其中需要修改的主题色相关的css变量
- 通过正则将这些需要修改的变量的值替换成我们选定的主题色
- 将修改后的样式写入style标签,覆盖掉原来的样式
- 获取element-plus样式
// utils/theme.js
import axios from 'axios'
/**
* 获取element-plus默认样式表
*/
const getOriginalStyle = async () => {
// https://unpkg.com/element-plus@2.7.8/dist/index.css
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)
}
/**
* 将需要替换的色值用对应的字符串替换
*/
const getStyleTemplate = (data) => {
// element-plus 默认色值
const colorMap = {
'#409eff': 'primary',
'#79bbff': 'light-3',
'#a0cfff': 'light-5',
'#c6e2ff': 'light-7',
'#d9ecff': 'light-8',
'#ecf5ff': 'light-9',
'#337ecc': 'dark-2'
}
Object.keys(colorMap).forEach((key) => {
const value = colorMap[key]
data = data.replace(new RegExp(key, 'ig'), value)
})
return data
}
这里我们用axios请求element的css文件,官网上可以查到这个文件的地址,我们通过requre获取到项目中安装的element-plus的版本,拼接出项目中对应的css文件地址,通过axios请求获取。在这个文件中可以找到一系列主题色相关的变量和色值:
我们需要将这些色值替换成我们选定的颜色,在getStyleTemplate方法中,我们定义了一个colorMap,将css样式中的这些色值先替换成对应的字符串light-x,稍后我们将基于选定的主题色生成这一系列的色值,再替换成实际的颜色值。
2.替换主题色
1)首先,我们需要基于选定的主题色生成一系列 light-x 以及 dark-x 的色值,我们可以从element官网上看到,这是基于主题色的一组不同深浅的颜色:
我们可以结合两个工具类来生成这样的一组颜色:
- rgb-hex:用于将RGBA颜色转换为十六进制(https://github.com/sindresorhus/rgb-hex)。
- css-color-function:用于解析和转换css中的颜色函数(https://github.com/ianstormtaylor/css-color-function)。
- 安装这两个工具类,然后创建一个用于颜色转换的文件formula.json:
{
"light-3": "color(primary tint(30%))",
"light-5": "color(primary tint(50%))",
"light-7": "color(primary tint(70%))",
"light-8": "color(primary tint(80%))",
"light-9": "color(primary tint(90%))",
"dark-2": "color(primary shade(20%))",
"mainColor": "color(primary)",
"subMenuActiveBg": "color(primary tint(95%))"
}
这里的 light-x 和 dark-2 就是之前我们给element-plus样式中的色值替换的字符串, color() 是css-color-function提供的方法,它会将参数中的一串字符串转换成对应的颜色,其中tint表示增加白色,其中的 primary tint(30%) 就是在primary这个颜色上混合30%的白色,这样颜色就变浅了,白色的百分比越高,颜色就越浅。shade表示增加黑色,也就意味着混合后颜色就变暗,百分比越高,颜色就越暗。这样我们就可以得到与element-plus类似的一组色值,稍后将用它们替换掉上一步中的字符串。
而 mainColor 和 subMenuActiveBg 是我们项目中自定义的css变量,可以根据自己的需要来处理。
2)现在我们就基于这个json文件生成对应的颜色:
// utils/theme.js
import rgbHex from 'rgb-hex'
import color from 'css-color-function'
import formula from '@/utils/formula.json'
/**
* 生成色值
* @param primary 主题色
* @returns {{primary: *}}
*/
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
}
这里就是把json中的primary替换成我们传入的主题色,然后调用color.convert进行转换,这个方法返回的是RGB的颜色,再调用rgbHex将其转为十六进制值,最终得到的就是这样的一个对象:
这里就是把json中的primary替换成我们传入的主题色,然后调用color.convert进行转换,这个方法返回的是RGB的颜色,再调用rgbHex将其转为十六进制值,最终得到的就是这样的一个对象:
{
"primary": "#FF365E",
"light-3": "#ff728e",
"light-5": "#ff9baf",
"light-7": "#ffc3cf",
"light-8": "#ffd7df",
"light-9": "#ffebef",
"dark-2": "#cc2b4b",
"mainColor": "#ff365e",
"subMenuActiveBg": "#fff5f7"
}
3)然后我们就可以替换element-plus的样式了:
// utils/theme.js
/**
* 根据主题色生成样式表
* @param color
*/
export const generateNewStyle = async (color) => {
// 获取当前element-plus的默认样式表,将需要进行替换的色值替换成对应字符串
let cssText = await getOriginalStyle()
// 根据主色生成色值表
const colors = generateColors(color)
// 遍历生成的色值表,对默认样式表进行全局替换
Object.keys(colors).forEach((key) => {
cssText = cssText.replace(
new RegExp('(:|s+)' + key, 'g'),
'{1' + colors[key]
)
})
})
return cssText
}
这里我们调用了之前定义的那两个方法,获取替换了字符串的样式表,生成一系列色值,使用正则将样式表中的字符串替换成对应的色值。这样,element-plus主题色的替换就完成了。
3.将修改后的样式写入style标签
最后,我们需要将这个修改过的样式表写入style标签,从而覆盖掉element-plus原本的样式表,这样新的色值就可以生效了:
// utils/theme.js
/**
* 将生成的样式表写入style
* @param cssText
*/
export const writeNewStyle = (cssText) => {
// 获取element-plus的样式的style标签
let styleDom = document.getElementById('element-plus-style')
if (styleDom) {
// 如果已经存在element-plus的样式,则直接修改样式
styleDom.innerText = cssText
return
}
// 如果是第一次添加,则新创建一个style标签
styleDom = document.createElement('style')
// 设置属性
styleDom.setAttribute('type', 'text/css')
styleDom.setAttribute('id', 'element-plus-style')
styleDom.innerText = cssText
// 添加到head中
document.head.appendChild(styleDom)
}
最后,在ThemeSelect组件中,选择颜色时调用相应的方法:
<template>
<div class="theme-select">
<el-color-picker v-model="themeStore.mainColor" @change="onChangeColor" />
</div>
</template>
<script setup>
import { useThemeStore } from '@/store/theme'
import { generateNewStyle, writeNewStyle } from '@/utils/theme'
const themeStore = useThemeStore()
const onChangeColor = async (color) => {
themeStore.setMainColor(color)
// 替换主题色
const cssText = await generateNewStyle(themeStore.mainColor)
writeNewStyle(cssText)
}
</script>
<style scoped></style>
至此,element-plus的样式就处理完了,接着还需要处理我们自定义变量的颜色。
四、修改非element-plus主题色
处理自定义变量就比较容易了,前面已经说过,在scss中通过:export导出的变量可以在js中使用,那么我们只要获取到这些变量,赋予新的颜色值,然后在代码中使用这些变量就可以了。
我们在themeStore中使用一个计算属性来返回这些变量:
// store/theme.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { getItem, setItem } from '@/utils/storage'
import variables from '@/styles/variables.module.scss'
import { generateColors } from '@/utils/theme'
const MAIN_COLOR = 'main_color'
const DEFAULT_COLOR = '#366AFF'
export const useThemeStore = defineStore('theme', () => {
// 从localStorage中获取主题色,没有的话设置为默认颜色
const mainColor = ref(getItem(MAIN_COLOR) || DEFAULT_COLOR)
// 设置主题色,并存入localStorage中
function setMainColor(color) {
mainColor.value = color || DEFAULT_COLOR
setItem(MAIN_COLOR, color)
}
const cssVar = computed(() => {
return {
...variables,
...generateColors(mainColor.value)
}
})
return { mainColor, setMainColor, cssVar }
})
这里,我们导入了variables.module.scss中的变量,调用之前定义的generateColors方法生成色值,还记得这个方法是基于formula.json这个文件来进行色值转换的吧,formula.json里定义了mainColor和subMenuActiveBg,上面代码中的cssVar通过展开运算符将新生成的这两个字段的值覆盖了原来variables.module.scss中定义的值。而且由于它是个计算属性,当它依赖mainColor发生改变时,它会立即重新调用,所以,只要我们代码里使用cssVar中的字段,就可以在主题色变更时立刻得到更新了:
<template>
<div>
<h2 :style="{ color: themeStore.cssVar.mainColor }">自定义主题</h2>
<el-button type="primary">Button</el-button>
<el-button type="primary" plain>Button-Plain</el-button>
</div>
</template>
<script setup>
import { useThemeStore } from '@/store/theme'
const themeStore = useThemeStore()
</script>
<style scoped>
</style>