前端动态换肤、一键换主题色的总结和实现

前端动态换肤、一键换主题色的总结和实现

需求背景

简而言之,就是需要动态切换主题,一键换肤,提供自定义主题色的能力,满足客户定制化需求。

实现方案

常用四种前端动态切换主题颜色的实现方式:

  1. 使用 JavaScript

    使用 JavaScript 实现主题切换的最简单方法是通过修改 class 属性来改变主题样式。

    优点:

    样式可控的颗粒度高:可以在不同的元素上使用不同的主题。

    缺点:

    维护成本高:需要编写大量的 JavaScript 代码。

  2. 使用 class 和样式表

    使用 class 和样式表实现主题切换的方法是创建多个样式表,每个样式表都对应一个主题。然后,通过修改文档的 class 属性来切换不同的样式表。

    优点:

    兼容性好:使用类名和样式表切换在各种浏览器中都被支持。 可扩展性好:使用类名切换可以方便地扩展到其他的主题样式,例如字体、背景等。

    缺点:

    代码冗余:使用类名切换需要定义多个不同的主题类,这会导致代码冗余。 维护成本高:当需要添加或修改主题类时,需要同时修改 HTML 和 CSS 代码,维护成本较高。

  3. 使用预处理器变量

    使用预处理器变量实现主题切换的方法是定义多个变量,每个变量都对应一个主题。然后,通过修改变量的值来切换主题。

    优点:

    简单易用:使用预处理器变量非常简单,只需要定义变量并在需要使用的地方引用即可。 兼容性好:由于是在编译时生成不同的 CSS 文件,因此可以在各种浏览器中使用。

    缺点:

    编译时间长:每次修改主题颜色都需要重新编译,可能会导致编译时间较长。 不灵活:预处理器变量在编译时就已经确定了主题颜色,无法根据用户喜好自由选择主题颜色。

  4. 使用 CSS 变量

    使用 CSS 变量实现主题切换的方法是定义一个变量,并将其在需要使用的地方引用。然后,使用 JavaScript 修改变量的值来切换主题。这种方法的优点是灵活性高,易于维护,缺点是在一些较老的浏览器中可能不被支持。

    优点: 简单易用:使用 CSS 变量非常简单,只需要在根元素上定义变量,并在需要使用的地方引用即可。 灵活性高:使用 CSS 变量可以实现非常灵活的主题切换方案,可以根据用户喜好自由选择主题颜色。

    缺点: 兼容性问题:CSS 变量在一些较老的浏览器中不被支持,需要使用 polyfill 进行兼容。 变量作用域问题:CSS 变量作用域只在当前元素内或当前元素之后的元素内生效,无法实现在 整个页面内切换主题颜色的功能。

基于上述的方案,根据已有项目的情况和需求,考虑到微前端的各种问题,选择使用 CSS 变量来实现主题切换功能。为了解决兼容性问题,可使用css-vars-ponyfill

前置条件

设计团队需要统一颜色规范

最终参考Ant Design色板生成算法演进之路,动态计算色板也是采用了目前 Ant Design 的算法@ant-design/colors

与设计团队定义出可配置的基础色

品牌色(brand-base) 提示色(info-base) 成功色(success-base) 警告色(warning-base) 危险色(danger-base)

中性色是完全自定义,不做计算,背景色用于浅色暗色主题的切换,最终配置文件如下:

export const themeMode = {
  light: {
    theme: 'default',
    backgroundColor: '#FFFFFF',
  },
  dark: {
    theme: 'dark',
    backgroundColor: '#101011',
  },
};

export const configColor = {
  light: {
    brandColor: '#304FFE',
    infoColor: '#304FFE',
    successColor: '#52c41a',
    warningColor: '#faad14',
    dangerColor: '#f5222d',
    grayColor1: '#FFFFFF',
    grayColor2: '#F7F8FA',
    grayColor3: '#F0F2F5',
    grayColor4: '#E7EAF0',
    grayColor5: '#CED1D9',
    grayColor6: '#ACB0BF',
    grayColor7: '#797D8C',
    grayColor8: '#4A4C59',
    grayColor9: '#373840',
    grayColor10: '#292A30',
    grayColor11: '#202125',
    grayColor12: '#18181B',
    grayColor13: '#101011',
    bgColor1: 'var(--gray-1)',
    bgColor2: 'var(--gray-1)',
    bgColor3: 'var(--gray-1)',
    bgColor4: 'var(--gray-3)',
  },
  dark: {
    brandColor: '#304FFE',
    infoColor: '#304FFE',
    successColor: '#52c41a',
    warningColor: '#faad14',
    dangerColor: '#f5222d',
    grayColor13: '#FFFFFF',
    grayColor12: '#F7F8FA',
    grayColor11: '#F0F2F5',
    grayColor10: '#E7EAF0',
    grayColor9: '#CED1D9',
    grayColor8: '#ACB0BF',
    grayColor7: '#797D8C',
    grayColor6: '#4A4C59',
    grayColor5: '#373840',
    grayColor4: '#292A30',
    grayColor3: '#202125',
    grayColor2: '#18181B',
    grayColor1: '#101011',
    bgColor1: 'var(--gray-2)',
    bgColor2: 'var(--gray-3)',
    bgColor3: 'var(--gray-4)',
    bgColor4: 'var(--gray-1)',
  },
};

export default { themeMode, configColor };

实现

例子

  1. 在根元素上定义变量,如下所示:
:root {
  --brand-base: #304ffe;
  --brand-bg: #f0f5ff;
  --brand-click: #1e34d9;
  --brand-disable: #abc0ff;
  --brand-hover: #5978ff;
}
  1. 在需要使用主题颜色的元素中引用变量,如下所示:
.header {
  background-color: var(--brand-bg);
}

.button {
  background-color: var(--brand-base);
}
  1. 使用 JavaScript 修改变量的值来切换主题,如下所示:
document.documentElement.style.setProperty('--brand-base', '#304FFE');
document.documentElement.style.setProperty('--brand-bg', '#f0f5ff');

封装

结合 Ant Design 的算法进行封装

import cssVars from 'css-vars-ponyfill';
// eslint-disable-next-line import/no-extraneous-dependencies
const { generate } = require('@ant-design/colors');

// 获取品牌色系
export const getBrandColors = (color, mode) => {
  return generate(color, mode);
};

// 获取功能色系
export const getFunctionalColors = (color, mode) => {
  const { successColor, warningColor, dangerColor, infoColor } = color;
  const successColors = generate(successColor, mode);
  const warningColors = generate(warningColor, mode);
  const dangerColors = generate(dangerColor, mode);
  const infoColors = generate(infoColor, mode);
  return {
    successColors,
    warningColors,
    dangerColors,
    infoColors,
  };
};

// 输出色板
export const modifyVars = (configColor, themeMode) => {
  // 根据项目需求
  ......
  return modeColors;
};

/**
 * @description 设置主题
 * @param configColor
 * @param themeMode
 */
export const setThemeColors = (configColor, themeMode) => {
  if (typeof window !== 'undefined') {
    document.documentElement.setAttribute('data-theme', themeMode.theme);
    cssVars({
      watch: true,
      variables: modifyVars(configColor, themeMode),
      onlyLegacy: true,
    });
  }
};

再进一步封装一下:

import {
  modifyVars,
  getFunctionalColors,
  getBrandColors,
  setThemeColors,
} from 'utils/setTheme/variable';
import cssVars from 'css-vars-ponyfill';

interface IConfigColorProps {
  brandColor: string;
  infoColor: string;
  successColor: string;
  warningColor: string;
  dangerColor: string;
  grayColor1: string;
  grayColor2: string;
  grayColor3: string;
  grayColor4: string;
  grayColor5: string;
  grayColor6: string;
  grayColor7: string;
  grayColor8: string;
  grayColor9: string;
  grayColor10: string;
  grayColor11: string;
  grayColor12: string;
  grayColor13: string;
}

interface IThemeModeProps {
  theme?: 'dark' | 'default';
  backgroundColor?: string;
}

interface IFunctionalColor {
  infoColor: string;
  successColor: string;
  warningColor: string;
  dangerColor: string;
}

export class Theme {
  configColor: IConfigColorProps;

  themeMode: IThemeModeProps;

  constructor(configColor: IConfigColorProps, themeMode: IThemeModeProps) {
    this.configColor = configColor;
    this.themeMode = themeMode;
  }

