好好唠唠前端换肤那些事

7,228 阅读12分钟

    开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

前言

    大家好,我是张添财。在我们业务开发中,免不了要接到换肤这种业务需求。那咱接下来就好好唠一唠前端一键换肤的方案实现。首先要说明的是,这里我用的是react项目来做主题切换的demo,项目目录见文章结尾。好了,咱们话不多说,抓紧发车。

壹、less.modifyVars实现换肤

    从实际开发出发,我们先来介绍下利用less.modifyVars(毕竟咱项目里都是用的预处理器嘛)来进行一键换肤。

    首先我们要了解less.modifyVars为什么可以进行一键换肤,less的modifyVars方法是是基于 less 在浏览器中的编译来实现。并且less.modifyVars是将我们设置的样式变量进行替换从而实现新样式变量覆盖旧样式变量从而实现改变css的展现效果。

    从上述我们就大致知道用less.modifyVars进行一键换肤的几个关键点:

  1. 由于是基于less在浏览器中的编译实现,所以我们引入全局样式文件时候需要通过link方式引入 , 也就是说不要在工程化配置里配死全局变量,否则less.modifyVars是替换不掉的。(静态切换主题的方案另说)

  2. 既然是link引入且根据less.modifyVars动态改,那么我们也不能在业务组件样式文件里直接@import样式变量文件,否则我们耦合在业务组件样式文件里的变量就固定是引入文件的变量了,less.modifyVars同样也无法进行替换更改。


    说明完注意事项之后,我们就可以开始进行代码实现了。当然,实现之前我们先梳理一下逻辑:利用less.modifyVars实现动态换肤拢共分三步:请客、斩首、收下当狗... 不好意思串台了,方案大致分三步:

  1. 我们首先需要设置全局样式文件,并在index.html进行引入;
  2. 其次编写替换的变量文件,并在less.modifyVars调用时相应传入;
  3. 最后,将我们的全局变量写入要进行换肤元素的className中。 上述完成后,就可以在less.modifyVars方法调用后实现换肤功能了。

    好了,话不多说,继续发车发车。下面是我们的具体实现步骤:

一、全局样式文件设置

    我们首先需要创建一个styles文件夹,styles文件夹主要是用来设置我们编写全局变量相关的文件。在此文件夹下存放的是我们的样式变量文件-variable.less、全局应用的样式文件-global.less、styles文件夹的入口文件index.less。styles文件夹下各文件内容如下所示:

variable.less 设置样式变量:

// 背景色
@primary-color:rgb(176, 68, 58);
// 字号
@font-size: 22px;
// 字体颜色
@font-color: rgb(178, 199, 72);
// 内容背景色
@primary-color-content:rgb(20, 136, 165);

global.less 设置样式变量类名:

@import './variable.less';

.bgc{
    background: @primary-color;
}

.contentBg{
    background: @primary-color-content;
}

.txt{
    font-size: @font-size;
    color:  @font-color;
}

在styles文件夹下的index.less入口文件导入相关文件:

@import './global.less';

index.html 引入样式:

    在项目的index.html 文件引入全局变量样式文件index.less (这一步不引入,样式不会生效,less.modifyVars自然也无法生效) :

 <head>
    <meta charset="utf-8" />
     ...
   
      //  rel关联的样式表别引错了
    <link rel="stylesheet/less" type="text/css" href="/styles/index.less" />

     ...
    <title>React App</title>
  </head>

补充说明: 进行到这里有的小伙伴可能会有疑问,浏览器是不认识less的,less是经过编译处理成css文件才能被浏览器认识。这里直接link一个less文件其实是不生效的。也就是说此时我们直接在业务组件中使用全局变量是无论如何都不生效的。

    那怎么解决这个问题呢?有的小伙伴可能会说那直接用script执行less文件去解析不就可以了。确实,为了引入大小考虑我们不会把less文件整个引入,而是常常在项目里创建 less.min.js 去解析less文件。

less.min.js 下载地址:github.com/less/less.j…

    此处我们在styles文件夹同级目录下创建 less.min.js , 并将 github上 less.min.js 文件内容贴进去。然后再在index.html 导入即可,此时全局文件less就可以生效了。

 <head>
    <meta charset="utf-8" />
     ...
   
  
    <link rel="stylesheet/less" type="text/css" href="/styles/index.less" />
  	<script type="text/javascript" src="/less.min.js"></script>

     ...
    <title>React App</title>
  </head>

    一顿操作完之后咱还是要思考一下的,这里真的有必要用 script 引入less.min.js吗?那回答必须是

image.png

原因有二:

