React 元件庫 CSS 樣式方案分析

雲音樂技術團隊發表於2022-05-18

圖片來源:https://unsplash.com

本文作者:iwyvi

背景

隨著業務的發展,一些程式碼邏輯可能同時在多個專案中使用,為了避免每次使用和更新都要複製貼上程式碼,構造一個元件庫就十分有必要了。構建元件庫有很多需要考慮的方面,本文主要討論在 React 生態下,如何選擇一種適合元件庫的 CSS 樣式方案。

目前開發一個瀏覽器中執行的專案,可以選擇的樣式方案根據寫法主要分為三種:第一種是常規 CSS(regular CSS),即原生 CSS 和各種預處理語言;第二種是在 JS 側寫樣式的 CSS in JS 方案,例如 styled-components;第三種是在 HTML 中寫工具類,由 CSS 框架生成對應樣式的方案,例如 Tailwind CSS

但是當我們構建元件庫時,考慮問題的角度和普通專案可能會不太一樣,不但需要考慮開發體驗,同時也要照顧到使用者的感受。因此,本文不從寫法層面對 CSS 樣式方案進行分析,而是從元件庫的開發角度來探討以下兩個問題:

  1. CSS 與 JS 的關聯方式是什麼,即元件如何使用樣式,以及 CSS 如何參與打包。
  2. 在不同的關聯方式下,元件庫如何處理樣式命名衝突。

方案分析

在 React 生態中,沒有統一的樣式管理方案,因此如何處理 CSS 有多種解決方案,不同的方案也有著各自的優缺點。

構造元件庫時,我們一方面希望在開發時能夠寫起來更簡單,另一方面也希望使用時能更方便。CSS 作為元件庫必不可少的一部分,選擇合適的樣式方案,會影響到後續開發和使用的體驗。

CSS 與 JS 關聯方式

首先我們從 CSS 與 JS 的關聯方式說起。不同的 CSS 與 JS 關聯方式,有著不同的樣式引入方法,同時在按需載入、效能和 SSR 支援等方面也有各自的特性。

本文將元件庫使用 CSS 的方案分為以下三種型別:

  1. 樣式和邏輯分離。元件的 CSS 和 JS 在程式碼層面分離,JS 裡不引入樣式檔案,在元件庫打包時分別生成獨立的邏輯和樣式檔案。對於元件庫的使用者來說,新增一個元件,需要分別引入元件程式碼和 CSS 檔案。使用這種方案的元件庫有 Ant DesignZent 等。
  2. 樣式和邏輯結合。將元件的 JS 和 CSS 打包在一起,最終只輸出 JS 檔案。使用時只需要引入元件就可以直接使用。這種方案目前主要有以下兩種種實現形式:

    1. 將 CSS 寫在 JS 裡。例如使用 styled-components, Emotion 等 CSS in JS 方案。代表元件庫有 MUIMantine 等。
    2. 寫程式碼時依然使用常規 CSS,元件內 import 樣式檔案,通過打包工具將 CSS 打進 JS 裡。例如使用 webpack 配合 style-loader。基於這種方案的元件(庫)有 react-mobile-pickerAngular Material 等。
  3. 樣式和邏輯關聯。元件的 JS 和 CSS 在程式碼層面分離,打包後生成獨立的邏輯和樣式檔案,但是元件內會直接引用樣式檔案,且打包結果中保留對應的 import 語句。使用這種方案的有 Semi DesignReact SpectrumAnt Design Mobile 5.0

這幾種方案各有優劣,接下來本文將對其細緻分析:

樣式和邏輯分離

這種 CSS 組織方案在元件庫的構建中最為常見,各個框架中都有大量的元件庫使用這種形式。CSS 寫在單獨的樣式檔案中,元件的 JS 不直接引入 CSS,而在使用元件庫時需要分別引入元件和樣式。

使用這個方案的元件庫有一個較為顯著的特點,他們的安裝教程中都會讓使用者自行引入一個或多個 CSS 檔案,而且通常來說,這個 CSS 檔案會包含整個元件庫所有元件的樣式。

這種方案的優點有:

  • 適用性廣泛,可以支援元件庫使用者的各種開發環境。
  • 不限制元件庫的技術棧,同一套樣式可以用在基於多個框架的元件庫上。
  • 無需考慮對 SSR(服務端渲染)的支援,對外提供的是 CSS 檔案,因此 SSR 流程完全交給元件庫的使用者控制。
  • 可以直接對外提供 lesssass 等原始檔,便於外部覆蓋變數,實現主題定製或換膚等功能。

