一篇文章实现 Element Plus 动态切换主题色

前言

本篇文章主要讲解如何实现 Element Plus 主题色的动态切换

说点关于专栏的话

距离最后一篇专栏文章:《彻底搞懂面包屑,手把手封装一个 Vue3 面包屑导航组件》发布,已经有半年时间了

说句实话,专栏是打算写到此为止的,一是当时快过年了,心思不在,第二就是一边在写 Clean Admin,一边还要写文章,像前面有几篇文章,动不动就是 5 千字、7 千字的,两头顾实在耗费精力

但是,当你看到我这篇文章时,说明我已经打算继续写点内容了

在不久前,我习惯性在掘金翻阅文章和我的主页,注意起专栏的阅读数据一直在极缓慢的升高,专栏订阅数也有 40+ 人(虽然不算多,但至少还能证明有读者愿意读)

我想,可能这些文章对于读者还是有一点点帮助的,哪怕只是一点点,这就是我想继续写下去的理由

本文也是《通俗易懂的中后台系统建设指南》系列的第八篇文章,该系列旨在告诉你如何来构建一个优秀的中后台管理系统

了解 Element Plus 主题自定义

打开 Element Plus 官网,在进阶部分有一篇自定义主题的文章,文章介绍了几种定制主题色彩的方案,大致分为:

  • 通过 SCSS 变量,新建一个样式文件来覆盖默认样式
  • 通过 CSS 变量,修改以 --el- 开头的 CSS 变量,通过 CSS 变量覆盖或通过 JS 修改

第一种 SCSS 变量方案,适用于相对固定场景下的色彩定制需求,有 Scss 预编译的加持,定制起来也方便

第二种方案很好,通过 CSS 变量就可以覆盖默认样式,但还不够好,比如你想要修改 Element Plus 默认的主题色调 #409eff,Css 变量名是 --el-color-primary,但是这只修改了一个变量值,并不会生成相对应的色阶值

所以,仅仅修改主题色还不够,至少现在还不够,这里推荐你看看 ELement Plus 讨论区的一篇内容:关于运行时多主题切换的探讨与实现

Element Plus 内部 SCSS 函数会计算出一套主题色阶,供系统全局使用

  • --el-color-primary
  • --el-color-primary-light-3
  • --el-color-primary-light-5
  • --el-color-primary-light-7
  • --el-color-primary-light-8
  • --el-color-primary-light-9
  • --el-color-primary-dark-2

我们需要全部覆盖,才能完全实现主题色的切换

动态切换 Element Plus 主题实现

上面简单介绍了一下 Element Plus 的自定义主题,这节来聊一下具体实现方案

在目前很多开源的后台系统中,实现动态主题,大致的方案就是借助 JS 来写一套颜色算法函数(参考 Element Plus Scss 色值逻辑),从而取得各色阶的颜色,再用 JS 控制在根元素上覆盖原有的 Css 变量值来达到动态的效果

Element Plus 自定义主题文章在底部,也给出了 JS 控制 Css 变量的示例

ts 复制代码
// document.documentElement 是全局变量时
const el = document.documentElement
// const el = document.getElementById('xxx')

// 获取 css 变量
getComputedStyle(el).getPropertyValue(`--el-color-primary`)

// 设置 css 变量
el.style.setProperty('--el-color-primary', 'red')

本文也将基于上述方案通过 JS 控制来实现

前期准备

Clean Admin 在根目录下新建了一个 theme 文件夹,表示主题相关的内容

颜色算法函数

上面提到,Element Plus 的颜色算法是通过 SCSS 函数计算得来的,我们现在要做的,就是用 JS 来重写这套函数的色值计算逻辑

也别担心,这里的颜色算法函数不需要你去手写,在各大知名开源后台中的 utils 文件夹下一般都可以找到,实现逻辑大差不差

Clean Admin 的色阶计算函数取自于 Art Design Pro,因为这是最近我觉得比较美观的后台系统,色彩设计不错

theme 文件夹下新建一个 helpers.ts 文件,写入以下内容:

如果你想看 Clean Admin 关于这里的源码:点击查看 - helpers.ts

ts 复制代码
/**
 * 颜色转换结果接口
 */
interface RgbaResult {
  red: number;
  green: number;
  blue: number;
  rgba: string;
}

/**
 * 验证hex颜色格式
 * @param hex hex颜色值
 * @returns 是否为有效的hex颜色
 */
