聊一聊前端換膚

大搜車無線團隊發表於2019-04-03

更多文章,參見大搜車技術部落格:blog.souche.com/

大搜車無線開發中心持續招聘中,前端,Nodejs,android 均有 HC,簡歷直接發到:sunxinyu@souche.com

之前在做網站換膚,所以想談談網站換膚的實現。網頁換膚就是修改顏色值,因此重點就在於怎麼來替換。

一般實現

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:如果你還有其他換膚的方式,或者上面有說到不妥的地方,歡迎補充與交流??

相關文章