在线主题切换

512 阅读6分钟

概述

那么实现换肤分两步;一是 element-ui 的换肤方案;二是 douluo-ui组件库 的换肤方案

element-ui 的换肤

方案一:在线生成

访问 Element 在线主题生成工具,选择自己所需的颜色,下载主题压缩包,解压到项目系统中,按如下方式引入系统

import Vue from 'vue'
import Element from 'element-ui'
import '../theme/index.css'

Vue.use(Element)
  • 优点:替换方便
  • 缺点:只能替换一种主题样式

方案二:直接修改 SCSS 变量

Element 的 theme-chalk 使用 SCSS 编写,如果项目也使用了 SCSS,那么可以直接在项目中改变 Element 的样式变量。新建一个样式文件,例如 element-variables.scss,写入以下内容:

/* 改变主题色变量 */
$--color-primary: teal;

/* 改变 icon 字体路径变量,必需 */
$--font-path: '~element-gui/lib/theme-chalk/fonts';

@import "~element-gui/packages/theme-chalk/src/index";

之后,在项目的入口文件 src/main.js 中,直接引入以上样式文件即可(无需引入 Element 编译好的 CSS 文件,因为已经 import 了):

import Vue from 'vue'
import Element from 'element-ui'
import './element-variables.scss'

Vue.use(Element)

常用默认颜色变量:

$--color-primary: #1890FF;
$--color-success: #57B21C;
$--color-warning: #FC9306;
$--color-danger: #E82F2F;
$--color-info: #999999;

$--color-text-primary: #333333;
$--color-text-empty: #333333;
$--color-text-disabled: #666666;
$--color-text-readonly: #666666;
$--color-text-placeholder: #999999;

$--border-color-base: #D2D2D2;
$--border-color-light: #999999;

$--icon-color: #666666;

$--background-color-base: #EFEFEF;
$--background-color-dark: #E5E5E5;

注意:覆盖字体路径变量是必需的,而且这种方案有个问题,调试会重复加载多份样式,听说上生产环境会少点,原因可能 定义了 common.scss 在多个组件 scss 前面引入

方案实现

  • 优点:灵活,可以自定义替换主题和常用的颜色变量等
  • 缺点:只能实现一种换肤

方案三:使用Element的命令行主题工具

由于 element-ui 的样式单独维护,官方将它抽象出来做成命令行工具使用,实现换肤分为5步

1. 安装工具

a) 首先安装主题生成工具,可以全局安装或者安装在当前项目下,推荐安装在项目里,方便别人 clone 项目时能直接安装依赖并启动,这里以全局安装做演示。

npm i element-theme -g

b) 安装白垩主题,可以从 npm 安装或者从 GitHub 拉取最新代码。

# 从 npm
npm i element-theme-chalk -D

# 从 GitHub
npm i https://github.com/ElementUI/theme-chalk -D

2. 初始化变量文件

主题生成工具安装成功后,全局安装可以在命令行里通过 et 调用工具,如果安装在当前目录下,需要通过 node_modules/.bin/et 访问到命令。执行 -i 初始化变量文件。默认输出到 element-variables.scss ,当然你可以传参数指定文件输出目录。

et -i [可以自定义变量文件]

> ✔ Generator variables file

如果使用默认配置,执行后当前目录会有一个 element-variables.scss 文件。内部包含了主题所用到的所有变量,它们使用 SCSS 的格式定义。大致结构如下:

$--color-primary: #409EFF !default;
$--color-primary-light-1: mix($--color-white, $--color-primary, 10%) !default; /* 53a8ff */
$--color-primary-light-2: mix($--color-white, $--color-primary, 20%) !default; /* 66b1ff */
$--color-primary-light-3: mix($--color-white, $--color-primary, 30%) !default; /* 79bbff */
$--color-primary-light-4: mix($--color-white, $--color-primary, 40%) !default; /* 8cc5ff */
$--color-primary-light-5: mix($--color-white, $--color-primary, 50%) !default; /* a0cfff */
$--color-primary-light-6: mix($--color-white, $--color-primary, 60%) !default; /* b3d8ff */
$--color-primary-light-7: mix($--color-white, $--color-primary, 70%) !default; /* c6e2ff */
$--color-primary-light-8: mix($--color-white, $--color-primary, 80%) !default; /* d9ecff */
$--color-primary-light-9: mix($--color-white, $--color-primary, 90%) !default; /* ecf5ff */

$--color-success: #67c23a !default;
$--color-warning: #e6a23c !default;
$--color-danger: #f56c6c !default;
$--color-info: #909399 !default;

...

3. 修改变量

直接编辑element-variables.scss文件,例如修改主题色为红色。

$--color-primary: red;

4. 编译主题

保存文件后,到命令行里执行et编译主题,如果你想启用 watch 模式,实时编译主题,增加 -w 参数;如果你在初始化时指定了自定义变量文件,则需要增加 -c 参数,并带上你的变量文件名

et

> ✔ build theme font
> ✔ build element theme

5. 引入自定义主题

默认情况下编译的主题目录是放在 ./theme 下,你可以通过 -o 参数指定打包目录。像引入默认主题一样,在代码里直接引用theme/index.css 文件即可。

import '../theme/index.css'
import ElementUI from 'element-ui'
import Vue from 'vue'

Vue.use(ElementUI)

element-theme 官方换肤方案参考这里

这种换肤方案缺点:只能使用一种主题,安装比较麻烦,容易踩坑安装失败,不推荐

自定义主题色【推荐】

由于项目会员等级有 4 种主题(未来可能会新增),上面的实现方案有局限性,如果实现的话要提前准备 4 种主题,这样做的结果会增加维护成本,而且拓展性不好,增加一种新的主题要重新生成一份新的样式,打包体积也会变大

参考 vue-element-admin 更换主题,通过自定义换肤的方案实现,这种方式比较灵活,可以自定义任意一种主题颜色,无需准备多套主题,可以自由动态换肤;缺点是自定义不够,只支持基础颜色的切换。

原理

element-ui 2.0 版本之后所有的样式都是基于 SCSS 编写的,所有的颜色都是基于几个基础颜色变量来设置的,所以就不难实现动态换肤了,只要找到那几个颜色变量修改它就可以了。

Element官方实现了一个demo:在线主题生成工具。作者在 issue 中回复了他的方案:

  1. 先把默认主题文件中涉及到颜色的 CSS 值替换成关键词:源码
  2. 根据用户选择的主题色生成一系列对应的颜色值:源码
  3. 把关键词再换回刚刚生成的相应的颜色值:源码
  4. 直接在页面上加 style 标签,把生成的样式填进去源码

根据以上方案,简单说明一下实现原理:

  • 设计对外暴露拓展配置,包括 element-ui 的版本号 或 element-ui 样式链接 、新的主题颜色、旧的主题颜色、插入 dom 的位置等
  • 根据新、旧主题颜色,转化为 16 进制的红、绿、蓝颜色值,生成 10%, 20%, ……, 90% 混合颜色,得到两个颜色数组, 例如 themeCluster, originalCluster
  • 请求 element-ui 样式文件,保存到内存中,通过正则匹配,将 themeCluster, originalCluster 两个数组替换样式文件
  • 最后将替换的新样式插入到 dom 上,覆盖 element-ui 原来的样式

实现新功能

  1. 创建 useTheme.js 文件,定义替换和加载样式方法
const ColorUnit = {
  //hex颜色转rgb颜色
  HexToRgb(str) {
    str = str.replace("#", "");
    var hxs = str.match(/../g);
    for (var i = 0; i < 3; i++) hxs[i] = parseInt(hxs[i], 16);
    return hxs;
  },
  //rgb颜色转hex颜色
  RgbToHex(a, b, c) {
    var hexs = [a.toString(16), b.toString(16), c.toString(16)];
    for (var i = 0; i < 3; i++) {
      if (hexs[i].length == 1) hexs[i] = "0" + hexs[i];
    }
    return "#" + hexs.join("");
  },
  //加深
  darken(color, level) {
    var rgbc = this.HexToRgb(color);
    for (var i = 0; i < 3; i++) rgbc[i] = Math.floor(rgbc[i] * (1 - level));
    return this.RgbToHex(rgbc[0], rgbc[1], rgbc[2]);
  },
  //变淡
  lighten(color, level) {
    var rgbc = this.HexToRgb(color);
    for (var i = 0; i < 3; i++)
      rgbc[i] = Math.floor((255 - rgbc[i]) * level + rgbc[i]);
    return this.RgbToHex(rgbc[0], rgbc[1], rgbc[2]);
  },
};

export default function () {
  // 样式资源包
  let chalk = "";

  const tintColor = (color, tint) => {
    color = color.replace("#", "");
    let red = parseInt(color.slice(0, 2), 16);
    let green = parseInt(color.slice(2, 4), 16);
    let blue = parseInt(color.slice(4, 6), 16);

    if (tint === 0) {
      // when primary color is in its rgb space
      return [red, green, blue].join(",");
    } else {
      red += Math.round(tint * (255 - red));
      green += Math.round(tint * (255 - green));
      blue += Math.round(tint * (255 - blue));

      red = red.toString(16);
      green = green.toString(16);
      blue = blue.toString(16);

      return `#${red}${green}${blue}`;
    }
  };

  const shadeColor = (color, shade) => {
    let red = parseInt(color.slice(0, 2), 16);
    let green = parseInt(color.slice(2, 4), 16);
    let blue = parseInt(color.slice(4, 6), 16);

    red = Math.round((1 - shade) * red);
    green = Math.round((1 - shade) * green);
    blue = Math.round((1 - shade) * blue);

    red = red.toString(16);
    green = green.toString(16);
    blue = blue.toString(16);

    return `#${red}${green}${blue}`;
  };

  const getThemeCluster = function (theme) {
    const clusters = [theme];
    for (let i = 0; i <= 9; i++) {
      clusters.push(tintColor(theme, Number((i / 10).toFixed(2))));
    }
    clusters.push(shadeColor(theme, 0.1));
    return clusters;
  };

  const getClusterMap = function (theme) {
    const set = new Set([]);
    Object.keys(theme).reduce((pre, cur) => {
      const colorStr = theme[cur];
      const cluster = getThemeCluster(colorStr.replace("#", ""));

      cluster.forEach((item) => {
        pre.add(item);
      });

      return pre;
    }, set);

    return [...set];
  };

  const getCSSString = function (url) {
    return new Promise((resolve) => {
      const xhr = new XMLHttpRequest();
      xhr.onreadystatechange = () => {
        if (xhr.readyState === 4 && xhr.status === 200) {
          chalk = xhr.responseText.replace(/@font-face{[^}]+}/, "");
          resolve();
        }
      };
      xhr.onerror = (err) => {
        console.error("样式下载失败", err);
      };
      xhr.open("GET", url);
      xhr.send();
    });
  };

  const updateStyle = function (style, oldCluster, newCluster) {
    let newStyle = style;
    oldCluster.forEach((color, index) => {
      newStyle = newStyle.replace(new RegExp(color, "ig"), newCluster[index]);
    });
    return newStyle;
  };

  /**
   * 将css3变量设置到document中方便全局调用
   */
  function setPropertyColor(varName, color, funName, level) {
    level = level ? level : 0;
    funName = funName ? funName : "lighten";
    document.documentElement.style.setProperty(
      varName,
      ColorUnit[funName](color, level / 10)
    );
  }

  /**
   * 生成主色的其余渐变色并修改对应CSS3变量值
   */
  function themeColorGradient(varName, funName, themeColor, themeLevel) {
    themeColor = themeColor ? themeColor : "#409eff";
    themeLevel = themeLevel ? themeLevel : [3, 5, 7, 8, 9];

    themeLevel.forEach(function (level) {
      setPropertyColor(
        varName.replace("#level#", level),
        themeColor,
        funName,
        level
      );
    });
  }

  const updateElementTheme = async function (options = {}) {
    const {
      version = "2.15.14",
      oldTheme,
      newTheme,
      appendDom,
      insertBefore,
      cssUrl,
      chalkStyle = "chalk-style",
    } = options;

    // if (typeof newTheme !== "string") return;

    setThemeColor(newTheme);

    if (parseFloat(version) >= 3) {
      return false;
    }

    const checkResultTheme = Object.keys(newTheme).reduce((pre, cur) => {
      if (newTheme[cur]) {
        pre[cur] = newTheme[cur];
      } else {
        pre[cur] = oldTheme[cur];
      }
      return pre;
    }, {});

    console.log("checkResultTheme...");
    console.log(checkResultTheme);

    const themeCluster = getClusterMap(newTheme);
    // const originalCluster = getClusterMap(oldTheme);
    const originalCluster = getClusterMap(checkResultTheme);

    const chalkHandler = (id) => {
      const newStyle = updateStyle(chalk, originalCluster, themeCluster);

      let styleTag = document.querySelector(`#${id}`);
      if (!styleTag) {
        styleTag = document.createElement("style");
        styleTag.setAttribute("id", id);
        if (appendDom) {
          if (insertBefore) {
            appendDom.parentNode.insertBefore(styleTag, appendDom.nextSibling);
          } else {
            appendDom.appendChild(styleTag);
          }
        } else {
          document.head.appendChild(styleTag);
        }
      }
      styleTag.innerText = newStyle;
    };

    if (!chalk) {
      const url =
        cssUrl ||
        `https://unpkg.com/element-ui@${version}/lib/theme-chalk/index.css`;
      await getCSSString(url);
    }

    chalkHandler(chalkStyle);
  };

  const setThemeColor = function (colorMap) {
    let _namespace = "el";

    const $colorMap = Object.keys(colorMap).reduce((pre, cur) => {
      if (colorMap[cur]) {
        pre.push([cur, colorMap[cur]]);
      }
      return pre;
    }, []);

    $colorMap.forEach((colorItem) => {
      setPropertyColor(`--${_namespace}-color-${colorItem[0]}`, colorItem[1]);
      themeColorGradient(
        `--${_namespace}-color-${colorItem[0]}-light-#level#`,
        "lighten",
        colorItem[1]
      );
      setPropertyColor(
        `--${_namespace}-color-${colorItem[0]}-dark-2`,
        colorItem[1],
        "darken"
      );
      // element-plus 目前只有dark-2,不需要放开这个
      // themeColorGradient(`--${_namespace}-color-${colorItem[0]}-dark-#level#`,"darken",colorItem[1]);
    });
  };

  const setCustomeColor = function (colorMap, namespace = "yt") {
    const $colorMap = Object.keys(colorMap).reduce((pre, cur) => {
      if (colorMap[cur]) {
        pre.push([cur, colorMap[cur]]);
      }
      return pre;
    }, []);

    console.log($colorMap);

    $colorMap.forEach((colorItem) => {
      setPropertyColor(`--${namespace}-color-${colorItem[0]}`, colorItem[1]);
    });
  };

  return {
    updateElementTheme,
    setThemeColor,
    setCustomeColor,
  };
}

颜色配置 themMap.js

export const themeMap = {
  primary: {
    primary: "#409EFF",
    success: "#67C23A",
    warning: "#E6A23C",
    danger: "#F56C6C",
    error: "#F56C6C",
    info: "#909399",
  },

  light: {
    primary: "#f20000",
    success: "#55DE12",
    warning: "#EA9412",
    danger: "#E12020",
    error: "#E12020",
    info: "#209399",
  },

  dark: {
    primary: "#0A4680",
    success: "#276409",
    warning: "#815410",
    danger: "#931d1d",
    error: "#931D1D",
    info: "#454A55",
  },
};

export const customThemeMap = {
  primary: {
    primary: "#7e5858",
    warning: "#587e5e",
  },

  light: {
    primary: "#58597e",
    warning: "#4caacf",
  },

  dark: {
    primary: "#637e58",
    warning: "#7e5861",
  },
};

  1. 在入口文件引入组件,调用 updateElementTheme 方法
import useTheme from "@/utils/theme";
handleSwitchTheme(name) {
  const theme = useTheme();
  theme.updateElementTheme({
    // version: "2",
    oldTheme: this.themeMap.primary,
    newTheme: this.themeMap[name],
  });
  theme.setCustomColor(this.customThemeMap[name]);
}

注意:为了避免页面出现闪烁(原因样式是异步加载,会先渲染 element 主题颜色,再渲染自定义颜色),可以在入口文件封装成方法,使用 async/await 初始化变量文件

const theme = useTheme();
const initApp = async function () {

  await theme.updateElementTheme({
    oldTheme,
    primaryColor
  })
  new Vue({
    router,
    render: (h) => h(App)
  }).$mount('#app');
}

initApp()
  1. 异常处理,如果 css 样式加载失败,会导致页面加载失败,打不开,需要捕获异常处理
const initApp = async () => {
  try {
    await DouluoUI.updateElementTheme({
      oldTheme,
      primaryColor
    })

    new Vue({
      el: '#app',
      router,
      render: (h) => h(App)
    }).$mount()
  } catch (error) {
    new Vue({
      el: '#app',
      router,
      render: (h) => h(App)
    }).$mount()
    console.error('主题更新失败')
  }
}

因此,样式最好放在公司内部的 cdn 上,保证样式资源稳定安全,同时最好做一下降级处理方案兼容加载失败的情况