1、我们已经下载了less依赖了,我直接在项目的入口文件index.js 引入 import less from 'less' 不就可以了嘛。

2、咱也没强迫症,既然link引入less文件浏览器不认识,那我直接把styles文件夹的入口文件从 index.less 改为index.css 不就可以了,既不用创建less.min.js文件,也不用非得在项目入口文件引入 less,而是哪里用到less方法再引入。

    这里我推荐直接改成index.css,当然如果非要保持队形一致那咱就还用index.less。额外加处理就行。

二、设置替换文件以及编写切换主题方法

1、设置全局变量替换文件-theme.js

const selectOne = {
    '@primary-color': 'rgb(39, 155, 33)',
    '@font-size': '22px',
    '@font-color': 'rgb(199, 72, 127)',
    '@primary-color-content': 'rgb(188, 217, 19)',
}

const selectTwo = {
    '@primary-color': 'rgb(205, 61, 14)',
    '@font-size': '22px',
    '@font-color': 'rgb(138, 72, 199)',
    '@primary-color-content': 'rgb(19, 217, 204)',
}

const selectThree = {
    '@primary-color': 'rgb(227, 36, 179)',
    '@font-size': '22px',
    '@font-color': 'rgb(171, 144, 134)',
    '@primary-color-content': 'rgb(35, 20, 20)',
}

const mapToTheme = [selectOne, selectTwo, selectThree]

export default mapToTheme

2、编写切换主题方法:

    利用less.modifyVars来编写切换主题变量的方法-changeThemeVars.js

import less from 'less';

export const changeTheme = (themeVarConfig) => {
  // 调用 `less.modifyVars` 方法来改变less变量值
    less.modifyVars({  
        ...themeVarConfig,
    }).then(() => {
        console.log('主题切换成功');
    })
}

三 设置全局变量位置,绑定切换主题事件

    在这一部分中,我们只需要将我们的全局变量设置到业务代码中即可。这里我们用一个商品展示卡片作为demo 进行演示。

    如下代码,我们将全局变量分别加在了wrap、card和Info对应的元素上,当我们勾选radio时,changeTheme方法会将我们具体映射的theme样式变量值进行覆盖,这样就实现了动态切换主题色的功能。

    如果我们给changeTheme方法传进去空值(也即less.modifyVars({})),此时主题色会去取我们最开始设置的颜色。如本例中选择主题4一样。

文件路径: src->pages->TestDemo->index.jsx

import { changeTheme } from "@/utils/changeThemeVars";
import themeConstants from "@/constants/theme";
import styles from "./index.module.less";

export default function Test() {
  return (
    <div className={`${styles.wrap} txt`}>
      <div className={`${styles.card} bgc`}>商品图片</div>
      <div className={`${styles.info} contentBg`}>商品介绍</div>
      {[...Array(4).keys()].map((item) => {
        return (
          <>选择主题{item + 1}:
            <input
              type="radio"
              name="theme"
              value="male"
              onClick={() => changeTheme(themeConstants[item])}
            />
            <br /> </> ); })}
    </div>
  );
}

样式文件:

.wrap {
    font-size: 30px;

    .card {
        width: 200px;
        height: 250px;
        text-align: center;
    }

    .info {
        .card();
        width: 200px;
        height: 100px;
    }

}

效果图:

    至此,利用less.modifyVars 方法进行动态切换主题色的介绍就落入尾声。但此时可能有的小伙伴要说,这种是项目支持less才可以使用的。那如果我现有的项目是sass或者stylus该怎么搞呢,总不能重新换成less预处理器吧。咱就是说,那没必要。既然咱用不了less,那不还有css可以用嘛,这些预处理器终归是要转成css的。好了,接下来就进入下一part--- 利用 css 变量进行切换主题色。

贰、css vars 切换主题色

    利用css变量来进行主题切换有个好处就是非常通用,无关你项目里用的是什么处理器。老规矩,咱们还是先来分析一下css变量进行主题切换的逻辑。在分析之前,我们先来看一下css变量的使用:

    css变量声明也分全局变量和局部变量,全局变量声明使用如下:

1、 全局变量声明:

:root {
  /* // 背景色 */
--primary-color:rgb(176, 68, 58);
/* // 字号 */
--font-size: 22px;
/* // 字体颜色 */
--font-color: rgb(178, 199, 72);
/* // 内容背景色 */
--primary-color-content:rgb(20, 136, 165);
}

2、在根页面引入:

 <head>
    <meta charset="utf-8" />
     ...
   
      //  rel关联的样式表别引错了
    <link rel="stylesheet" type="text/css" href="/styles/demo.css" />

   
     ...
    <title>React App</title>
  </head>

