聊一聊前端換膚

更多文章,參見大搜車技術博客: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:若是你還有其餘換膚的方式,或者上面有說到不妥的地方,歡迎補充與交流🤝🤝

相關文章
相關標籤/搜索