  /**
   * @description 获取色板
   * @param configColor
   * @param themeMode
   */
  getPalette(configColor?: IFunctionalColor, themeMode?: IThemeModeProps) {
    return modifyVars(configColor || this.configColor, themeMode || 			this.themeMode);
  }

  /**
   * @description 获取功能色系
   * @param configColor
   * @param themeMode
   */
  getFunctionalColor = (configColor?: IFunctionalColor, themeMode?: IThemeModeProps) => {
    return getFunctionalColors(configColor || this.configColor, themeMode || this.themeMode);
  };

  /**
   * @description 获取品牌色系
   * @param color
   * @param mode
   */
  getBrandColor = (color?: string, mode?: IThemeModeProps) => {
    return getBrandColors(color || this.configColor.brandColor, mode || this.themeMode);
  };

  /**
   * @description 设置主题
   * @param configColor
   * @param themeMode
   */
  setTheme = (configColor?: IFunctionalColor, themeMode?: IThemeModeProps) => {
    setThemeColors({ ...this.configColor, ...configColor }, { ...this.themeMode, ...themeMode });
  };

  /**
   * @description 设置品牌色
   * @param color
   */
  setBrandColor = (color: string) => {
    this.configColor.brandColor = color;
    this.setTheme();
  };

  /**
   * @description 设置提示色
   * @param color
   */
  setInfoColor = (color: string) => {
    this.configColor.infoColor = color;
    this.setTheme();
  };

  /**
   * @description 设置成功色
   * @param color
   */
  setSuccessColor = (color: string) => {
    this.configColor.successColor = color;
    this.setTheme();
  };

  /**
   * @description 设置警告色
   * @param color
   */
  setWarningColor = (color: string) => {
    this.configColor.warningColor = color;
    this.setTheme();
  };

  /**
   * @description 设置失败色
   * @param color
   */
  setDangerColor = (color: string) => {
    this.configColor.dangerColor = color;
    this.setTheme();
  };

  /**
   * @description 设置功能色
   * @param colors
   */
  setFunctionalColors = (colors: IFunctionalColor) => {
    this.configColor = { ...this.configColor, ...colors };
    this.setTheme();
  };
}

/**
 * @description 获取色板
 * @param configColor
 * @param themeMode
 */
export const getPalette = (configColor: IConfigColorProps, themeMode: IThemeModeProps) => {
  return modifyVars(configColor, themeMode);
};

/**
 * @description 获取功能色系
 * @param configColor
 * @param themeMode
 */
export const getFunctionalColor = (configColor: IFunctionalColor, themeMode: IThemeModeProps) => {
  return getFunctionalColors(configColor, themeMode);
};

/**
 * @description 获取品牌色系
 * @param color
 * @param mode
 */
export const getBrandColor = (color: string, mode: IThemeModeProps) => {
  return getBrandColors(color, mode);
};

/**
 * @description 设置主题
 * @param configColor
 * @param themeMode
 */
export const setTheme = (configColor: IFunctionalColor, themeMode: IThemeModeProps) => {
  setThemeColors(configColor, themeMode);
};

export default { getPalette, getFunctionalColor, getBrandColor, setTheme };

再写一个能自动生成CSS变量文件的

const fs = require('fs');
let path = require('path');
const {modifyVars, configColor, themeMode} = require('./config');

const createTheme = () => {
  const root = path.dirname(__filename) + '/theme';
  fs.mkdir(root, {recursive: true}, function(e) {
    if (e) {
      return false;
    }
    console.log(`==== 生成样式变量文件 ====`);
    
    const content = modifyVars(configColor, themeMode);

    fs.mkdir(root, {recursive: true}, function(e) {
      if (e) {
        return false;
      }

      fs.writeFile(`${root}/vars.less`, content, 'utf8', (error) => {
        if (error) {
          return false;
        }
      });
    }).catch(e => {
      console.log(e);
      console.log(`==== ${rootDescirption} 输出失败 ====`);
      return false;
    });

  });
};

createTheme();

这样差不多可以用了,不过,后续可能会考虑通过文件引入加载的方式来进行优化或者结合一些类似styled-componentsstyled-jsx来进行优化,基于现有框架看看怎么再做得完善一点。

参考资料

接到“网站动态换主题”的需求,我是如何踩坑的

关于前端主题切换的思考和现代前端样式的解决方案落地

一文总结前端换肤换主题