Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

聊一聊前端换肤 #70

Open
jyzwf opened this issue Mar 17, 2019 · 4 comments
Open

聊一聊前端换肤 #70

jyzwf opened this issue Mar 17, 2019 · 4 comments

Comments

@jyzwf
Copy link
Owner

jyzwf commented Mar 17, 2019

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

一般实现

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

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

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

ElementUI 的实现

image

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

下面我具体讲下我参考它的原理的实现过程 (我们的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:
image

图2,黑色字即为颜色正则表达式:
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:如果你还有其他换肤的方式,或者上面有说到不妥的地方,欢迎补充与交流🤝🤝

@drvli
Copy link

drvli commented May 23, 2019

less那个方案在生产环境也可以用吗

@jyzwf
Copy link
Owner Author

jyzwf commented May 24, 2019

@dr000 可以啊,,antd 就是用的 less

@mchenyang
Copy link

那antd的自定义颜色怎么实现呢

@eaTong
Copy link

eaTong commented Dec 31, 2020

做了个webpack的插件,大致原理是:定义几个原始颜色,然后在打包过程中解析css样式,最后换肤的时候直接用最简化的css样式生成新的style 插入到DOM中。
详见:webpack-stylesheet-variable-replacer-plugin

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants