web區域性(antd)主題方案演進

Nekron發表於2017-09-17

背景

當web應用存在著預覽場景時,在進階體驗中勢必存在主題配色這樣的需求。

切換主題即整體修改網頁中各元素的樣式

成熟的ui庫一般都會將用到的關鍵css屬性抽離為單一檔案,以供開發者定製顏色、元素間距等。開發者通過修改預設變數值,打包成不同的主題樣式檔案,在預覽時使用主題相應的樣式檔案進行全域性替換即可達到主題切換的目的。

本文以下部分均以antd為例,其他諸如bootstrapmaterial-design-lite等實際開發工作均大同小異。

antd是由螞蟻金服官方維護的,主要針對企業級應用場景設計的前端元件庫

當然,預覽區域如果是隔離的(獨立頁面),那本文完結。。。

但預覽區域如果和配置區域耦合在一起,並使用了相同的ui庫,該如何應對?所以,誕生了區域性主題方案

方案制定

css通過規則權重來確認生效的樣式,後面的樣式定義覆蓋前面的:

.text {
    color: #000;
}

// 這樣做會導致預覽區的樣式汙染配置區的樣式
.text {
    color: #111;
}

// 常見做法是增加一層容器來增加權重
.theme-container .text {
    color: #111;
}複製程式碼

方案一:ui元件庫增加外層class

自己定義的屬性外層增加一個容器class尚不是難事,但如何為整個antd庫增加外層class?

方案二:預覽區作用域隔離

當然,也可以人為地將預覽區域隔離開來,使用iframeweb components方案都理論可行,不過解決區域性樣式問題的同時帶來了其他的問題需要攻克:

  1. 配置區域和預覽區域存在大量拖拽互動時,在技術上、體驗上是否可行?
  2. 將整個ui樣式庫全部內建入web components是否可行?

本文首先使用方案一進行實施,方案二還在繼續摸索中,待有突破性進展以後再做分享。

為antd庫增加外層class

如何定製antd主題

官方提供了幾種定製主題的方式,以下是專案中的具體實現:

// theme.less

// 由於antd內部使用了utf8編碼的文字元號而不是使用\u****的Unicode碼點表示
// 在cdn樣式檔案返回頭裡沒有明確指明utf-8時,可能存在文字元號亂碼的可能
// 編譯生成過程中通過css-loader也會自動轉換,而本文場景是直接編譯
// 所以,為了安全起見,最好指定編碼格式
@charset "utf-8";

@import "/path/to/node_modules/antd/dist/antd.less";

// 覆蓋變數定義
// 所有變數詳見 https://github.com/ant-design/ant-design/blob/master/components/style/themes/default.less
@primary-color: #F15B41;複製程式碼

由於需要給樣式增加外層class,postcss的外掛機制更能符合定製需求,於是使用postcss來處理less。編譯指令碼大致如下:

#!/usr/bin/env node
const fs = require("fs")
const postcss = require("postcss")
const less = require('postcss-less-engine')
const autoprefixer = require('autoprefixer')
const clean = require('postcss-clean')

const lessContent = fs.readFileSync('/path/to/theme.less', 'utf-8')

postcss([
    // 外掛配置
    less(),
    autoprefixer(),
    clean(),
]).process(lessContent, {
    parser: less.parser,
    src: '/path/to/theme.less',
}).then(result => {
    const cssContent = result.css

    fs.writeFileSync('/path/to/theme.css', cssContent)
})複製程式碼

這樣就能得到使用新配色的主題樣式檔案了。

@import的使用

目前,css預處理語言中我只使用過less/scss,antd使用的是less,本文會偶爾拿出scss來進行比較說明。

由於scss文件中明確說明了@import可以放於選擇器下,less文件中並沒有提到,一開始我擔心不支援該類語法。百聞不如一試,其實less也是支援的:

// theme.less
@charset "utf-8";
.theme-container {
    @import "/path/to/node_modules/antd/dist/antd.less";
    @primary-color: #F15B41;
}複製程式碼

現在所有的新主題樣式在theme-container下的權重更高,預覽區的樣式會覆蓋配置區的樣式,同時不影響配置區本身的樣式。

不過,通過檢視生成的css(開發階段可以先去除autoprefixer、clean兩個外掛)很快發現了問題。由於每個ui庫都會有自己的全域性樣式,如果將less統一塞在theme-container下會使得全域性樣式無效:

// 編譯出來的全域性css
.theme-container html {}

.theme-container body {}複製程式碼

不過這種問題整體範圍來說影響不大,即使無效也可能沒有大問題,出了問題只要簡單區分一下即可:

// theme.less
@charset "utf-8";

@import "/path/to/node_modules/antd/lib/style/index.less";

.theme-container {
    @import "/path/to/node_modules/antd/lib/style/components.less";
}

@primary-color: #F15B41;複製程式碼

這樣就結束了麼?可能一些ui庫已經搞定了,不過,antd的故事只是剛剛開始

難題重重

多個&(父類選擇器)的問題

&平時使用的很多,但大部分場景下不會在一條規則中使用複數個&,整個antd用到的其實也不多,但是這些地方就出了問題:

// 原版
.ant-alert {
    &&-no-icon {
        padding: 8px;
    }
}
// 編譯後
.ant-alert.ant-alert-no-icon {
    padding: 8px;
}

// 增加外層容器後
.theme-container {
    .ant-alert {
        &&-no-icon {
            padding: 8px;
        }
    }    
}
.theme-container .ant-alert.theme-container .ant-alert-no-icon {
  padding: 8px;
}複製程式碼