function isHexColor(hex: string): boolean {
  const cleanHex = hex.trim().replace(/^#/, '');
  return /^[0-9A-F]{3}$|^[0-9A-F]{6}$/i.test(cleanHex);
}

/**
 * 验证RGB颜色值
 * @param r 红色值
 * @param g 绿色值
 * @param b 蓝色值
 * @returns 是否为有效的RGB值
 */
function isRgbValue(r: number, g: number, b: number): boolean {
  const isValid = (value: number) => Number.isInteger(value) && value >= 0 && value <= 255;
  return isValid(r) && isValid(g) && isValid(b);
}

/**
 * 将hex颜色转换为RGBA
 * @param hex hex颜色值 (支持 #FFF 或 #FFFFFF 格式)
 * @param opacity 透明度 (0-1)
 * @returns 包含RGB值和RGBA字符串的对象
 */
export function hexToRgba(hex: string, opacity: number): RgbaResult {
  if (!isHexColor(hex)) {
    throw new Error('Invalid hex color format');
  }

  // 移除可能存在的 # 前缀并转换为大写
  let cleanHex = hex.trim().replace(/^#/, '').toUpperCase();

  // 如果是缩写形式(如 FFF),转换为完整形式
  if (cleanHex.length === 3) {
    cleanHex = cleanHex
      .split('')
      .map((char) => char.repeat(2))
      .join('');
  }

  // 解析 RGB 值
  const [red, green, blue] = cleanHex.match(/\w\w/g)!.map((x) => parseInt(x, 16));

  // 确保 opacity 在有效范围内
  const validOpacity = Math.max(0, Math.min(1, opacity));

  // 构建 RGBA 字符串
  const rgba = `rgba(${red}, ${green}, ${blue}, ${validOpacity.toFixed(2)})`;

  return { red, green, blue, rgba };
}

/**
 * 将hex颜色转换为RGB数组
 * @param hexColor hex颜色值
 * @returns RGB数组 [r, g, b]
 */
export function hexToRgb(hexColor: string): number[] {
  if (!isHexColor(hexColor)) {
    throw new Error('Invalid hex color format');
  }

  const cleanHex = hexColor.replace(/^#/, '');
  let hex = cleanHex;

  // 处理缩写形式
  if (hex.length === 3) {
    hex = hex
      .split('')
      .map((char) => char.repeat(2))
      .join('');
  }

  const hexPairs = hex.match(/../g);
  if (!hexPairs) {
    throw new Error('Invalid hex color format');
  }

  return hexPairs.map((hexPair) => parseInt(hexPair, 16));
}

/**
 * 将RGB颜色转换为hex
 * @param r 红色值 (0-255)
 * @param g 绿色值 (0-255)
 * @param b 蓝色值 (0-255)
 * @returns hex颜色值
 */
export function rgbToHex(r: number, g: number, b: number): string {
  if (!isRgbValue(r, g, b)) {
    throw new Error('Invalid RGB color values');
  }

  const toHex = (value: number) => {
    const hex = value.toString(16);
    return hex.length === 1 ? `0${hex}` : hex;
  };

  return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}

/**
 * 颜色混合
 * @param color1 第一个颜色
 * @param color2 第二个颜色
 * @param ratio 混合比例 (0-1)
 * @returns 混合后的颜色
 */
export function colourBlend(color1: string, color2: string, ratio: number): string {
  const validRatio = Math.max(0, Math.min(1, Number(ratio)));

  const rgb1 = hexToRgb(color1);
  const rgb2 = hexToRgb(color2);

  const blendedRgb = rgb1.map((value1, index) => {
    const value2 = rgb2[index];
    return Math.round(value1 * (1 - validRatio) + value2 * validRatio);
  });

  return rgbToHex(blendedRgb[0], blendedRgb[1], blendedRgb[2]);
}

/**
 * 获取变浅的颜色
 * @param color 原始颜色
 * @param level 变浅程度 (0-1)
 * @param isDark 是否为暗色主题
 * @returns 变浅后的颜色
 */
export function getLightColor(color: string, level: number, isDark: boolean = false): string {
  if (!isHexColor(color)) {
    throw new Error('Invalid hex color format');
  }

  if (isDark) {
    return getDarkColor(color, level);
  }

  const rgb = hexToRgb(color);
  const lightRgb = rgb.map((value) => Math.floor((255 - value) * level + value));

  return rgbToHex(lightRgb[0], lightRgb[1], lightRgb[2]);
}

/**
 * 获取变深的颜色
 * @param color 原始颜色
 * @param level 变深程度 (0-1)
 * @returns 变深后的颜色
 */
export function getDarkColor(color: string, level: number): string {
  if (!isHexColor(color)) {
    throw new Error('Invalid hex color format');
  }

  const rgb = hexToRgb(color);
  const darkRgb = rgb.map((value) => Math.floor(value * (1 - level)));

  return rgbToHex(darkRgb[0], darkRgb[1], darkRgb[2]);
}

/**
 * 设置 Css 变量,在根节点上(html)
 * @param property 属性名
 * @param value 属性值
 * @param priority 优先级
 */
export function setHtmlProperty(property: string, value: string | null, priority?: string) {
  const style = document.documentElement.style;
  style.setProperty(property, value, priority);
}

处理 Element Plus 主题颜色的函数

如下所示,Element Plus 的色阶一般只分 3,5,7,8,9 这五个梯值,只修改这几个值即可完整覆盖主题色

但我认为还不够,这里打算给出更多的色阶变量供选择,比如 1 ~ 9

新建一个 constants.ts 文件,写入一个主题色色阶权重常量,表示我们需要生成 1 ~ 9 的色阶值

点击查看:Clean Admin - constants.ts 代码

ts 复制代码
/** Element Plus 主题色权重 */
const EL_PRIMARY_COLOR_WEIGHT: ColorWeight = [100, 200, 300, 400, 500, 600, 700, 800, 900];

准备工作做完了,我们可以开始处理 Element Plus 主题颜色了

新建一个 element.ts 文件,表示处理 Element Plus 主题色的函数,定义一个 setElementPrimaryColor 函数:

点击查看:Clean Admin - element.ts 代码

ts 复制代码
import { getDarkColor, getLightColor, setHtmlProperty } from './color';

/**
 * 处理 Element Plus 主题颜色
 * @param color 颜色值
 * @param isDark 是否暗黑模式
 */
export function setElementPrimaryColor(color: string, isDark: boolean = false): void {
  setHtmlProperty('--el-color-primary', color);
  setHtmlProperty(`--el-color-primary-dark-2`, getDarkColor(color, 0.2));

  for (const weight of EL_PRIMARY_COLOR_WEIGHT) {
    setHtmlProperty(
      `--el-color-primary-light-${weight / 100}`,
      getLightColor(color, weight / 1000, isDark),
    );
  }
}

在这个函数中,我们根据传入的主题颜色,首先修改 --el-color-primary 变量和 --el-color-primary-dark-2 变量,这是 Element Plus 关于主题色需要的变量

根据常量 EL_PRIMARY_COLOR_WEIGHT 中定义的梯度色阶,也就是 1 ~ 9 循环计算出对应的色阶值,并使用 setHtmlProperty,将 Css 变量写入根元素 html 下进行覆盖

注意,这里还有个 isDark 参数,用于生成暗黑模式下的色彩值

现在,生成的色阶是这样的:

在 Clean Admin 已经应用了这套色阶算法实现动态切换,现在的效果是这样的:

Clean Admin - theme 文件夹找到全部相关文件

还可以做些什么?

至此,关于动态切换主题色的核心操作,基本已经完成,接下来的事可以是

  • 预设色彩值、允许用户自定义选择、手动输入色彩值
  • 持久化存储主题色
  • 适配暗黑模式下的色值变化

持久化存储主题色值

注意,当用户自主选择了色彩偏好时,为了用户体验,建议你保存该值,比如存在本地存储 local

这里的持久化存储作用是指当下次打开页面或刷新页面,依然保持用户选择的色彩偏好

Clean Admin 中使用了 Pinia + pinia-plugin-persistedstate 实现,专栏之前文章有讲过

暗黑模式下的色值变化

setElementPrimaryColor 函数中,有一个 isDark 参数,当值为 true 时,会生成暗黑模式下的色阶值

如果你的系统有暗黑模式切换的功能,那么在切换暗黑模式时,需要重新调用 setElementPrimaryColor 函数

vueuse 中有一个 useDark 函数,可以响应式获取暗黑模式的状态

一个最简单例子:

ts 复制代码
import { useDark } from '@vueuse/core'

const isDark = useDark()

watch(isDark, (mode) => {
  setElementPrimaryColor('#409eff', mode)
})

拓展太多有点偏题,你可以在 Clean Admin 源码找到相关实现

参考资料

了解更多

系列专栏地址:GitHub 博客 | 掘金专栏 | 思否专栏

实战项目:vue-clean-admin

专栏往期回顾:

  1. 收下这份 Vue + TS + Vite 中后台系统搭建指南,从此不再害怕建项目
  2. 中后台开发必修课:Vue 项目中 Pinia 与 Router 完全攻略
  3. 用了这些 Vite 配置技巧,同事都以为我开挂了
  4. 受够了团队代码风格不统一?7千字教你从零搭建代码规范体系
  5. 开发者必看!在团队中我是这样实现 Git 提交规范化的
  6. 告别繁琐!Vue3 组合式函数解锁 Echarts 封装新姿势
  7. 彻底搞懂面包屑,手把手封装一个 Vue3 面包屑导航组件

交流讨论

文章如有错误或需要改进之处,欢迎指正

相关推荐
爱分享的程序员8 分钟前
前端面试专栏-工程化:29.微前端架构设计与实践
前端·javascript·面试
上单带刀不带妹12 分钟前
Vue3递归组件详解:构建动态树形结构的终极方案
前端·javascript·vue.js·前端框架
-半.14 分钟前
Collection接口的详细介绍以及底层原理——包括数据结构红黑树、二叉树等,从0到彻底掌握Collection只需这篇文章
前端·html
90后的晨仔34 分钟前
📦 Vue CLI 项目结构超详细注释版解析
前端·vue.js
@大迁世界34 分钟前
用CSS轻松调整图片大小,避免拉伸和变形
前端·css
一颗不甘坠落的流星35 分钟前
【JS】获取元素宽高(例如div)
前端·javascript·react.js
白开水都有人用36 分钟前
VUE目录结构详解
前端·javascript·vue.js
if时光重来1 小时前
axios统一封装规范管理
前端·vue.js
m0dw1 小时前
js迭代器
开发语言·前端·javascript
烛阴1 小时前
别再让 JavaScript 卡死页面!Web Workers 零基础上手指南
前端·javascript