elementPlus 自定义主题在暗黑模式下的问题探究

810 阅读9分钟

一、起因

书接上回

基于elementPlus + Tailwindcss的动态主题配置方案

一年多前在实际工作中设计了一套动态主题方案,并基于此分享了一篇文章:基于elementPlus + Tailwindcss的动态主题配置方案,做完后就很久没有碰过这个项目了。

而最近在github要求开启二次认证的邮箱提醒下,重新上了一下github,“不小心发现”,这个文章分享的代码仓库居然多了个 Issue,打开一看,是关于 dark 模式下,这套颜色混合模式算法的计算结果和官方的不同的提问。

如下图,这位大哥发现dark模式下,我的颜色计算出来的和官方不同。

image.png

马上哈,我就想到,我当时好像根本没考虑过什么dark模式,所以切换到dark模式颜色肯定也是不会变化的。

既然如此何不研究下,要如何适配这个dark模式呢。

探究

通过上面那张图可以很明显看出来,在dark模式下elementPlus是将混合色的深浅变化掉了个头,即在dark模式下使用黑色来对基础色(如默认的primary色#409EFF)进行混合。

默认的light模式下,是使用的白色#fff混合的。

那么可以简单得到一个方案:即在初始化更新我们自定义的主题色的时候,获取一下当前的dark模式是否开启,如果开启就将导出的darklight交换一下即可。

而elementPlus的dark模式判断,也很简单,通过官方文档易得,直接通过html标签的class类名是否包含dark这个类名即可判断

image.png

1. 首先,我们先按照官方文档引入一下dark模式下的样式文件

// main.ts
import { createApp } from 'vue'
import './assets/style.scss'
import 'element-plus/theme-chalk/dark/css-vars.css';
import App from './App.vue'
import { setTheme } from './utils/theme';

setTheme();

createApp(App).mount('#app')

2. 再者,我们设计这么一个模式的适配,当然是为了可以切换模式

这里直接用vueuseuseDark来简单实现一下主题切换(useDark会自动同步到html的className,不需要自己再去操作dom)。

// settings-modal.vue
<template>
  <el-dialog
    v-model="dialogVisible"
    title="主题配置"
    width="640px"
    draggable
    :close-on-click-modal="false"
  >
    <el-form label-position="left" :label-width="80">
      <!-- 增加一行控件,控制dark模式开启 -->
      <el-form-item label="暗黑模式">
        <el-switch v-model="isDark" />
      </el-form-item>
      ...
    </el-form>
  </el-dialog>
</template>

<script setup lang="ts">
...
import {useDark } from '@vueuse/core';

// 引入控制dark模式的hoos
const isDark = useDark();

...
</script>
<style></style>

而根据下图官方生成的dark模式颜色可知,dark模式下的elementPlus色彩深浅的梯度和light模式是一样的,只是单纯调换了使用的混合色,light用的是#000黑色,dark用的是#fff白色,使用的混合百分比为20%、30%、50%、70%、80%、90%,这样的话我们的颜色生成代码应该是可以不需要修改的。

image.png

3. 接着在我们进行主题色更新的时候判断一下是否是dark模式,dark模式的情况下调换一下生成出来的light、dark颜色序列

/**
 * 主题色相关
 */

import { merge } from 'lodash-es';
import { genMixColor } from './gen-color';
import { useDark } from '@vueuse/core';

...

function updateThemeColorVar({ colors }: Theme) {
  // 每次更新前判断一下是否是dark模式
  const isDark = useDark();

  for (const brand in colors) {
    updateBrandExtendColorsVar(
      colors[brand as keyof Theme['colors']] as string,
      brand
    );
  }

  function updateBrandExtendColorsVar(color: string, name: string) {
    let { DEFAULT, dark, light, test } = genMixColor(color);

    // dark模式的情况,调换light、dark颜色序列
    if (isDark) {
      [dark, light] = [light, dark];
    }
    
    ...

    // elementPlus主题色更新 
    setStyleProperty(`--el-color-${name}`, DEFAULT);
    setStyleProperty(`--el-color-${name}-dark-2`, dark[2]);
    setStyleProperty(`--el-color-${name}-light-3`, light[3]);
    setStyleProperty(`--el-color-${name}-light-5`, light[5]);
    setStyleProperty(`--el-color-${name}-light-7`, light[7]);
    setStyleProperty(`--el-color-${name}-light-8`, light[8]);
    setStyleProperty(`--el-color-${name}-light-9`, light[9]);
  }
}

...

4. 最后在我们控制dark模式切换的时候,重新更新一遍主题色。

// settings-modal.vue
<template>
  <el-dialog
    v-model="dialogVisible"
    title="主题配置"
    width="640px"
    draggable
    :close-on-click-modal="false"
  >
    <el-form label-position="left" :label-width="80">
      <!-- 增加一行控件,控制dark模式开启 -->
      <el-form-item label="暗黑模式">
        <el-switch v-model="isDark" />
      </el-form-item>
      ...
    </el-form>
  </el-dialog>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue';
import { Theme, getTheme, setTheme } from '../utils/theme';
import { useDark } from '@vueuse/core';

const isDark = useDark();

const themeConfig = ref<Theme>(Object.assign({}, getTheme()));

const updateTheme = () => setTheme(themeConfig.value);

// 监听变化,更新主题
watch(isDark, () => nextTick(() => updateTheme()));
...
</script>
<style></style>

现在我们点击切换到dark模式下看看生成的颜色。

image.png

扫一眼,颜色梯度雀食已经修改了,变成了越来越深的颜色。

image.png

基本搞定后,我想这回复一下 Issue 增加了这个功能了。

But,在看了一眼Issue中贴的官方生成的颜色时,我不淡定了。

这居然不一样,WTF?

二、溯源

我们对比官方网站上的dark模式下primary颜色,会发现只有--el-color-primary-dark-2#66b1ff)能够对上,其他几个light系列颜色都对不上了。

image.png

这就很怪了,--el-color-primary-dark-2能对应上,说明起码在dark模式下,我们将混合色(黑色和白色)交换其中,dark颜色系列使用白色混合,这个操作是对的,但light颜色系列,混合色计算出来的是错误的。

我们的混合色算法只对了一半?

1. 研究官方dark模式源码

在上面,我们看了官方推荐的最简单的启用dark模式的方式,即html标签添加dark类名 + 引入dark模式下所需要的样式:

import 'element-plus/theme-chalk/dark/css-vars.css'

现在回头一看,这居然是个css文件,然而在上一篇文章中,我们知道主题色的替换,官方的方法是,通过覆盖elementPlus内部的颜色变量(在element-plus/theme-chalk/src/common/var.scss文件)来实现的,elementPlus的主题系统会读取变量,然后在通过这一系列变量来生成最终的颜色文件(css)。

也就是说整个主题生成的过程是动态的,但此时我们引入的dark模式样式文件是一个静态文件,打开一看果不其然,相关的颜色都是写死的:

/* element-plus/theme-chalk/dark/css-vars.css */
html.dark {
  color-scheme: dark;
  --el-color-primary: #409eff;
  --el-color-primary-light-3: #3375b9;
  --el-color-primary-light-5: #2a598a;
  --el-color-primary-light-7: #213d5b;
  --el-color-primary-light-8: #1d3043;
  --el-color-primary-light-9: #18222c;
  --el-color-primary-dark-2: #66b1ff;
  ...
}

那么也就是说这种引入方式下,主题颜色都是写死的,我们切换到dark模式后,即使按照官方的主题色切换方法,最终显示的颜色也不会变了。

我们可以尝试一下:

// theme.scss 
// 用官方文档提供的的主题更新方案修改主题
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
  $colors: (
    'primary': (
      'base': green,
    ),
  ),
);
// vite.config.ts 
// 因为我们目前测试的项目是按需导入的elementPlus,根据官方文档,按需导入组件修改样式的,必须要使用预处理来引入。
export default defineConfig({
  ...
  css: {
    preprocessorOptions: {
      scss: {
        // 添加上主题替换代码
        additionalData: `@use "src/assets/theme.scss";`,
      },
    },
  }
});