但是這種方案也有一些問題:

  • 需要使用者手動引入樣式檔案。如果直接引入了完整的 CSS 檔案,而在實際使用中並沒有用到元件庫裡的全部元件,就會造成一些無用的樣式被打包進專案中。
  • 讓元件庫支援 CSS 按需引入的功能會比較複雜,既需要元件庫的開發者在打包流程和產物上進行處理,又需要使用者按照一定規則引入樣式檔案。首先元件庫開發者需要定一套樣式檔案的目錄組織規範,使其能在打包流程中支援以元件為單位打包樣式檔案,之後使用者就可以按需手動引入對應元件的樣式檔案。對於具有特定目錄組織規範的元件庫,目前已經有外掛可以在編譯階段輔助生成引入樣式的 import 語句,例如 babel-plugin-importunplugin-vue-components 等。
  • 如果元件庫內部的元件存在引用關係,為了實現按需引入,打包出來的元件的樣式可能會存在冗餘。

樣式和邏輯結合

這種方案中,CSS 以字串或者物件的形式存在 JS 裡,而且通常打包後的程式碼裡會帶一個用於掛載樣式的 runtime。

這種方案具有以下優點:

  • 不需要使用者單獨引入樣式檔案,只需要 import 元件即可使用
  • 天然支援按需載入,每個元件只需要處理自己的樣式即可

但是同樣這個方案也並不完美:

  • 需要帶一個 runtime,可能增大程式碼體積,帶來效能影響。
  • 相較於單獨的 CSS 檔案,此方案的樣式都在 JS 中,可能無法充分利用到瀏覽器快取。
  • 對 SSR 支援需要具體實現方案提供的能力,這一點在後文中會詳細說明。

這種方案主要有以下兩種實現形式:

CSS in JS

CSS in JS 是一種與常規 CSS 檔案不同的樣式方案,多用於 React 生態,解決了一些使用常規 CSS 時存在的痛點,例如命名衝突、樣式冗餘等問題。

如今 CSS in JS 框架百花齊放,內部也出現了有執行時和零執行時(zero-runtime)兩個類別。 styled-componentemotion 這一類屬於有執行時,樣式編寫、變更和掛載都是在 JS 中進行的,框架會提供對應的 runtime 來處理這些工作。linariavanilla-extract 這一類屬於零執行時,他們在寫法上與有執行時框架類似,但是需要配置編譯流程,經過編譯以後將輸出標準 CSS。

因為本節討論的是樣式和邏輯結合的方案,因此接下來提到的 CSS in JS 均指有執行時框架。

引入 runtime 意味著帶來更多的 JS 程式碼,因此 CSS in JS 相比常規 CSS 一定存在著效能差異。Real-world CSS vs. CSS-in-JS performance comparison 這篇文章從使用者體驗的角度分析了 styled-componentlinaria 的效能差異(由上文可知 linaria 為零執行時的 CSS in JS 框架,即它可以代表常規 CSS 的效能),得出了常規 CSS 在各個方面的效能均優於 CSS in JS 的結論。

除了效能問題,由於樣式的注入流程是由 CSS in JS 框架提供的 runtime 執行的,所以 SSR 流程也需要框架進行額外處理,好在目前幾乎所有主流的 CSS in JS 框架都提供了 SSR 的支援。

目前來看,大部分 CSS in JS 框架都需要使用者在服務端渲染流程中新增額外的樣式收集和插入流程,才能成功用上 SSR。少數框架例如 emotion 提供了一種無需額外配置的 SSR 支援方案,在服務端渲染時,元件的樣式會以內聯 style 標籤的形式放到元件 DOM 的前面。但是這種方案也存在一些問題:當同一個元件在頁面中被多次使用時,渲染後的 HTML 裡會包含多份重複的樣式;此外,因為插入了額外的 style 標籤,會影響到 :nth-child() 這一類選擇器。

為什麼在 SSR 中,這個額外流程無法避免呢?服務端的一次渲染可以認為是呼叫了一次 ReactDOMServerrenderToString 方法,但是這個方法並沒有為內部元件提供感知渲染狀態的能力。對於一個元件來說,如果不記錄自己是否被渲染過,就只能採取類似 emotion 的零配置方案,元件在每次渲染時都帶上自己的樣式;但是如果元件通過一個全域性變數來記錄自己是否被渲染過,如果沒有渲染過就插入樣式,已經渲染過就不插入樣式,不難發現元件無法區分服務端的多次渲染,因為用來記錄狀態的全域性變數在服務端一直是同一個,為了元件標記自己是否被渲染的狀態在每一輪渲染中重新整理,需要在每次渲染時建立一個變數來儲存這個狀態,而上述這些行為,就是上文提到的額外樣式收集流程。

不過也正是因為新增了樣式收集流程,CSS in JS 的方案大多都支援提取關鍵樣式(Critical CSS),可以在 SSR 時減小首屏請求大小,這也是它的一個優勢。

