一篇文章实现 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 面包屑导航组件

交流讨论

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

相关推荐
加班是不可能的,除非双倍日工资3 小时前
css预编译器实现星空背景图
前端·css·vue3
wyiyiyi4 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip4 小时前
vite和webpack打包结构控制
前端·javascript
excel5 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国5 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼5 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin
Jimmy5 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
草梅友仁5 小时前
草梅 Auth 1.4.0 发布与 ESLint v9 更新 | 2025 年第 33 周草梅周报
vue.js·github·nuxt.js
ZXT5 小时前
promise & async await总结
前端
Jerry说前后端5 小时前
RecyclerView 性能优化:从原理到实践的深度优化方案
android·前端·性能优化