聊一聊前端换肤

更多文章,参见大搜车技术博客:blog.souche.com/javascript

大搜车无线开发中心持续招聘中,前端,Nodejs,android 均有 HC,简历直接发到:sunxinyu@souche.comcss

以前在作网站换肤,因此想谈谈网站换肤的实现。网页换肤就是修改颜色值,所以重点就在于怎么来替换。html

通常实现

image
如上图,咱们会看到在某些网站的右上角会出现这么几个颜色块,点击不一样的颜色块,网站的总体颜色就被替换了。要实现它,咱们考虑最简单的方式: 点击不一样的按钮切换不一样的样式表 ,如:

  • theme-green.css
  • theme-red.css
  • theme-yellow.css

能够看出,咱们须要为每一个颜色块编写样式表,那若是我要实现几百种或者让用户自定义呢,显而易见这种方式十本笨拙,且拓展性并不高,另外,若是考虑加载的成本,那其实这种方式并不可取。前端

ElementUI 的实现

image

ElementUI 的实现比上面的实现高了好几个level,它能让用户自定义颜色值,并且展现效果也更加优雅。当前个人实现就是基于它的思路来实现。 咱们来看看他是怎么实现的(这里引用的是官方的实现解释):vue

下面我具体讲下我参考它的原理的实现过程 (咱们的css 编写是基于 postcss 来编写的):java

  1. 先肯定一个主题色,其余需在在换肤过程当中随主题色一块儿修改的颜色值就根据主题色来调用例如(上面已经说到了咱们是基于postcss来编写的,因此就使用了以下函数来计算颜色值): tint(var(--color-primary), 20%)darken(var(--color-primary), 15%)shade(var(--color-primary), 5%) 等。这也相似就实现了上面的第一步
  2. 而后根据用户选择的颜色值来生成新的一轮对应的一系列颜色值: 这里我先把所有css文件中能够经过主题色来计算出其余颜色的颜色值汇总在一块儿,以下:
// formula.js
const formula = [
    {
        name: 'hoverPrimary',
        exp: 'color(primary l(66%))',
    },
    {
        name: 'clickPrimary',
        exp: 'color(primary l(15%))',
    },
    {
        name: 'treeBg',
        exp: 'color(primary l(95%))',
    },
    {
        name: 'treeHoverBg',
        exp: 'color(primary h(+1) l(94%))',
    },
    {
        name: 'treeNodeContent',
        exp: 'color(primary tint(90%))',
    },
    {
        name: 'navBar',
        exp: 'color(primary h(-1) s(87%) l(82%))',
    }  
];

export default formula;
复制代码

这里的color函数 是后面咱们调用了 css-color-function 这个包,其api使然。react

既然对应关系汇总好了,那咱们就来进行颜色值的替换。在一开始进入网页的时候,我就先根据默认的主题色根据 formula.js 中的 计算颜色汇总表 生成对应的颜色,以便后面的替换,在这过程当中使用了css-color-function 这个包,android

import Color from 'css-color-function';

componentDidMount(){
this.initColorCluster = ['#ff571a', ...this.generateColors('#ff571a')];
        // 拿到全部初始值以后,由于咱们要作的是字符串替换,因此这里利用了正则,结果值如图2:
        this.initStyleReg = this.initColorCluster  
            .join('|')
            .replace(/\(/g, '\\(') // 括号的转义
            .replace(/\)/g, '\\)')
            .replace(/0\./g, '.');  // 这里替换是由于默认的css中计算出来的值透明度会缺省0,因此索性就直接所有去掉0
}

generateColors = primary => {
        return formula.map(f => {
            const value = f.exp.replace(/primary/g, primary);  // 将字符串中的primary 关键字替换为实际值,以便下一步调用 `Color.convert`
            return Color.convert(value);     // 生成一连串的颜色值,见下图1,能够看见计算值所有变为了`rgb/rgba` 值
        });
    };
复制代码

图1: git

image

图2,黑色字即为颜色正则表达式: github

image

好了,当咱们拿到了原始值以后,就能够开始进行替换了,这里的替换源是什么?因为咱们的网页是经过以下 内嵌style标签 的,因此替换原就是全部的style标签,而 element 是直接去请求网页 打包好的的css文件

image

注:并非每次都须要查找全部的 style 标签,只须要一次,而后,后面的替换只要在前一次的替换而生成的 style 标签(使用so-ui-react-theme来作标记)中作替换

下面是核心代码:

changeTheme = color => {
        // 这里防止两次替换颜色值相同,省的形成没必要要的替换,同时验证颜色值的合法性
        if (color !== this.state.themeColor && (ABBRRE.test(color) || HEXRE.test(color))) {
            const styles =
                document.querySelectorAll('.so-ui-react-theme').length > 0
                    ? Array.from(document.querySelectorAll('.so-ui-react-theme')) // 这里就是上说到的
                    : Array.from(document.querySelectorAll('style')).filter(style => {  // 找到须要进行替换的style标签
                          const text = style.innerText;
                          const re = new RegExp(`${this.initStyleReg}`, 'i');
                          return re.test(text);
                      });

            const oldColorCluster = this.initColorCluster.slice();
            const re = new RegExp(`${this.initStyleReg}`, 'ig');  // 老的颜色簇正则,全局替换,且不区分大小写

            this.clusterDeal(color);  // 此时 initColorCluster 已经是新的颜色簇

            styles.forEach(style => {
                const { innerText } = style;
                style.innerHTML = innerText.replace(re, match => {
                    let index = oldColorCluster.indexOf(match.toLowerCase().replace('.', '0.'));

                    if (index === -1) index = oldColorCluster.indexOf(match.toUpperCase().replace('.', '0.'));
                    // 进行替换
                    return this.initColorCluster[index].toLowerCase().replace(/0\./g, '.');
                });

                style.setAttribute('class', 'so-ui-react-theme');
            });
          

            this.setState({
                themeColor: color,
            });
        }
    };
复制代码

效果以下:

image