將 CSS 打包到 JS 中

這種方案一般是通過打包工具配合相應的外掛將常規 CSS 和 JS 直接打包到一起,例如 webpack + style-loaderrollup + rollup-plugin-styles

通常這些外掛引入的 runtime 均有 DOM 操作,會在 SSR 階段報錯或者什麼都不做,等 CSR 階段才真正注入樣式,因此無法支援 SSR

若要支援 SSR 也並非完全沒有選擇,webpack 生態裡的 isomorphic-style-loader 就提供了 SSR 支援,在功能上基本等同於 style-loader。但是它的實現方式和效果與 CSS in JS 方案比較類似,在開發時需要給元件包一個高階元件來載入樣式,同樣在 SSR 階段需要通過其提供的方法收集和注入樣式。

在 React 生態中,很少有元件庫採用這種樣式方案,只有少數單元件專案使用這種打包方案。原因一方面在於這種方案難以提供 SSR 支援,另一方面既然已經寫了常規 CSS,不如直接匯出檔案讓使用者自行處理。

然而在一些非 React 生態中,使用這個方案構造的元件庫還是比較多的,因為他們的主流開發工具提供了包含打包和開發在內的一整套解決方案,而 React 生態下百花齊放,沒有統一的開發工具,因此也沒有統一的樣式解決方案。

樣式和邏輯關聯

這種方案的開發流程與樣式和邏輯分離方案類似,主要區別在於輸出結果裡直接保留了引入樣式檔案的 import 語句,如果使用者的專案能正確處理 CSS 檔案,那麼就可以做到只引入元件即可使用。且這種方案同樣也能支援按需載入,不需要引入一個大而全的 CSS 檔案。

但是這種方案也有一些缺陷:

  1. 對元件庫的開發者來說,如果使用了預處理語言,打包編譯的流程會更加複雜,需要讓元件最終產物的 import 語句正確關聯經過編譯的 CSS 檔案。
  2. 對使用者的開發配置有一定要求,需要能正確處理由元件庫內的程式碼引入的 CSS 檔案(例如在 webpack 下配置對應 loader)。如果需要支援 SSR,還需要修改打包工具的配置,讓元件庫的檔案也參與到構建中,避免出現 CSS 檔案在 node 端直接執行導致渲染出錯。

總結

樣式和邏輯分離樣式和邏輯結合樣式和邏輯關聯
開發打包流程中等簡單複雜
輸出檔案JS 檔案和 CSS 檔案JS 檔案JS 檔案和 CSS 檔案
使用方法分別引入 JS 和 CSS只引入 JS只引入 JS
按需載入需要額外支援支援支援
效能影響帶額外 runtime,可能有影響
SSR支援需要額外支援(部分方案不支援)支援(可能需要使用者調整配置)
支援寫法常規 CSS / 零執行時 CSS in JS常規 CSS / CSS in JS常規 CSS / 零執行時 CSS in JS
關鍵樣式提取自行處理支援自行處理

元件庫樣式命名衝突處理

解決樣式命名衝突也是構建一個元件庫需要考慮的問題,開發者總是希望能使用更簡單的名字,同時也不希望出現命名衝突。

目前在 React 生態下,常見的建立樣式名稱空間的方案以下幾種:

約定命名規則

約定命名規則即在整個元件庫遵守一個人為約定的命名規則,例如 BEM 規範或統一給所有選擇器名新增字首等。這種方案的好處是不需要調整打包方案,但是缺點在於規則全憑人為約定,在開發時要依靠開發者的自覺,當多人維護時可能會比較麻煩;此外為了達成約定的命名規則,可能還需要寫一些樣板程式碼來生成符合規範的名稱,比較耗費精力。

CSS Modules

CSS Modules 是解決名稱空間問題的一種方案,它可以基於指定的規則生成選擇器名稱,無需開發者遵守嚴格的規範,同時也避免對全域性樣式造成汙染。

以下是一個簡單的例子,原始程式碼是這樣的:

.test {
   color: red;
}
import styles from 'index.less';
// ...
<div className={styles.test} />

經過轉換後,成為了這樣:

._xxxxxx {
   color: red;
}
var modules_xxx = {"test":"_xxxxxx"};
// ...
<div className={modules_xxx['test']} />

通過對選擇器新增 hash 值等方法,使選擇器不會與其他地方產生衝突。

但是當我們用 CSS Modules 開發元件庫時,也需要考慮這些問題:

  • 使用 CSS Modules 需要對元件庫的編譯流程進行一定處理。如果想採用樣式和邏輯分離的打包方案,需要在打包出來的程式碼中移除對樣式檔案的引用語句,僅保留選擇器名稱轉換的資料;如果使用樣式和邏輯關聯的方案,需要在保留選擇器名稱轉換的同時,正確引入經過編譯後的樣式檔案。
  • 因為無法保證使用者的開發環境也支援 CSS Modules,因此無法將樣式的原始檔直接對外提供。
  • 由於生成出的選擇器名稱不穩定,可能會經常變動,對於元件庫的使用者來說,在外層不能對元件樣式進行覆蓋。

CSS in JS

是的,CSS in JS 方案又出現了,因為這個方案在誕生之初,就有意解決 class 的命名問題。由於選擇器名稱都是動態生成的,所以開發時不需要遵守命名規範,也無需考慮命名衝突。

Emotion 為例:

import styled from '@emotion/styled';
const Test = styled.div`
    color: red;
`;
// ...
<Test />

在瀏覽器上執行時,真實的 DOM 被渲染成:

<style data-emotion="css">.css-1vdv3ej{color:red;}</style>
<!-- ... -->
<div class="css-1vdv3ej"></div>

上文我們提到,CSS in JS 方案分為有執行時和零執行時兩種型別,其中零執行時的方案最終經過編譯會輸出帶有隨機選擇器命名的 CSS 檔案,這個效果類似 CSS Modules。例如 vanilla-extract 就稱自己為 “CSS Modules-in-TypeScript”。因此零執行時的 CSS in JS 方案可以用在所有支援常規 CSS 的方案中。

選型思考

關聯方案選擇

選擇一種樣式方案,首先確定 CSS 與 JS 的關聯方式,之後再考慮如何處理樣式命名。

假如打算構建一個支援多框架的元件庫,最優先考慮的打包方案是樣式和邏輯分離。在這種方案下,產出一份基礎樣式,就可以在多框架的元件庫裡共用,不需要在各個框架中分別處理,適用性最廣泛。

但是如果僅需要支援 React 技術棧,以上幾種方案都可以根據使用情況進一步考慮:

在目前的時間節點構造一個元件庫,本文的觀點是:在滿足相容性的要求下,能夠接受 CSS in JS 的寫法,且可以容忍其自身的一些不足(如效能問題或一些寫法限制),優先考慮有執行時的 CSS in JS 方案。元件庫的開發者無需花精力處理樣式的起名、引入和打包工作,在使用時也能很方便實現樣式的按需載入,同時可以支援 SSR 和關鍵渲染路徑的樣式提取。

假如對 CSS in JS 的寫法不太能接受,依然更偏向常規 CSS 的寫法,或者覺得目前 CSS in JS 方案太多,難以做出抉擇,那麼還可以考慮以下常規 CSS 的方案:

如果元件庫的應用場景明確不需要支援 SSR,且不需要考慮單獨拿到 CSS 檔案做快取等效能優化,可以選擇直接將 CSS 打包進 JS。這種方案不需要調整打包流程,使用者也只需要 import 元件即可使用,且邏輯和樣式都能支援按需載入。

如果上面的條件不滿足,可以優先選擇樣式與邏輯關聯的方案。假如元件庫用在內部專案中,需要該專案支援打包元件庫裡直接引入的樣式檔案。

樣式與邏輯分離是最為通用且穩妥的方案。需要注意的是,這種方案實現按需載入比較麻煩,通常元件庫會考慮提供一套生成按需載入樣式語句的處理方法,例如引導使用者使用 babel-plugin-import,並給出相應的配置。

命名方案選擇

選擇寫常規 CSS ,還需要考慮樣式的命名規則。

如果是基礎 UI 元件庫,更推薦使用約定命名規則的方案:

  1. UI 元件庫通常維護人員較為固定,便於推行統一的命名規範;
  2. 基礎元件在業務中使用時,可能涉及到定製化場景。這種方案可以直接對外提供原始碼,便於在具體業務中覆蓋變數或樣式,給使用者更大的自由。

如果是業務元件庫,則更推薦使用 CSS Modules / 零執行時 CSS in JS 方案:

  1. 業務元件庫本身與業務強相關,可能由不同業務的開發者維護,難以推行統一的命名規範,也無法保證所有人都能嚴格遵守。而使用 CSS Modules 可以幫助開發者保證樣式的名稱空間不會汙染。
  2. 業務元件較少涉及自定義的場景,不需要滿足覆蓋變數或樣式的需求。

總結

目前在 React 生態中沒有一種佔統治地位的樣式方案,而現有的各種方案都有各自的優缺點,因此選擇一種合適的樣式方案需要綜合考慮很多方面。

本文從 CSS 與 JS 的關聯方式和命名衝突處理方式兩個方面對元件庫的樣式方案選型進行了對比和分析,沒有哪種方案是完美的方案,但是不同的業務場景肯定會有更合適的方案,希望本文能對大家構建元件庫時的選型提供一定的幫助。

參考

本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!

相關文章