這個編譯結果是正確的,但不符合預期,造成了樣式失效。那如何克服呢?

&是否可以寫死父類選擇器的值?

這個方案可以實現,原本antd的less檔案內就有很多變數可供使用。

@alert: ant-alert;

.theme-container {
  .ant-alert {
    &.@{alert}-no-icon {
          padding: 8px;
      }
  }
}複製程式碼

&是否可以編譯為最近的父類?

我查了很久,當我看到這個issue#1075時,一個已經討論了快5年的issue仍然是open的。我就知道這個方案懸了。

其實裡面有很多設想,包括寫本文時最新關聯的這個issue#3053,都是解決該場景的一種設想。

而為什麼有這麼多奇淫技巧?因為scss是支援的。。。

我稍微花了些時間嘗試了下scss在該場景下的實現,原理大致是將&對應的選擇器內容作為入參,通過函式將選擇器內容進行自定義修改:

// 參考地址:
// https://medium.com/@jakobud/how-to-do-sass-grandparent-selectors-b8666dcaf961

// scss還不支援&&連寫,所以換個例子
.theme-container {
    .ant-collapse {
        & &-item-disabled {
            cursor: not-allowed;
        }
    }    
}
// 原本的編譯結果
.theme-container .ant-collapse .theme-container .ant-collapse-item-disabled {
  cursor: not-allowed;
}

// scss下可以這麼做
@function get_last_selector($str) {
    $selector: nth($str, 1);
    $last: nth($selector, length($selector));
    @return $last;
}
.theme-container {
    .ant-collapse {
        $parent: get_last_selector(&);
        & #{$parent}-item-disabled {
            cursor: not-allowed;
        }
    }    
}
// 編譯結果
.theme-container .ant-collapse .ant-collapse-item-disabled {
  cursor: not-allowed;
}複製程式碼

雖然上述的每種方案都理論可行,不過到最後都無法落地,因為涉及到的改動程式碼量很大,有些甚至需要修改原始碼,會導致維護成本過高。

方案調整

新增postcss外掛

postcss的編譯流程大致是:

less => css => optimze(autoprefix + clean)

由於一開始在less層面能區分開antd的全域性樣式和元件樣式,所以一直致力於在less階段解決問題。但經過了上面的種種波折,在less層面的所有方案基本都無法實施了。

包括antd,所有的ui庫都會有一個prefix頭來區分樣式,這其實也變相提供了在css層面區分開全域性樣式和元件樣式的能力。

所以,只需在css => optimze階段新增一個外掛來為每條元件樣式增加一個父類選擇器就可以實現區域性主題了!

即,最終流程會變為:

less => css => prefix => optimze

// theme.less
// 不需要再使用@import而帶來多&問題了
@charset "utf-8";

@import "/path/to/node_modules/antd/dist/antd.less";
@primary-color: #F15B41;

// 外掛程式碼
const prefixPlugin = postcss.plugin('prefix', (PREFIX = '.theme-container') => {
  const process = node => {
    node.walkRules(rule => {
      rule.selectors = rule.selectors.map(selector => {
        if (selector.startsWith('.ant')) {
          return `${PREFIX} ${selector}`
        }
        return selector
      })
    })
  }

  return process
})

// 編譯指令碼內加入這個外掛
postcss([
    less(),
    prefixPlugin(),
    autoprefixer(),
    clean(),
]).then(() => {
    // ...
})複製程式碼

區域性主題到最後幾行程式碼就搞定了,antd的故事終於結束了。哦不,等等,還有一個小問題。。。

改造部分元件

預覽區域的部分元件沒有受到區域性主題樣式影響

antd提供的部分元件,如Modal、Message等的實現,都是在body下插入dom,所以諸如這類樣式:

.theme-container .ant-modal { ... }

在應用內都是無法生效的。解決這類問題有兩種方式:

修改元件的掛載位置,讓區域性主題樣式生效

class Preview extends PureComponent {
    getContainer = () => {
        const container = document.createElement('div')
        document.querySelector('.theme-container').appendChild(container)

        return container
    }

    render() {
        return (
            <div id="preview-area">
                <Modal visible getContainer={this.getContainer}>
                    content
                </Modal>
            </div>
        );
    }
}複製程式碼

修改元件的類名,讓配置區域和預覽區域的類名區分開來

class Preview extends PureComponent {
    render() {
        return (
            <div id="preview-area">
                <Modal visible prefixCls="custom-modal">
                    content
                </Modal>
            </div>
        );
    }
}

// 外掛也需要相應的修改一下
const prefixPlugin = postcss.plugin('prefix', (PREFIX = '.theme-container') => {
  const process = node => {
    node.walkRules(rule => {
      rule.selectors = rule.selectors.map(selector => {
        if (selector.startsWith('.ant')) {
            if (selector.startsWith('ant-modal', 1)) {
                return selector.replace(/ant-(modal)/g, 'custom-$1')
            }
            return `${PREFIX} ${selector}`
        }
        return selector
      })
    })
  }

  return process
})複製程式碼

後記

區域性主題樣式是一個非常業務化的場景,需求面較小,比較難提出一些非常通用的知識點。但在方案的制定過程中,需要了解不少less的特性、postcss的編譯流程以及如何編寫外掛,這些可能在其他實戰中得到更廣泛的應用,故成此文,將整個方案的演進過程詳盡記錄下來,希望能啟迪有類似需求的場景並方便自己之後回溯。

從長遠來說,web components可能是這類場景的最佳解決方案,目前的方案還比較偏黑科技,所以這個方案並不會停止演進,讓實現變得更合理、更規範。

相關文章