至此,咱们的颜色值替换已经完成了。正如官方所说,实现原理十分暴力😂,同时感受使用源css经过 postcss 编译出来的颜色值很差经过 css-color-function 这个包来计算的如出一辙,好几回我都是对着 rgba 的值一直在调🤣🤣,( 👀难受

antd 的实现

antd 的样式是基于 less 来编写的,因此在作换肤的时候也利用了 less 能够直接 编译css 变量 的特性,直接上手试下。页面中顶部有三个色块,用于充当颜色选择器,下面是用于测试的div块。

image

下面div的css 以下,这里的 @primary-color@bg-color 就是 less 变量:

.test-block {
    width: 300px;
    height: 300px;
    text-align: center;
    line-height: 300px;
    margin: 20px auto;
    color: @primary-color;
    background: @bg-color;
}
复制代码

当咱们点击三个色块的时候,直接去加载 less.js,具体代码以下(参考antd的实现):

import React from 'react';
import { loadScript } from '../../shared/utils';
import './index.less';
const colorCluters = ['red', 'blue', 'green'];

export default class ColorPicker extends React.Component {
    handleColorChange = color => {
        const changeColor = () => {
            window.less
                .modifyVars({  // 调用 `less.modifyVars` 方法来改变变量值
                    '@primary-color': color,
                    '@bg-color': '#2f54eb',
                })
                .then(() => {
                    console.log('修改为功');
                });
        };
        const lessUrl =
            'https://cdnjs.cloudflare.com/ajax/libs/less.js/2.7.2/less.min.js';

        if (this.lessLoaded) {
            changeColor();
        } else {
            window.less = {
                async: true,
            };

            loadScript(lessUrl).then(() => {
                this.lessLoaded = true;
                changeColor();
            });
        }
    };

    render() {
        return (
            <ul className="color-picker"> {colorCluters.map(color => ( <li style={{ color }} onClick={() => { this.handleColorChange(color); }}> color </li> ))} </ul>
        );
    }
}
复制代码

而后点击色块进行试验,发现并无生效,这是why?而后就去看了其文档,原来它会找到全部以下的less 样式标签,而且使用已编译的css同步建立 style 标签。也就是说咱们必须吧代码中全部的less 都如下面这种link的方式来引入,这样less.js 才能在浏览器端实现编译。

<link rel="stylesheet/less" type="text/css" href="styles.less" />
复制代码

这里我使用了 create-react-app ,因此直接把 less 文件放在了public目录下,而后在html中直接引入:

image

image

点击blue色块,能够看见 colorbackground 的值确实变了:

image

而且产生了一个 id=less:color 的style 标签,里面就是编译好的 css 样式。紧接着我又试了link两个less 文件,而后点击色块:

image

从上图看出,less.js 会为每一个less 文件编译出一个style 标签。 接着去看了 antd 的实现,它会调用 antd-theme-generator 来把全部antd 组件 或者 文档 的less 文件组合为一个文件,并插入html中,有兴趣的能够去看下 antd-theme-generator 的内部实现,可让你更加深刻的了解 less 的编程式用法。

注:使用less 来实现换肤要注意 less 文件html 中编写的位置,否则极可能被其余css 文件所干扰致使换肤失败

基于 CSS自定义变量 的实现

先来讲下 css自定义变量 ,它让我拥有像less/sass那种定义变量并使用变量的能力,声明变量的时候,变量名前面要加两根连词线(--),在使用的时候只须要使用var()来访问便可,看下效果:

image

若是要局部使用,只须要将变量定义在 元素选择器内部便可。具体使用见使用CSS变量关于 CSS 变量,你须要了解的一切

使用 css 自定义变量 的好处就是咱们可使用 js 来改变这个变量:

  • 使用 document.body.style.setProperty('--bg', '#7F583F'); 来设置变量
  • 使用 document.body.style.getPropertyValue('--bg'); 来获取变量
  • 使用 document.body.style.removeProperty('--bg'); 来删除变量

有了如上的准备,咱们基于 css 变量 来实现的换肤就有思路了:将css 中与换肤有关的颜色值提取出来放在 :root{} 中,而后在页面上使用 setProperty 来动态改变这些变量值便可。

上面说到,咱们使用的是postcss,postcss 会将css自定义变量直接编译为肯定值,而不是保留。这时就须要 postcss 插件 来为咱们保留这些自定义变量,使用 postcss-custom-properties,而且设置 preserve=true 后,postcss就会为咱们保留了,效果以下:

image

image

这时候就能够在换肤颜色选择以后调用 document.body.style.setProperty 来实现换肤了。

不过这里只是替换一个变量,若是须要根据主颜色来计算出其余颜色从而赋值给其余变量就可能须要调用css-color-function 这样的颜色计算包来进行计算了。

import colorFun from "css-color-function"

document.body.style.setProperty('--color-hover-bg', colorFun.convert(`color(${value} tint(90%))`));
复制代码

其postcss的插件配置以下(如需其余功能可自行添加插件):

module.exports = {
    plugins: [
        require('postcss-partial-import'),
        require('postcss-url'),
        require('saladcss-bem')({
            defaultNamespace: 'xxx',
            separators: {
                descendent: '__',
            },
            shortcuts: {
                modifier: 'm',
                descendent: 'd',
                component: 'c',
            },
        }),

        require('postcss-custom-selectors'),
        require('postcss-mixins'),
        require('postcss-advanced-variables'),
        require('postcss-property-lookup'),
        require('postcss-nested'),
        require('postcss-nesting'),
        require('postcss-css-reset'),
        require('postcss-shape'),
        require('postcss-utils'),

        require('postcss-custom-properties')({
            preserve: true,
        }),

        require('postcss-calc')({
            preserve: false,
        }),
    ],
};
复制代码

聊下 precsspostcss-preset-env

它们至关于 babelpreset

precss 其包含的插件以下:

使用以下配置也能达到相同的效果,precss 的选项是透传给上面各个插件的,因为 postcss-custom-properties 插件位于 postcss-preset-env 中,因此只要按 postcss-preset-env 的配置来便可:

plugins:[
require('precss')({
            features: {   
                'custom-properties': {
                    preserve: true,
                },
            },
        }),
]
复制代码

postcss-preset-env 包含了更多的插件。这了主要了解下其 stage 选项,由于当我设置了stage=2 时(precss 中默认 postcss-preset-envstage= 0 ),个人 字体图标 居然没了:

image

这就很神奇,因为没有往 代码的编写 上想,就直接去看了源码

它会调用 cssdb,它是 CSS特性 的综合列表,能够到各个css特性 在成为标准过程当中现阶段所处的位置,这个就使用 stage 来标记,它也能告知咱们该使用哪一种 postcss 插件 或者 js包 来提早使用css 新特性。cssdb 包的内容的各个插件详细信息举例以下

{ id: 'all-property',
    title: '`all` Property',
    description:
     'A property for defining the reset of all properties of an element',
    specification: 'https://www.w3.org/TR/css-cascade-3/#all-shorthand',
    stage: 3,
    caniuse: 'css-all',
    docs:
     { mdn: 'https://developer.mozilla.org/en-US/docs/Web/CSS/all' },
    example: 'a {\n all: initial;\n}',
    polyfills: [ [Object] ] }
复制代码

当咱们设置了stage的时候,就会去判断 各个插件的stage 是否大于等于设置的stage,从而筛选出符合stage的插件集来处理css。最后我就从stage小于2的各个插件一个一个去试,终于在 postcss-custom-selectors 时候试成功了。而后就去看了该插件的功能,难道我字体图标的定义也是这样?果真如此:

image

总结

上面介绍了四种换肤的方法,我的更加偏向于 antd 或者基于 css 自定义变量 的写法,不过 antd 基于 less 在浏览器中的编译,less 官方文档中也说到了:

This is because less is a large javascript file and compiling less before the user can see the page means a delay for the user. In addition, consider that mobile devices will compile slower.

因此编译速度是一个要考虑的问题。而后是 css 自定义变量 要考虑的可能就是浏览器中的兼容性问题了,不过感受 css 自定义变量 的支持度仍是挺友好了的🤣🤣。

ps:若是你还有其余换肤的方式,或者上面有说到不妥的地方,欢迎补充与交流🤝🤝