运行下,看看primary主题色是否变成了绿色

  • 首先是Light模式,主题切换成功,primary色变成了绿色

image.png

  • 然后切换到dark模式,果不其然,这种情况下,由于是写死的dark配色,当然是不会随着主题变化而变化的。

image.png

2. 随主题变化动态dark模式

我们发现了,官方推荐的导入方式是静态的,但其实,往下看,就能看到主题相关的细节暗示,只不过官方文档没有像【主题】这一章节一样详细的介绍。

image.png

可以看到在暗黑模式章节的末尾,文档介绍了如何自定义变量修改主题色的办法,“通过css”的方案和上述基础方案没有区别,挨个替换css变量而已。

而“通过SCSS”这里,我们能发现替换方案和主题章节推荐的替换方案是一致的,即通过@forward来导入并更新默认的颜色变量值,使用我们自定义的变量生成一组新的颜色值。

我们可以打开文件node_modules\element-plus\theme-chalk\src\dark\var.scss看看细节:

image.png

可以看到的是dark模式下的主题样式生成算法和正常模式是一致的(正常模式下的生成方法可见element-plus/theme-chalk/src/common/var.scss,只有混合色的选择不同,dark模式下生成light系列颜色使用的是map.get($bg-color, '')这个变量的颜色来进行混合的,即上图可见到的#141414,而我们查看正常模式下的light系列颜色混合色为$color-white(见下图)

image.png

而这就是为什么我们自己生成的混合色系列和官方生成的不一样的根本原因,这下终于找到了!

image.png

我们需要使用通过Scss引入的方式才能达成像上一篇文章一样的动态主题效果,可以先试试:

// main.ts
...
// 引入css-vars.scss而不是var.scss, css-vars.scss中引入过了var.scss且还包含了针对box-shadow,bg-color等一系列默认样式在dark模式下的修改,匹配到官方文档对于dark模式的基础引用文件就是 css-vars.css 我们引入这个文件更加合理
import 'element-plus/theme-chalk/src/dark/css-vars.scss';
...

修改颜色变量还是跟上面修改正常非dark模式下一样的:

image.png

这里注意,我们依旧还是在引入element-plus/theme-chalk/src/common/var.scss的基础上进行修改的,这是因为在文件element-plus/theme-chalk/src/dark/var.scss中,首先就引入了common文件夹中的变量文件,并使用该文件中的变量来进行合并,这样我们可以直接统一在common中进行变量覆盖,而无需去区分dark模式和正常模式了

image.png

现在我们保存,并刷新页面看看,是否修改成功,并且在dark模式下也能动态的进行主题色适配:

image.png

image.png

可以看到,肥肠完美,能够适配到dark模式,且生成颜色和我们之前引入的静态css文件中的一致(这个懒得放图了,可以自己尝试一下不修改primary主题色看看)

三、改造

在上述所有结论下,我们已经知道问题的原因了,现在可以结合上面的结论来改造我们的动态主题项目代码。

1. 修改引入的dark模式样式为动态的样式文件

// main.ts
// 上面说过了为什么导入这个文件而不是官方文档说的var.scss不多逼逼了
import 'element-plus/theme-chalk/src/dark/css-vars.scss';

按道理我们其实也可以不引入这个文件,因为我们会手动生成dark模式主题色,并通过变量来覆盖,可以实现一样的效果。但这里主要是因为该dark样式文件不仅仅针对主题色做了适配改动,还对背景色,一些组件的边框/阴影等等样式做了dark模式下的适配工作,我们可以不需要手动再写一遍了。

而手动修改的elementPlus主题色使用的是document.documentElement.style.setProperty(propName, value),属于是行内样式,比他默认的html.dark优先级更高。

2. 修改颜色生成代码,改成读取变量生成,并适配dark模式的变化

根据上文结论,我们得到了关键信息如下:

  • Light模式下:
    • light系列颜色的混合色为$color-white变量,也就是--el-color-white
    • dark系列颜色的混合色为$color-black变量,也就是--el-color-black
  • Dark模式下:
    • light系列颜色的混合色为$bg-color变量,也就是--el-color-white
    • dark系列颜色的混合色为map.get($bg-color, '')变量,也就是--el-bg-color

结合这一点我们来修改颜色混合算法代码,且由于颜色的表示语法有多种 hex、hsl、rgb、rgba,如果直接从变量中获取的话我们无法确定具体类型,所以还需要针对获取到的颜色做归一化处理,方便后续颜色混合操作:

// gen-color.ts
...

function genMixColor(
  base: string,
  isDark?: boolean
): {
  DEFAULT: HEX;
  dark: GenColorList;
  light: GenColorList;
} {
  // 基准色统一转换为RGB
  base = normalizationColor(base);
  const rgbBase = hexToRGB(base);

  // 传入是否dark模式,动态获取混合色
  const { mixLightColor, mixDarkColor } = getMixColorFromVar(isDark);

  // 混合色
  function mix(color: RGB, mixColor: RGB, weight: number): RGB {
    return {
      r: color.r * (1 - weight) + mixColor.r * weight,
      g: color.g * (1 - weight) + mixColor.g * weight,
      b: color.b * (1 - weight) + mixColor.b * weight,
    };
  }

  return {
    DEFAULT: base,
    dark: {
      1: rgbToHex(mix(rgbBase, mixDarkColor, 0.1)),
      2: rgbToHex(mix(rgbBase, mixDarkColor, 0.2)),
      3: rgbToHex(mix(rgbBase, mixDarkColor, 0.3)),
      4: rgbToHex(mix(rgbBase, mixDarkColor, 0.4)),
      5: rgbToHex(mix(rgbBase, mixDarkColor, 0.5)),
      6: rgbToHex(mix(rgbBase, mixDarkColor, 0.6)),
      7: rgbToHex(mix(rgbBase, mixDarkColor, 0.7)),
      8: rgbToHex(mix(rgbBase, mixDarkColor, 0.8)),
      9: rgbToHex(mix(rgbBase, mixDarkColor, 0.9)),
    },
    light: {
      1: rgbToHex(mix(rgbBase, mixLightColor, 0.1)),
      2: rgbToHex(mix(rgbBase, mixLightColor, 0.2)),
      3: rgbToHex(mix(rgbBase, mixLightColor, 0.3)),
      4: rgbToHex(mix(rgbBase, mixLightColor, 0.4)),
      5: rgbToHex(mix(rgbBase, mixLightColor, 0.5)),
      6: rgbToHex(mix(rgbBase, mixLightColor, 0.6)),
      7: rgbToHex(mix(rgbBase, mixLightColor, 0.7)),
      8: rgbToHex(mix(rgbBase, mixLightColor, 0.8)),
      9: rgbToHex(mix(rgbBase, mixLightColor, 0.9)),
    },
  };
}

// 混合基础色获取
function getMixColorFromVar(isDark?: boolean) {
  const VAR_WHITE = '--el-color-white';
  const VAR_BLACK = '--el-color-black';
  const VAR_BG = '--el-bg-color';

  let mixLightColor, mixDarkColor;

  // dark模式和非dark模式获取不同的css变量
  if (isDark) {
    mixLightColor = getComputedStyle(document.documentElement)
      .getPropertyValue(VAR_BG)
      .trim();
    mixDarkColor = getComputedStyle(document.documentElement)
      .getPropertyValue(VAR_WHITE)
      .trim();
  } else {
    mixLightColor = getComputedStyle(document.documentElement)
      .getPropertyValue(VAR_WHITE)
      .trim();
    mixDarkColor = getComputedStyle(document.documentElement)
      .getPropertyValue(VAR_BLACK)
      .trim();
  }

  // 对变量颜色做归一化处理
  mixLightColor = hexToRGB(normalizationColor(mixLightColor));
  mixDarkColor = hexToRGB(normalizationColor(mixDarkColor));

  return {
    mixLightColor,
    mixDarkColor,
  };
}

// 归一化处理,统一返回 HEX 类型的颜色值
function normalizationColor(color: string): HEX {
  const prefix = /^(#|hsl|rgb|rgba)/i.exec(color)?.[1];

  if (!prefix) {
    throw new TypeError('color is invalid.');
  }

  const colorVal = color.replace(prefix, '').trim();
  if (prefix === '#') {
    return fixHexVal(colorVal);
  } else if (prefix.toLocaleLowerCase() === 'hsl') {
    return fixHslVal(colorVal);
  } else {
    return fixRgbVal(colorVal);
  }

  function fixHexVal(val: string) {
    const len = val.length;
    if (len === 8) {
      return `#${val.substring(0, 6)}`; // 舍弃掉透明度
    } else if (len === 6) {
      return `#${val}`;
    } else if (len === 3) {
      return val.split('').reduce((pre, cur) => `${pre}${cur + cur}`, '#');
    } else {
      throw new TypeError('hex color is invalid.');
    }
  }

  function fixHslVal(val: string) {
    const hslVal = val
      .substring(1, val.length - 1)
      .split(',')
      .map((v) => parseInt(v.trim()));
    return hslToHex({
      h: hslVal[0],
      s: hslVal[1],
      l: hslVal[2],
    });
  }

  function fixRgbVal(val: string) {
    const rgb = val
      .substring(1, val.length - 1)
      .split(',')
      .map((v) => parseInt(v.trim()));

    // 舍弃掉alphe
    return rgbToHex({
      r: rgb[0],
      g: rgb[1],
      b: rgb[2],
    });
  }
}

...

修改主题色生成代码,传入当前是否dark模式:

// theme.ts
...

function updateThemeColorVar({ colors }: Theme) {

  // 获取当前是否dark模式,并传入混合色生成器,其余部分不变
  const isDark = useDark();

  for (const brand in colors) {
    updateBrandExtendColorsVar(
      colors[brand as keyof Theme['colors']] as string,
      brand
    );
  }

  function updateBrandExtendColorsVar(color: string, name: string) {
    let { DEFAULT, dark, light } = genMixColor(color, isDark.value);

    setStyleProperty(`--${name}-lighter-color`, light[5]);
    setStyleProperty(`--${name}-light-color`, light[3]);
    setStyleProperty(`--${name}-color`, DEFAULT);
    setStyleProperty(`--${name}-deep-color`, dark[2]);
    setStyleProperty(`--${name}-deeper-color`, dark[4]);

    setStyleProperty(`--el-color-${name}`, DEFAULT);
    setStyleProperty(`--el-color-${name}-dark-2`, dark[2]);
    setStyleProperty(`--el-color-${name}-light-3`, light[3]);
    setStyleProperty(`--el-color-${name}-light-5`, light[5]);
    setStyleProperty(`--el-color-${name}-light-7`, light[7]);
    setStyleProperty(`--el-color-${name}-light-8`, light[8]);
    setStyleProperty(`--el-color-${name}-light-9`, light[9]);
  }
}

...

修改完成,验证一下,是否和官方的一致:

image.png

image.png

我们手动修改一下primary色,看看效果:

image.png

四、总结

经过对elementPlus源码的分析,和dark模式切换效果的调试,我们最终发现了,elementPlus的dark模式切换原理,找到了对应模式下正确的混合色算法,据此改造并优化我们原有的混合色生成代码,完成了对elementPlus暗黑模式的拟合。

最终完整代码请查看我的仓库:elementPlus-dynamic-theme