為什么我要含淚揮別 CSS-in-JS?
這篇文章將深入探討最初吸引我使用CSS-in-JS以及后來又決定放棄的原因。如果你對CSS-in-JS背后的邏輯感興趣,建議耐心看完本文。
CSS-in-JS是什么?
顧名思義,CSS-in-JS允許你通過在JavaScript或TypeScript代碼中直接編寫CSS來設(shè)置React組件的樣式:
styled-components[1]和Emotion[2]是React社區(qū)中最受歡迎的CSS-in-JS庫。
本文重點介紹運行時CSS-in-JS,包括styled-components和Emotion。運行時CSS-in-JS僅表示庫在應(yīng)用程序運行時解析并應(yīng)用樣式。并將在本文末尾簡要討論編譯時CSS-in-JS。
CSS-in-JS的優(yōu)缺點
優(yōu)點
1.可以限定樣式應(yīng)用的范圍。 在編寫純CSS時,很容易不小心擴大樣式的應(yīng)用范圍。例如,假設(shè)你正在創(chuàng)建一個列表視圖,每行都需要有一些填充和邊框。你可能會這樣寫CSS:
幾個月后,你完全忘記了這個列表視圖,又創(chuàng)建了另一個具有列表行的組件。當(dāng)然,你可以在這些元素上設(shè)置className="row"。現(xiàn)在,新組件的列表行有一個難看的邊框,你不知道為什么!雖然這種類型的問題可以通過使用更長的類名或更具體的選擇器來解決,但作為開發(fā)人員,你需要確保沒有類名沖突。
CSS-in-JS通過控制樣式的默認(rèn)應(yīng)用范圍來完全解決此問題。如果將列表視圖行編寫為:
那么,填充和邊框就不可能意外地應(yīng)用于不相關(guān)的元素。
注意:CSS模塊也提供本地范圍的樣式。
2.集中放置。 如果使用普通CSS,那么你可以把所有.css文件放在src/styles目錄,同時所有React組件放在src/components中。但是隨著應(yīng)用程序規(guī)模的增長,很快就會難以判斷每個組件使用哪種樣式。于是這樣的CSS很多時候就會變成死代碼,因為沒有簡單的方法可以判斷這些樣式有沒有被使用過。
組織代碼的更好方法是,將與單個組件相關(guān)的所有內(nèi)容都放在同一位置。這種做法稱為集中放置。
問題在于,這在使用純CSS時很難實現(xiàn),因為CSS和JavaScript必須放在單獨的文件中,并且無論.css文件位于何處,都將全局應(yīng)用樣式。另一方面,如果使用的是CSS-in-JS,則可以直接在使用它們的React組件中編寫樣式!如果操作正確,這將大大提高應(yīng)用程序的可維護性。
注意:CSS模塊還允許將樣式與組件一起放置,即使不在同一文件中。
3.可以在樣式中使用腳本變量。 CSS-in-JS使你能夠在樣式規(guī)則中引用JavaScript變量,例如:
如本例所示,你可以在CSS-in-JS樣式中使用JavaScript常量(例如colors)和React prop/state(例如fontSize)。在某些情況下,在樣式中使用JavaScript常量的功能減少了重復(fù)代碼,因為不必將同一常量既定義為CSS變量又定義為JavaScript常量。使用props和state的功能允許你創(chuàng)建具有高度可自定義樣式的組件,而無需使用內(nèi)聯(lián)樣式。(當(dāng)相同的樣式應(yīng)用于許多元素時,內(nèi)聯(lián)樣式對于性能并不友好。)
缺點
1.CSS-in-JS會增加運行時開銷。當(dāng)呈現(xiàn)組件時,CSS-in-JS庫必須將樣式“序列化”為可插入到文檔中的純CSS。很明顯,這會占用額外的CPU開銷。
2.CSS-in-JS會增加Bundle的大小。這是一個顯而易見的問題——每個訪問網(wǎng)站的用戶都必須下載CSS-in-JS庫。Emotion是7.9kB壓縮包,而styled-components是12.7kB。雖然這兩個庫都不是很大,但加在一起就不是了(react + react dom是44.5kB)。
3.CSS-in-JS會擾亂React開發(fā)工具。對于使用css prop的每個元素,Emotion將呈現(xiàn)<EmotionCssPropInternal>和<Insertion>組件。如果你在許多元素上使用css prop,那么Emotion的內(nèi)部組件確實會使React DevTools變得混亂,如下所示:
最可怕的幾個地方
1.頻繁插入CSS會迫使瀏覽器做很多額外的工作。在并發(fā)渲染中,React將會在渲染之間讓步于瀏覽器。如果你在組件中插入一個新CSS規(guī)則,那么瀏覽器必須先查看這些CSS規(guī)則是否適用于現(xiàn)有的DOM樹,因此會重新計算樣式規(guī)則。之后React渲染下一個組件,該組件發(fā)現(xiàn)一個新規(guī)則,然后同樣的情況再次發(fā)生。
這就導(dǎo)致了在React渲染時每幀針對所有DOM節(jié)點會重新計算所有CSS規(guī)則。
關(guān)于這個問題最糟糕的是,這不是一個可修復(fù)的問題(在運行時CSS-in-JS的上下文中)。運行時CSS-in-JS庫通過在組件呈現(xiàn)時插入新的樣式規(guī)則來工作,這對基本的性能是不利的。
2.使用CSS-in-JS可能會出現(xiàn)更多錯誤,尤其是在使用SSR或組件庫時。在Emotion GitHub存儲庫中,存在的問題有:
- 一次加載多個Emotion實例。
- 組件庫通常不能讓你完全控制樣式的插入順序。
- Emotion的SSR支持在React 17和React 18之間的工作方式不同。這對于與React 18的流式服務(wù)端渲染兼容是必要的。
這些缺點還只是冰山一角。
深入探討性能
很明顯,運行時CSS-in-JS既有重要的優(yōu)點,也有顯著的缺點。為了說明為什么我選擇遠離這項技術(shù),我們需要探索CSS-in-JS對實際性能的影響。
渲染內(nèi)部 vs. 渲染外部的序列化
樣式序列化是指Emotion獲取CSS字符串或?qū)ο髽邮讲⑵滢D(zhuǎn)換為可插入到文檔中的純CSS字符串的過程。Emotion還會在序列化期間計算普通CSS的哈希值——此哈希值是你在生成的類名中所看到的內(nèi)容,例如.css-15nl2r3。
Emotion文檔在渲染中執(zhí)行序列化的示例,如下所示:
每次渲染MyComponent時,都會再次序列化對象樣式。如果MyComponent頻繁渲染(例如,在每次擊鍵時),那么重復(fù)的序列化可能會產(chǎn)生很高的性能開銷。
提高性能的方法是將樣式移到組件外部,以便在加載模塊時進行一次序列化,而不是在每次渲染時都進行序列化。@emotion/react的css函數(shù)可以做到這一點:
當(dāng)然,這會阻止你訪問樣式中的prop,因此會錯過CSS-in-JS的主要優(yōu)點之一。
在使用Emotion時,對Member瀏覽器進行基準(zhǔn)測試
下面是一個關(guān)于Member瀏覽器的簡單列表視圖。幾乎所有Member瀏覽器的樣式都使用Emotion,特別是css prop。
在此次測試中:
Member瀏覽器將顯示20個用戶,
將刪除列表項周圍的React.memo,
強制最頂層<BrowseMembers>組件每秒渲染一次,并記錄前10次渲染的時間。
關(guān)閉React嚴(yán)格模式。
使用React DevTools分析該頁面,前10次渲染時間的平均值為54.3毫秒。
我個人的經(jīng)驗法則是,React組件的渲染時間應(yīng)該為16毫秒或更短的時間,因為以每秒60幀的速度渲染,渲染1幀是16.67毫秒。Member瀏覽器目前是這個數(shù)字的3倍以上,所以是一個非常重量級的組件。
該測試是在M1 Max CPU上執(zhí)行的,此CPU比普通用戶擁有的CPU快得多。在功能較弱的計算機上,54.3毫秒的渲染時間甚至很容易達到200毫秒。
分析火焰圖
以下是上述測試中單個列表項的火焰圖:
正如你所看到的,有大量的<Box>和<Flex>組件正在渲染——這些都使用css prop。雖然每個<Box>組件只需要0.1–0.2毫秒的渲染時間,但因為<Box>組件的總量大,所以總的耗時將是巨大的。
在沒有用Emotion時,對Member瀏覽器進行基準(zhǔn)測試
為了了解這種昂貴的渲染代價有多少是由于Emotion造成的,我重寫了Member瀏覽器樣式,使用的是Sass模塊而不是Emotion。(Sass模塊在構(gòu)建時編譯為普通CSS,因此使用時幾乎沒有性能損失。)
重復(fù)與上述相同的測試,前10次渲染的平均值是27.7毫秒。比原來減少了48%!
所以,這就是我們與CSS-in-JS說再見的原因:運行時性能成本太高了。
免責(zé)聲明:如果你的代碼庫以更高性能的方式使用Emotion(例如,在渲染之外進行樣式序列化),那么移除CSS-in-JS后,看到的性能提升可能并不顯著。
如果你對此測試感興趣,以下是原始數(shù)據(jù):
新的樣式系統(tǒng)
在下定決心要擺脫CSS-in-JS之后,有一個問題馬上擺在了我們面前:那么用什么來代替呢?理想情況下,我們想要一個性能類似于普通CSS的樣式系統(tǒng),同時盡可能多地保留CSS-in-JS的好處。也就是說最好具有:
可以控制樣式的應(yīng)用范圍。
樣式與應(yīng)用樣式的組件放在一起。
可以在樣式中使用腳本變量。
前面我說過CSS模塊也提供樣式應(yīng)用范圍控制和集中放置的能力。CSS模塊編譯為普通的CSS文件,因此使用它們沒有運行時性能成本。
但是,CSS模塊的主要缺點是,歸根結(jié)底,它們?nèi)匀皇瞧胀ǖ腃SS——而普通的CSS缺乏改善DX和減少代碼重復(fù)的功能。
幸運的是,這個問題有一個簡單的解決方案——Sass模塊,用Sass編寫的CSS模塊。獲得CSS模塊的本地范圍樣式和Sass強大構(gòu)建功能的同時,基本上沒有運行時成本。這就是為什么Sass模塊將成為我們未來的通用樣式解決方案的原因。
實用程序類
我們團隊對從Emotion切換到Sass模塊的一個擔(dān)憂是,應(yīng)用常見樣式,如display: flex會不太方便。以前:
如果僅使用Sass模塊執(zhí)行此操作,則必須打開.module.scss文件并創(chuàng)建一個應(yīng)用樣式display: flex和align-items: center的類。
為了改進這方面的DX,我們決定引入一個實用程序類系統(tǒng)。實用工具類是在元素上設(shè)置單個CSS屬性的CSS類。通常組合多個實用程序類以獲得所需的樣式。上面的示例可以編寫為:
Bootstrap和Tailwind是提供實用程序類的最流行的CSS框架。我已使用Bootstrap多年,所以選擇Bootstrap。雖然可以將Bootstrap實用程序類作為預(yù)構(gòu)建的CSS文件引入,但我們需要自定義類以適應(yīng)現(xiàn)有的樣式系統(tǒng),因此我將Bootstrap源代碼的相關(guān)部分復(fù)制到了項目中。
將Sass模塊和實用程序類用于新組件,我已經(jīng)使用幾個星期了,感到非常滿意。DX與Emotion相似,但運行時性能要優(yōu)越得多。
附注:還可以使用typed-scss-modules[3]來為Sass模塊生成類型腳本定義。這樣做的最大好處是,允許我們定義一個像classnames[4]一樣工作的utils()幫助函數(shù)。但是不方便的一點是,它只接受有效的實用程序類名作為參數(shù)。
關(guān)于編譯時CSS-in-JS的說明
本文重點介紹運行時CSS-in-JS庫,如Emotion和樣式化組件。最近,我發(fā)現(xiàn)有越來越多的CSS-in-JS庫在編譯時將樣式轉(zhuǎn)換為純CSS。包括:
- Compiled[5]
- Vanilla Extract[6]
- Linaria[7]
這些庫旨在提供與運行時CSS-in-JS類似的優(yōu)勢,而不會降低性能成本。
雖然我自己沒有使用過編譯時CSS-in-JS庫,但我仍然認(rèn)為與Sass模塊相比,它們有缺點。以下是我在查看編譯時看到的缺點:
- 當(dāng)組件首次掛載時,仍會插入樣式,這會強制瀏覽器在每個DOM節(jié)點上重新計算樣式。
- 動態(tài)樣式(如示例中的color prop)無法在構(gòu)建時提取,因此編譯時使用style prop(也稱為內(nèi)聯(lián)樣式)將值作為CSS變量添加。已知內(nèi)聯(lián)樣式在應(yīng)用許多元素時會導(dǎo)致性能欠佳。
- 庫仍會將樣式組件插入到React樹中。這將使React開發(fā)工具變得混亂,就像運行時CSS-in-JS一樣。
總結(jié)
本文深入探討了運行時CSS-in-JS的優(yōu)點和缺點。作為開發(fā)人員,我們需要評估這些優(yōu)缺點,然后就該技術(shù)是否適合使用案例做出明智的決定。對于我來說,Emotion的運行時性能成本遠遠超過了DX方面的好處,尤其是考慮到Sass模塊+實用程序類的替代方案仍然具有良好的DX,同時也提供了非常卓越的性能。
參考資料
[1]styled-components: https://styled-components.com/
[2]Emotion: https://emotion.sh/
[3]typed-scss-modules: https://www.npmjs.com/package/typed-scss-modules
[4]classnames: https://www.npmjs.com/package/classnames
[5]Compiled: https://compiledcssinjs.com/
[6]Vanilla Extract: https://vanilla-extract.style/
[7]Linaria: https://linaria.dev/