聊聊 React 組件庫的技術(shù)選型與設(shè)計(jì)
前言
最近在業(yè)務(wù)中開發(fā)了一套定制化的 C 端組件庫,在這個(gè)過程中遇到了一些組件庫技術(shù)選型和設(shè)計(jì)的問題,在參考公司內(nèi)外的多個(gè)組件庫后確定了最終的方案。本文希望通過向讀者介紹技術(shù)選型的過程中的方案比較和組件庫設(shè)計(jì)中的考量,讓讀者在組件庫的技術(shù)選型和設(shè)計(jì)上有所啟發(fā)。?

一個(gè)完整的組件庫方案的思路
組件庫的技術(shù)選型
樣式方案選擇

事實(shí)上,這三種樣式方案可以并存,但實(shí)際開發(fā)以其中一種為主。
Sass/Less
這是大家最熟悉的方式,它的優(yōu)點(diǎn)是足夠靈活、開發(fā)成本低(絕大多數(shù)工程師都熟悉它們)、 完全支持外部覆蓋組件的樣式,缺點(diǎn)是難以調(diào)試(需要到 runtime 才能知道命中的規(guī)則),以及難以實(shí)現(xiàn)靜態(tài)分析。
Atomic CSS
在 UI 足夠標(biāo)準(zhǔn)化的情況下,使用 Atomic CSS 能實(shí)現(xiàn)更小的包體積大小,對于單個(gè)組件,除了極少數(shù)無法抽象的樣式以及自定義動畫,不再需要聲明其他樣式。當(dāng)然它的缺點(diǎn)是代碼可讀性稍稍降低。同時(shí)開發(fā)者需要先熟悉項(xiàng)目的原子樣式,增加了一定的開發(fā)成本。
CSS-in-JS
CSS-in-JS 指包括 styled-component、Emotion、JSS 等在內(nèi)的,在運(yùn)行時(shí)通過 js 生成 css 樣式的第三方庫。CSS-in-JS 這種方案的優(yōu)點(diǎn)在于能有效解決“組件樣式隨著數(shù)據(jù)變化”的問題。但是,它的缺點(diǎn)在于為了支持從外部覆蓋內(nèi)部元素的樣式,需要給內(nèi)部元素加上 className,同時(shí)不支持 postcss,取而代之的是特定 CSS-in-JS 庫自己的 plugin 生態(tài),少部分庫(如 emotion)沒有支持 rem 的工具庫。另外在做 SSR 和流式渲染時(shí),都需要在 node 層增加提取樣式邏輯,增加了開發(fā)成本和額外的開銷。
小結(jié):在有成熟的 UI 規(guī)范的情況下,Atomic CSS 是一個(gè)不錯(cuò)的選擇,其次,使用傳統(tǒng)的 sass/less 來編寫樣式也利于維護(hù)(大部分前端開發(fā)者都熟悉它),在選用 CSS-in-JS 方案時(shí)則要考慮團(tuán)隊(duì)的開發(fā)習(xí)慣和上手成本。
icon 方案選擇

在選擇 icon 方案的時(shí)候,除了關(guān)注渲染質(zhì)量,我們還應(yīng)該關(guān)注它的靈活性,以便具有更好的適配能力。
iconfont
iconfont 這種方案的優(yōu)點(diǎn)在于兼容性最好,支持 IE6 及以上版本。但是,由于 iconfont 方案是將 icon 作為文本來使用,在 webkit 內(nèi)核的瀏覽器下由于對文字有抗鋸齒,導(dǎo)致渲染失真。另外,由于將所有的 icon 打包成一個(gè)字體文件,不支持按需加載,包體積偏大。這樣很容易導(dǎo)致在加載完成 icon font 后頁面的重刷新:

base64 引入
base64 也是一種常用的方法,但是由于將 svg 作為背景圖引入,只能控制它的大小,不能覆蓋它的顏色,也更不能修改 svg 內(nèi)部的元素,不夠靈活。對于常常采用 MPA 結(jié)構(gòu)的端內(nèi) h5,不利于 icon 在不同 SPA 之間復(fù)用。同時(shí) base64 字符串的長度是 svg 文件(優(yōu)化后)的 1.3 倍左右。
React Component、SVGUseElement 和直接寫入 svg 元素
這三種方式本質(zhì)上都是將 svg 作為 html 元素進(jìn)行渲染,但具體的使用方式不同。
svg 的基本能力的兼容性除了在 IE11 以下不支持動畫和縮放,基本沒問題,而 svg effect(主要是使用 transform、filter 等屬性)在 android4.4 以上的支持良好。svg 的動畫性能有瓶頸,幸運(yùn)的是我們可以使用 css 動畫來替代它。
直接寫入 SVG 元素的方式缺點(diǎn)在于完全無法復(fù)用同一個(gè) icon。
而 SVGUseElement 的具體實(shí)現(xiàn)方式有使用
目前調(diào)研的結(jié)果,最好的方式是使用?svgr[2]?將 svg 轉(zhuǎn)換為 React Component 來使用,它支持按需加載、完全的樣式覆蓋能力。同時(shí),它支持自定義 AST 模板,可以在轉(zhuǎn)換時(shí)給 svg 元素加入自定義的 className 等,容易實(shí)現(xiàn) icon 自動適配 RTL、Dark Mode(這部分下文會詳細(xì)介紹)。
svgr 集成了 svgo 對 svg 文件進(jìn)行優(yōu)化,它可以抹去 svg 中無用的屬性、隱藏元素等,具體的配置可以參考?svgo-github[3]。
小結(jié):目前看來使用 svgr 將 svg 轉(zhuǎn)換生成 React Component 來構(gòu)建 icon 是最佳的方式,能很方便地按需加載、復(fù)用,適配能力也最強(qiáng)。我們可以將 icon 專門做成一個(gè) npm 包,供組件庫使用,也可以在業(yè)務(wù)倉庫中直接使用。
組件庫的核心設(shè)計(jì)
深色模式(Dark Mode)適配
事實(shí)上,本小節(jié)討論的是業(yè)務(wù)上使用組件庫的 Dark Mode 能力時(shí)會遇到的兼容性問題和實(shí)際業(yè)務(wù)場景。但組件庫本身就是服務(wù)于業(yè)務(wù)的,從這個(gè)角度講本小節(jié)的內(nèi)容也屬于組件庫相關(guān)的一部分,它指導(dǎo)組件庫如何去提供更好的 Dark Mode 適配能力。
多主題能力
深色模式本質(zhì)上是一種運(yùn)行時(shí)的多主題問題,主要是在運(yùn)行時(shí)支持切換不同的主題色。我們可以使用 CSS 變量來定義顏色,然后在 Sass/Less/Css 中約定使用它:
:root{
????--bg-default:?#fff;
}
:root[theme="dark"]{
????--bg-default:?#000;
}
.button{
????background-color:?var(--bg-default);
}
這樣,只要我們在元素中設(shè)置自定義屬性 theme 的值為 dark,顏色就會自動切換。且我們只要定義好顏色變量,并約定使用它,則開發(fā)組件的時(shí)候只寫一次就可以支持多個(gè)主題。
可惜的是 CSS 變量在 android4、IE11 及以下等有兼容性問題。我們有如下三種方案:

我們可以實(shí)現(xiàn)一個(gè) postcss plugin 來生成兜底屬性,做法類似于:
//?處理前
.button{
????background-color:?var(--bg-default);
}
//?處理后
.button{
??? background-color:?#fff;?//?對于不支持css變量的瀏覽器這行會生效
????background-color:?var(--bg-default);?//?對于支持css變量的瀏覽器這行會覆蓋上一行屬性
}
它最大的優(yōu)點(diǎn)在于增大的包大小幾乎可以忽略不計(jì),缺點(diǎn)在于對于不支持 CSS 變量的顏色實(shí)際上變成了強(qiáng)制展示一套兜底主題色。對于移動端內(nèi)頁面來說,不支持 css 變量的環(huán)境可以等同于沒有深色模式的環(huán)境,可以使用淺色模式的主題色兜底。
我們還有另一種方式來實(shí)現(xiàn)兼容,比如下面這樣:
.button{
????background-color:?#fff;
}
.theme-a?.button{
????background-color:?#000;
}
.thema-b?.button{
????background-color:?#ccc;
}
然后在某個(gè)根元素上(例如 html)增加 theme-a 這個(gè) class 即可,這樣的優(yōu)點(diǎn)在于完全不會有兼容性問題,缺點(diǎn)在于增加了開發(fā)成本,幸運(yùn)的是,我們可以使用postcss-css-variables[4]來很方便地從 css 變量的寫法生成這種聲明。它的另一個(gè)缺點(diǎn)是隨著主題色的增多,會成倍地產(chǎn)生額外的 CSS 包大小。
css-vars-ponyfill 能完美支持多主題色,缺點(diǎn)是會產(chǎn)生固定的額外包大小。
小結(jié):支持運(yùn)行時(shí)多主題色主要使用 css 變量,而業(yè)務(wù)倉庫的解決兼容性問題,可以根據(jù)具體情況選擇。如果是端內(nèi) h5 且只需要深淺色模式,可以考慮使用 postcss plugin 生成兜底屬性,否則可以使用 css-vars-ponyfill 或者 postcss-css-variables。
判斷 Dark Mode

