前言
本篇文章主要讲解如何实现 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 的色阶值
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
函数:
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
专栏往期回顾:
- 收下这份 Vue + TS + Vite 中后台系统搭建指南,从此不再害怕建项目
- 中后台开发必修课:Vue 项目中 Pinia 与 Router 完全攻略
- 用了这些 Vite 配置技巧,同事都以为我开挂了
- 受够了团队代码风格不统一?7千字教你从零搭建代码规范体系
- 开发者必看!在团队中我是这样实现 Git 提交规范化的
- 告别繁琐!Vue3 组合式函数解锁 Echarts 封装新姿势
- 彻底搞懂面包屑,手把手封装一个 Vue3 面包屑导航组件
交流讨论
文章如有错误或需要改进之处,欢迎指正