Vue3+ElementUI动态换肤

2024-08-24 15:26 月霖 418

首先看一下最终实现的效果,选择一个颜色,系统中的主题色实时变更为所选颜色:

在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标签,覆盖掉原来的样式
  1. 获取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-xdark-2 就是之前我们给element-plus样式中的色值替换的字符串, color() 是css-color-function提供的方法,它会将参数中的一串字符串转换成对应的颜色,其中tint表示增加白色,其中的 primary tint(30%) 就是在primary这个颜色上混合30%的白色,这样颜色就变浅了,白色的百分比越高,颜色就越浅。shade表示增加黑色,也就意味着混合后颜色就变暗,百分比越高,颜色就越暗。这样我们就可以得到与element-plus类似的一组色值,稍后将用它们替换掉上一步中的字符串。

mainColorsubMenuActiveBg 是我们项目中自定义的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>