媒體查詢
我們可以很容易的利用 prefers-color-scheme 這個(gè)媒體特性來檢測 Dark Mode,結(jié)合我們 css 變量的使用,就像這樣:
:root{
????--bg-default:?#fff;
}
@media?(prefers-color-scheme:?dark)?{
????:root{
????????--bg-default:?#000;
????}
}
//?支持白名單逃逸,再寫一次:root下的屬性
:root[theme="light"]{
????--bg-default:?#fff;
}
白名單逃逸是指在我們的業(yè)務(wù)中,可能有一部分頁面,如活動頁、抽獎(jiǎng)頁等不支持 Dark Mode,我們可以通過在 html 上增加一個(gè) theme 屬性來強(qiáng)制為淺色模式。
媒體查詢的優(yōu)點(diǎn)是使用方便,媒體查詢會自動監(jiān)聽系統(tǒng)設(shè)置的變化(是否開啟深色模式)不用在 html 中增加額外代碼。缺點(diǎn)在于對需要逃逸的情況,書寫比較繁瑣。
JS API 監(jiān)聽媒體查詢
使用 JS API 的例子如下:
????
????"root">
樣式的部分就像我們一開始介紹 CSS 變量的例子:
:root{
????--bg-default:?#fff;
}
:root[theme="dark"]{
????--bg-default:?#000;
}
這個(gè)方案的好處是靈活,可以很容易地在腳本里加入其它邏輯支持白名單逃逸。缺點(diǎn)是為了支持 SSR,需要單獨(dú)將這部分腳本寫在 html 模板的 body 元素內(nèi)最上方,對于組件庫的使用方增加接入成本。
小結(jié):從實(shí)際業(yè)務(wù)可能出現(xiàn)的白名單逃逸問題以及業(yè)務(wù)的變化來看,雖然使用 JS API 監(jiān)聽媒體查詢判斷 Dark Mode 的方式會少許增加接入組件庫的成本。但是和帶來的靈活性收益相比來說是值得的,建議使用這種方式。
RTL 適配
組件庫如果支持國際化,那么 RTL 是一個(gè)必不可少的部分。RTL(right to left) 是指部分語言,例如阿拉伯語是從右往左閱讀的,由此帶來 UI 上需要左右相反(大部分情況下,有些例外),一些 icon 也需要鏡像,手勢也是從右往左滑動的,input 輸入框從右到左輸入,更多細(xì)節(jié)具體可以參考?《bidirectionality - Material》[5]。
布局適配
我們可以利用原生的?dir 屬性[6]來支持大部分的 rtl 能力,即在 html 上設(shè)置屬性 dir='rtl'。在瀏覽器環(huán)境下可以通過 NavigatorLanguage API 來獲取頁面語言,進(jìn)而根據(jù)當(dāng)前語言是否是 rtl 來設(shè)置 dir 的值。在 node 環(huán)境下可以通過請求頭 Accept-Language 獲取頁面語言,判斷得到 dir 的值后注入到返回的頁面中。設(shè)置 dir='rtl'后,全局的 flex 水平布局會自動反向,文本也會自動右對齊(除非顯示聲明 text-align)。但包括 marin-left、left、border-left 這類屬性(其他方向類似)無法自動適配,解決這個(gè)問題有多種方式,我們可以很直觀地來看代碼:
//?方法1:?樣式覆蓋方式,ant design使用此方式
.button{
????margin-left:?16px;
}
html[dir='rtl']?.button{
????margin-left:?0;//?要覆蓋掉,否則左右都是16px的margin
????margin-right:?16px;
}
//?方法2:?另一種方式,雖然不用覆蓋,但是需要將方位屬性拆出來
.button{
???//?與方位無關(guān)的屬性
}
html[dir='ltr']?.button{
????margin-left:?16px;
}
html[dir='rtl']?.button{
????margin-right:?16px;
}
//?方法3:?方法2 + Atomic CSS
html[dir='ltr']?.ms-16{
????margin-left:?16px;
}
html[dir='rtl']?.ms-16{
????margin-right:?16px;
}
我們可以看到方法 1 和方法 2 都不是很方便,而方法 3 需要 UI 非常的規(guī)范化(將 margin、padding 收斂到可枚舉的狀態(tài)),也不能覆蓋所有的情況。幸運(yùn)的是,我們可以使用 margin-inline-start 這類 RTL 敏感的屬性來解決(更多屬性見CSS Logical Properties[7]) :
.button{
???//?其他屬性
???margin-inline-start:?16px;
}
在實(shí)際使用中還存在一些兼容性問題,我們可以使用?postcss-bidirection[8]?處理,會把上述聲明轉(zhuǎn)化為:
.button{
????//?其他屬性
}
html[dir="ltr"]?.button?{
????margin-left:?16px;
}
html[dir="rtl"]?.button?{
????margin-right:?16px;
}
小結(jié):RTL 的布局適配我們可以使用 RTL 敏感屬性,它與 Atomic CSS 不沖突,合適的情況下可以結(jié)合起來使用。
icon 適配
在 RTL 下,部分 icon 需要鏡像。前面我們已經(jīng)介紹,icon 的最佳方式是使用 svgr 將 svg 轉(zhuǎn)換為 React Component。這樣,我們可以在轉(zhuǎn)換時(shí)為需要 RTL 翻轉(zhuǎn)的 icon 增加一個(gè) class,例如 flip-rtl,然后組件庫提供以下 CSS 聲明供業(yè)務(wù)使用:
[dir="rtl"]?.flip-rtl?{
??transform:?scaleX(-1);
}
icon 是否鏡像可能是偏設(shè)計(jì)側(cè)的事情,如果我們將 icon 的設(shè)計(jì)稿托管在 figma 平臺上,我們可以和設(shè)計(jì)師約定需要 RTL 下需要翻轉(zhuǎn)的 icon 的命名,然后實(shí)現(xiàn)一個(gè)自動下載 svg 源文件、 svgo 處理、 使用 svgr 轉(zhuǎn)換成 React Component 的腳本,并且在轉(zhuǎn)換過程中根據(jù)命名自動判斷是否需要加上 flip-rtl 這個(gè) class。這樣,在組件庫和業(yè)務(wù)開發(fā)過程中,研發(fā)都不需要關(guān)心 icon 的鏡像問題,減少溝通和驗(yàn)收成本。
手勢適配
一些組件,如進(jìn)度條組件,在傳統(tǒng) LTR 下是從左向右滑動,但是在 RTL 下則是從右向左滑動。我們可以簡單地給這類組件增加一個(gè) isRTL 這種 props,但是這顯然不是一種很好的做法,使用的時(shí)候都要計(jì)算并傳入 props 值。由此思考,我們可以為整個(gè)組件庫抽象一些通用能力,全局注入。
全局化配置
對于 direction(LTR/RTL)、 prefixCls(類名前綴)等一些全局配置,我們可以使用 React 的 Context 來注入,例如應(yīng)用的根節(jié)點(diǎn)外面包裹一個(gè) ConfigProvider:
import?ConfigProvider?from?'myComponent';
//?...
export?default?()?=>?(
??"rtl">
????
?? );
在組件中使用 hooks 獲取:
import?{?useConfigContext?}?from?'myComponent';
export?default?()?=>?{
????const?{?rtl,?prefixCls,?platform?}?=?useConfigContext();
????//...
}
使用 Context 甚至可以實(shí)現(xiàn)局部的配置和全局不同,它非常靈活,后期可以很方便地?cái)U(kuò)展全局配置的能力,也解決了我們反復(fù)將一些全局通用的屬性作為 props 傳入各個(gè)組件的痛點(diǎn),缺點(diǎn)在于不利于代碼的靜態(tài)測試。
組件分層
在組件庫開發(fā)之前,應(yīng)該先規(guī)劃好組件庫的層級,以增加組件庫的代碼復(fù)用性和使用的靈活性。
我們應(yīng)該先規(guī)劃一些基礎(chǔ)組件,避免后續(xù)的重構(gòu)。Switch、Checkbox、Radio(它們在邏輯上區(qū)別僅僅在于點(diǎn)擊激活態(tài)后是取消還是依舊激活)可以抽象出一個(gè) BaseSwitch,在它的基礎(chǔ)上實(shí)現(xiàn)這三個(gè)組件。對于 Button,在彈窗組件等其他組件中也會出現(xiàn),我們可以抽象出一個(gè) BaseButton 或者在其他組件中使用 ConfigProvier 的 prefixCls 重寫它的樣式。對于表單相關(guān)的組件,可以先實(shí)現(xiàn)一些原子的 input、textarea,再實(shí)現(xiàn) Form 中帶有 lable、 校驗(yàn)狀態(tài)等和 UI 跟相關(guān)的 Form.input 等。對于彈層組件,可以封裝一個(gè) Portal 組件提供能力等等。在 Metrial UI 中還抽象了一個(gè) Box 組件,所有的組件都基于 Box 組件編寫,實(shí)現(xiàn)全局布局和樣式的控制。
樣式
樣式上,如果沒有使用 Atomic CSS,我們可以將 UI 規(guī)范(字重、文本大小和行高的組合)封裝成 sass/less 中的 mixin,降低出錯(cuò)的可能性。還可以封裝一些常用的能力,比如文本溢出顯示省略號、 0.5px 邊框的偽元素實(shí)現(xiàn)等。這些封裝的變量和 mixin 不僅可以在組件庫內(nèi)部使用,還可以提供給業(yè)務(wù)方使用(尤其在定制組件庫中)。同時(shí)要和 UI 約定組件庫不同組件的 z-index,以避免不符合預(yù)期的層級。
其他
組件庫中用到的一些 hooks(比如彈層組件用到的凍結(jié)頁面的滾動)可以使用 react-use 等主流開源庫,也可以定制開發(fā)。如果組件庫期望支持 preact(一個(gè)和 react 語法基本一致但更輕量的庫),可以參考?switching-to-preact[9]?來避免在開發(fā)過程中使用不支持 preact 的語法。同時(shí),組件庫中用到的 utils(一些函數(shù)能力)也要考慮兼容 node 環(huán)境,以支持 SSR。在不會引入特別大的成本的前提下,組件庫應(yīng)該充分地去考慮業(yè)務(wù)方可能的技術(shù)選型,以避免限制業(yè)務(wù)上的技術(shù)實(shí)現(xiàn)。
組件庫構(gòu)建一般使用 tsc 或者 rollup,動畫庫則根據(jù)具體需求選擇是否使用(使用 CSS 動畫更輕量)。
組件庫的其他細(xì)節(jié)
質(zhì)量保障
組件庫的質(zhì)量保障從流程上來說,主要是 code review 和嚴(yán)格的 UI 驗(yàn)收、QA 測試等流程。從技術(shù)層面來說可以收斂發(fā)包權(quán)限,結(jié)合?semantic-release[10]?在 CI/CD 中實(shí)現(xiàn)自動發(fā)包,杜絕研發(fā)過程中在非 master 分支上隨意發(fā)包的危險(xiǎn)操作。還有單元測試、快照測試、e2e 測試等常用的技術(shù)手段,限于本文篇幅不再詳細(xì)闡述。
規(guī)范
制定規(guī)范的目的在于保證質(zhì)量、 方便業(yè)務(wù)方使用和增加組件庫的可擴(kuò)展性。比如上文提到的對于樣式的封裝、常用 mixin 封裝,強(qiáng)制使用顏色變量等。還有設(shè)計(jì)統(tǒng)一的組件庫 API 風(fēng)格規(guī)范,能降低業(yè)務(wù)方的使用成本。
提效
組件庫一般有一個(gè)演示站點(diǎn),主流的技術(shù)選型有 stylegudist、storybook 等,可以根據(jù)團(tuán)隊(duì)習(xí)慣選用。對于移動端組件庫,可以通過 webpack 別名的方法重寫它們的組件,以支持移動端預(yù)覽,方便 UI 驗(yàn)收。對于國際化的組件,可以提供類似 vconsole 形式的 devtools,可視化切換 dark/light Mode、rtl/lrt 等能力,提高開發(fā)和測試流程中的效率。
一些思考
組件庫的開發(fā)是一個(gè)強(qiáng)依賴 UI 的事情,我們需要和 UI 進(jìn)行充分的溝通。同時(shí)我們不能局限于組件庫本身,而要考慮到開發(fā)、測試過程中的效率,業(yè)務(wù)中接入的難易,以及是否能良好地應(yīng)對業(yè)務(wù)的變化等,從更全局的視角去思考。另外,如果是通用組件庫,則組件庫的推廣是一個(gè)重中之重的事情,有更多的業(yè)務(wù)方接入,才能推動組件庫的進(jìn)一步迭代,形成良性循環(huán)。
參考資料
使用 SVGUseElement 插入 icon 的例子:?https://codepen.io/chriscoyier/pen/Hwcxp
[2]?svgr:?https://github.com/gregberge/svgr
[3]?svgo-github:?https://github.com/svg/svgo
[4]?postcss-css-variables:?https://github.com/MadLittleMods/postcss-css-variables
[5]?《bidirectionality - Material》:?https://material.io/design/usability/bidirectionality.html
[6]?dir 屬性:?https://developer.mozilla.org/zh-CN/docs/Web/HTML/Global_attributes/dir
[7]?CSS Logical Properties:?https://www.w3.org/TR/css-logical-1/#changes
[8]?postcss-bidirection:?https://github.com/gasolin/postcss-bidirection
[9]?switching-to-preact:?https://preactjs.com/guide/v10/switching-to-preact#portals
[10]?semantic-release:?https://github.com/semantic-release/semantic-release
?點(diǎn)擊閱讀原文,快來加入我們吧!