3、 在业务文件中引入

.wrap {
    font-size: var(--font-size);
    background-color: var(--primary-color);
    color: var(--font-color);
}

    局部变量声明如下:

1、 局部变量声明:

.block {
  /* // 背景色 */
--primary-color:rgb(176, 68, 58);
/* // 字号 */
--font-size: 22px;
/* // 字体颜色 */
--font-color: rgb(178, 199, 72);
/* // 内容背景色 */
--primary-color-content:rgb(20, 136, 165);
}

2、根页面引入:

 <head>
    <meta charset="utf-8" />
     ...
    <link rel="stylesheet" type="text/css" href="/styles/demo.css" />
     ...
    <title>React App</title>
  </head>

3、业务组件中使用:

    这里与全局变量的使用不同,由于.module 是将样式分模块了,我们在.module 文件里直接写.block 去用变量是不生效的,因为它在业务里被转成其他变量了。解决方法是在组件里写死 block,或者加上:global{ .block }。 这里我在业务组件里写死类名block:

业务组件样式文件:

.wrap {
    font-size: var(--font-size);
    background-color: var(--primary-color);
    color: var(--font-color); 
  }

业务组件代码:

从下面代码我们看出,业务组件处于局部变量作用域范围内,局部变量生效。

import styles from "./TestDemo.module.less";

export default function TestDemo() {
  return (
    <div className={`${styles.wrap} block`}>
     测试
    </div>
  );
}

    看完css全局变量和局部变量的使用相信有些小伙伴心里对主题切换实现的方案已经有些想法了。

    既然局部变量生效的前提是处于我们包裹局部变量作用域范围内,那么我们完全可以拿这个局部变量作用域范围来做文章。

    我们维护几份局部变量,根据不同的主题切换不同局部变量名,从而使不同的局部变量生效。这样也就达到了我们切换主题色的效果。好了,咱们说干就干,接下来咱们就来进行具体的代码实现。

一、全局样式文件设置

    老规矩,起手就是一个全局样式文件设置:我们在styles文件夹下创建variable.css 、 index.css 文件,在variable.css 放入我们的 css 局部变量和全局变量。声明局部变量为了进行我们的主题切换、设置全局变量则是为了支持我们默认的主题色相关配置。index.css 文件里则只需要引入variable.css 样式文件,并在根页面引入即可。

variable.css文件(实际开发可以拆分):

/* 全局变量,未切换时默认展示的主题色 */
:root {
    /* // 背景色 */
--primary-color:rgb(176, 68, 58);
/* // 字号 */
--font-size: 22px;
/* // 字体颜色 */
--font-color: rgb(178, 199, 72);
/* // 内容背景色 */
--primary-color-content:rgb(20, 136, 165);
}

/* 局部变量 */

.theme_one{
       /* // 背景色 */
--primary-color:rgb(39, 155, 33);
/* // 字号 */
--font-size: 22px;
/* // 字体颜色 */
--font-color: rgb(199, 72, 127);
/* // 内容背景色 */
--primary-color-content:rgb(188, 217, 19);
}


.theme_two{
      /* // 背景色 */
--primary-color:rgb(205, 61, 14);
/* // 字号 */
--font-size: 22px;
/* // 字体颜色 */
--font-color: rgb(138, 72, 199);
/* // 内容背景色 */
--primary-color-content:rgb(19, 217, 204);
}

.theme_three{
    /* // 背景色 */
--primary-color:rgb(227, 36, 179);
/* // 字号 */
--font-size: 22px;
/* // 字体颜色 */
--font-color: rgb(171, 144, 134);
/* // 内容背景色 */
--primary-color-content:rgb(35, 20, 20);
}

index.css 文件:

@import './variable.css';

    配置完样式变量后我们还是照常在根页面 index.html 中进行引入

 <head>
    <meta charset="utf-8" />
     ...
   
    <link rel="stylesheet" type="text/css" href="/styles/index.css" />
     ...
    <title>React App</title>
  </head>

二、编写切换主题色的方法

    在写代码之前,我们要先思考1个问题:类名应该设置业务中什么位置?

    之所以要提出这个问题,是因为我们局部变量生效的前提是使用局部变量的元素是处于作用域范围内的。如下图这两种情况:

  1. 假设block是我们声明局部变量的作用范围,则 情况1中我们就可以在 业务组件的 .test {} 中用 block里的局部变量;

  2. 如果我们写的如情况2所示,此时我们声明的变量就不会生效,这是因为情况2的test类名元素不被block包裹。

    这里我们得出了结论:在我们的业务开发中,要想随心所欲使用局部变量,那其必须尽可能包含整个项目组件。


    聊到这里,小伙伴们估计心里又想:这个我熟,直接把上图中的 block 类名设置在 root 根组件不就行了,那必是妥妥拿捏整个项目组件。之前咱也是这么想的,但真这么设置会导致在使用antd项目进行主题色切换时出问题:弹框类在进行主题色切换失效了。这又是什么情况呢??

各位看官,到这我们得先插播一个react的小知识---Portals(插槽) ,这里一句话概括下就是:Portals 是一种将子节点渲染到父组件以外的 DOM 节点的解决方案。感兴趣的小伙伴可以深入去了解下。下面是我们就来分析下这个弹窗换肤失效的问题:

    在antd中,弹框类的组件大都是用Portals来进行实现的。也就是说,弹框并不是在root根组件里的,而是在root根组件之外又创建的容器盒子。知道这个小tip,我们也就可以理解上述的问题:弹框类的组件不在css局部变量作用范围内,这就造成了弹框组件进行主题切换时局部变量并没有在弹窗组件生效,也正因此换肤功能也失效了。

image.png

    清楚了问题的根源,我们就很容易去解决了。既然局部变量作用的范围漏掉了弹框,那我们直接在最外围设置不就行了?该说不说,直接在body设置局部变量类名,这就能解决了antd弹窗切换主题色失效的问题。

    解决了弹框的问题,接下来如何设置我们的局部变量名就很简单了。如下所示:

export const changeTheme = (mode) => {
    let body = document.getElementsByTagName('body')[0];
    body.className = mode;
  };

三 、 在业务中使用

    我们把一些前置的工作完成之后,剩下的就是业务中哪里需要进行动态切换主题色,我们就将css变量写到哪就可以了。老规矩,还是用 less.modifyVars 那一part的 demo 演示:

import { changeTheme } from "@/utils/changeThemeVars";
import styles from "./index.module.less";

const themeClassConstants = ['theme_one','theme_two','theme_three']

export default function Test() {
  return (
    <div className={`${styles.wrap}`}>
      <div className={`${styles.card}`}>商品图片</div>
      <div className={`${styles.info}`}>商品介绍</div>

      {[...Array(4).keys()].map((item) => {
        return (
          <>
            选择主题{item + 1}:
            <input
              type="radio"
              name="theme"
              value="male"
              onClick={() => changeTheme(themeClassConstants[item])}
            />
            <br />
          </>
        );
      })}
    </div>
  );
}

样式文件:

.wrap {
    font-size: var(--font-size);
    color:var(--font-color);

  .card {
        width: 200px;
        height: 250px;
        text-align: center;
        background-color: var(--primary-color);
    }

    .info {
        .card();
        width: 200px;
        height: 100px;
        background-color: var(--primary-color-content);
    }
}

下面是效果图:

    进行到这里,我们css变量进行主题色动态切换的实现方法就快进入尾声了。但其实还有个小问题:

    如果我们刷新浏览器时,会发现我们设置的主题色又还原为默认设置了。这其实很好理解,当我们刷新时由于没有做数据持久化,导致刷新后的主题色是最初的默认色。解决方法也很简单,可以将当前选择的css变量类名做持久化处理即可,然后在我们项目入口文件主动调一下changeTheme方法就可以了。

    这里我用localStorage来对方案做一下改造,在TestDemo组件里改造下 onClick 事件,每次修改主题色之前先在本地存一下:

import { changeTheme } from "@/utils/changeThemeVars";
import styles from "./index.module.less";

const themeClassConstants = ['theme_one','theme_two','theme_three']

export default function Test() {
  return (

    	....
    
      {[...Array(4).keys()].map((item) => {
        return (
          <>
            选择主题{item + 1}:
            <input
              type="radio"
              name="theme"
              value="male"
              onClick={() =>{
      				localStorage.setItem('THEME',themeClassConstants[item])
              changeTheme(themeClassConstants[item])
              	}
              }
            />
            <br />
          </>
        );
      })}
    </div>
  );
}

    在 index.js 项目的入口文件里调用changeTheme方法,每次刷新都要获取存在本地的主题色值:

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import {changeTheme} from '@/utils/changeThemeVars'

changeTheme(localStorage.getItem('THEME'))

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
    <App />
);

    至此,我们利用css变量进行主题色切换就到此结束了。但是我们采用这种方案需要注意几点:

1、cssVars 这种方式容易和第三方组件库样式起冲突,所以我们在使用时要注意样式优先级的问题;

2、我们在设置局部变量作用域类名时,要尽量具有语义和唯一性,防止和业务组件类名冲突

