聊一聊前端换肤

917 阅读10分钟
原文链接: mp.weixin.qq.com

之前在做网站换肤,所以今天想聊聊网站换肤的实现。网页换肤就是修改颜色值,因此重点就在于怎么来替换。

一般实现

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

  • theme-green.css

  • theme-red.css

  • theme-yellow.css

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

ElementUI 的实现

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

  • 先把默认主题文件中涉及到颜色的 CSS 值替换成关键词:https://github.com/ElementUI/theme-preview/blob/master/src/app.vue#L250-L274

  • 根据用户选择的主题色生成一系列对应颜色: https://github.com/ElementUI/theme-preview/blob/master/src/utils/formula.json

  • 把关键词再换回刚刚生成的相应的颜色值:https://github.com/ElementUI/theme-preview/blob/master/src/utils/color.js

  • 直接在页面上加 style 标签,把生成的样式填进去:https://github.com/ElementUI/theme-preview/blob/master/src/app.vue#L198-L211

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

  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使然。

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

    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:

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

好了,当我们拿到了原始值之后,就可以开始进行替换了,这里的替换源是什么?由于我们的网页是通过如下 内嵌style标签 的,所以替换原就是所有的 style标签,而 element 是直接去请求网页 打包好的的css文件

注:并不是每次都需要查找所有的 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,

    });

    }

    };

效果如下:

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

antd的实现

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

下面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中直接引入:

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

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

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

基于CSS自定义变量的实现

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

如果要局部使用,只需要将变量定义在 元素选择器内部即可。具体使用见使用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就会为我们保留了,效果如下:

这时候就可以在换肤颜色选择之后调用 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,

    }),

    ],

    };

聊下 precss 和 postcss-preset-env

它们相当于 babelpreset

precss 其包含的插件如下:

  • postcss-extend-rule

  • postcss-advanced-variables

  • postcss-preset-env

  • postcss-atroot

  • postcss-property-lookup

  • postcss-nested

使用如下配置也能达到相同的效果,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 ),我的 字体图标 竟然没了:

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

它会调用 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 时候试成功了。然后就去看了该插件的功能,难道我字体图标的定义也是这样?果然如此:

总结

上面介绍了四种换肤的方法,个人更加偏向于 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:如果你还有其他换肤的方式,或者上面有说到不妥的地方,欢迎补充与交流🤝🤝