3、IE11 不支持cssVar变量,考虑使用 css-vars-ponyfill 这个插件可以之间转换ie11中css的var变量解决兼容。github地址 : github.com/jhildenbidd…

叁、引入不同样式文件

    看完了上面两种切换主题色的方案,相信很多小伙伴对一键换肤也有了一定的见解。也可能会有人提出,我就不想这么麻烦,咱就是直接莽,可以不,那必须可以!

    切换主题色还有种最直白的方式,就是维护多份css样式文件,然后利用我们的link标签去引入不同样式文件从而实现我们主题色的切换,这种方式简单粗暴,干就完了。但咱还是得提前说明下,这种方式的缺点也是很明显:

① 我们切换主题色时涉及的样式一旦很多,那到时候我们维护多个样式文件就很麻烦。我们不仅要同时维护多个样式文件,还要考虑样式优先级、样式是否重复声明等问题。

② 我们使用JS改变 link 标签的 href 属性也会带来样式加载的延迟,如果涉及的样式很多的话,就会出现切换不流畅的问题。

    下面是我们利用link引入不同css样式文件进行切换主题色的流程:

1、维护多个css样式文件

index.css文件:

body .bgc{
    background-color: rgb(176, 68, 58);
    font-size: 22px;
    color: rgb(178, 199, 72);
}

body .contentBg{
    background-color: rgb(20, 136, 165);
}

theme_one.css文件:

body .bgc{

    background-color: rgb(39, 155, 33);
    font-size: 22px;
    color: rgb(199, 72, 127);

}

body .contentBg{
    background-color: rgb(188, 217, 19);
}

theme_two.css文件:

body .bgc{
  
       background-color: rgb(205, 61, 14);
       font-size: 22px;
       color: rgb(138, 72, 199);
   
   }
   
   body .contentBg{
       background-color: rgb(19, 217, 204);
   }

2、在index.html文件中引入样式文件

 <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />

   ...
    <link rel="stylesheet" type="text/css" href="/styles/index.css" id="test"/>
	 ...

3、在业务文件中引入类名

import styles from "./index.module.less";
import { changeTheme } from "@/utils/changeThemeVars";

const themeClassConstants = ['theme_one','theme_two','index']

export default function Test() {
  return (
    <div className={`${styles.wrap} `}>
      <div className={`${styles.card} bgc`}>商品图片</div>
      <div className={`${styles.info} contentBg`}>商品介绍</div>

      {[...Array(4).keys()].map((item) => {
        return (
          <>
            选择主题{item + 1}:
            <input
              type="radio"
              name="theme"
              value="male"
              onClick={() => {
                changeTheme(themeClassConstants[item])
              }}
            />
            <br />
          </>
        );
      })}
    </div>
  );
}

4、设置主题切换方法

    这里我们就是利用DOM操作来改变link标签的href属性,从而加载不同样式文件实现主题切换的效果

export const changeTheme = (mode) => {
    document.getElementById('test').href = `/styles/${mode}.css`;
  };

效果图如下:

写在最后

    至此,我们本篇也进行到了尾声。一键换肤拓展性很高,比如:我们主题色也可开放选色板,用户可以利用选色板来自定义系统的主题色;也可以进行静态切换,根据部署的不同环境来选择当前系统主题色;甚至我们也能利用node脚本来读写样式资源文件,从而实现当我们构建时来选定系统主题甚至是排版等。换肤的方案也绝不止本篇所举,这里添财也只是给各位一个参考,如果能帮到各位小伙伴那自然是极好的。

--- 路漫漫其修远兮,各位继续努力呀,咱们下回见!

本篇演示的项目目录如下所示:

|---node_modules   // 本项目的依赖包
|---public         // 公共文件,不想被打包工具编译的文件夹
   |---styles      // 切换主题色相关的样式文件夹
      |---index.css    // 入口css样式文件
      |---variable.css   // 变量文件
   |---favicon.ico    // 网页图标
   |---index.html     // 首页模板文件
   |---logoXXX.png
|---src     // 主项目的入口文件夹,逻辑都在此
   |---pages   // 放置业务界面的文件夹
      |---TestDemo   // 用来做demo的界面
         |---index.jsx   // TestDemo入口文件
         |---index.module.less   // TestDemo样式文件
   |---utils   //   工具函数相关
      |---changeThemeVars.js    // 切换主题色的方法
|---App.js   // App根组件
|---index.js  // 主项目入口文件,这里会挂载App根组件
|---config-overrides.js   // 用来覆盖webpack配置
|---jsconfig.json   // js配置文件
|---package.json   // 当前项目配置文件
|---README.md   // 项目说明文件