為什么和 CSS-in-JS 說拜拜

本文是由 Emotion 的第二大活躍維護者 Sam 分享,本文第一人稱都指的是 Sam。Emotion 是一個廣泛流行的 CSS-in-JS 庫,用于React。文章 Sam 會帶大家深入探討 CSS-in-JS 最初吸引人的原因,以及為什么作者(以及Spot團隊的其他成員)決定放棄它。
什么是 CSS-in-JS?
顧名思義,CSS-in-JS 就是在 JS 或 TS 中直接編寫 CSS,為 React 組件提供樣式,如下所示:
styled-components和Emotion是React社區中最流行的CSS-in-JS庫。雖然我只使用了Emotion,但我相信本文的所有觀點也適用于styled-components。
本文重點介紹運行時CSS-in-JS,這個類別包括 styled-components 和 Emotion。運行時CSS-in-JS 僅僅意味著庫在應用程序運行時解釋并應用你的樣式。我們會在文章的最后簡要討論編譯時 CSS-in-JS。
CSS-in-JS 的好、壞、丑
在討論 CSS-in-JS 編碼模式及其對性能的影響之前,先來看看為什么有的開發者會使用 CSS-in-JS,有的不會使用。
好處
1.局部作用域的樣式。在寫普通的CSS時,很容易不小心將樣式應用到其它文件中。例如,假設我們正在寫一個列表,每一行都應該有一些 padding 和 border 。我們可能會這樣寫:
幾個月后,當我們完全忘記了這個列表時,又創建了一個列表。然后也設置了 ??className="row"??。現在,新組件的行有一個難看的邊框,而我們卻不知道為什么! 雖然這類問題可以通過使用較長的類名或更具體的選擇器來解決,但作為開發者還是要確保沒有類名沖突。
CSS-in-JS 完全解決了這一問題,它使樣式默認為本地作用域。如果把上面的樣式寫成這樣:
這樣 padding 和 border 就不可能應用到其它元素了。
2.托管。如果使用普通的CSS,則可以將所有.css文件放在 src/styles 目錄中,而所有的React組件都在 src/components 中。隨著應用程序的大小的增長,很難判斷每個組件使用哪些樣式。很多時候,你的CSS中會出現死代碼,因為沒有簡單的方法可以說出這些樣式沒有使用。
一個更好的組織代碼的方法是將所有與單個組件相關的東西放在同一個地方。這種做法被稱為colocation (托管)。
問題是,在使用普通的CSS時,很難實現 colocation,因為CSS和JavaScript必須放在單獨的文件中,而且無論??.css??文件在哪里,你的樣式都會全局應用。另一方面,如果使用CSS-in-JS,可以直接在使用它們的React組件中編寫樣式 如果操作得當,這將極大地提高應用程序的可維護性。
3.可以在樣式中使用JavaScript變量。CSS-in-JS 可以在樣式規則中引用JavaScript變量,例如:
如本示例所示,可以在CSS-in-JS樣式中同時使用 JS 常量(例如 colors)和 React Props/state (例如 fontSize)。
在樣式中使用 JS 常量的能力在某些情況下可以降低重復,因為同一個常量不需要同時定義為CSS變量和 JS 常量。
使用 props 和 state 。
中立
這是一項熱門的新技術。許多Web開發者,包括我自己,一般會社區中最熱門的新趨勢。部分原因是這樣的,因為在很多情況下,新的庫和框架已經被證明比它們的前輩有巨大的改進(想想React比早期的庫如jQuery提高了多少生產力就知道了)。
另一方面,我們對新工具的癡迷是害怕錯過下一個大事件,在決定采用一個新的庫或框架時,我們可能忽略了真正的缺點。我認為這肯定是CSS-in-JS被廣泛采用的一個因素--至少對我來說是這樣。
不好
1.CSS-in-JS增加了運行時的開銷。當組件渲染時,CSS-in-JS庫必須將樣式 "序列化"為可以插入到文檔中的普通CSS。很明顯,這需要占用額外的CPU周期,但這是否足以對應用程序的性能產生明顯的影響?我們在下一節中深入研究這個問題。
2 CSS-in-JS增加的包的大小。這是一個明顯的問題--每個訪問你網站的用戶都必須下載CSS-in-JS庫的JavaScript。Emotion 的最小壓縮量是7.9 kB?,styled-components 是12.7 kB。
3.CSS-in-JS會打亂React DevTools。對于每個使用css prop 的元素,Emotion會渲染<EmotionCssPropInternal>和<Insertion>組件。如果你在許多元素上使用css prop,Emotion 的內部組件會使React DevTools變得非常混亂,如圖所示。

丑
1.頻繁插入CSS規則迫使瀏覽器做很多額外的工作。React核心團隊成員、React Hooks的最初設計師Sebastian Markb?ge在React 18工作組中寫了一篇非常有見地的討論,內容是關于CSS-in-JS庫需要如何改變才能與React 18一起工作,以及總體上關于運行時CSS-in-JS的未來。特別是,他說:
在并發渲染中,React可以在渲染之間向瀏覽器讓步。如果在一個組件中插入一個新的規則,如果React 讓步了,那么瀏覽器就必須看看這些規則是否適用于現有的樹。所以它會重新計算樣式規則。然后React渲染下一個組件,然后該組件發現了一個新的規則,再次發生。引用 這有效地導致在React渲染時,每一幀都要針對所有DOM節點重新計算所有CSS規則。這是很慢的。
這個問題最糟糕的地方在于,它不是一個可修復的問題(在運行時CSS-in-JS的上下文中)。運行時CSS-in-JS庫通過在組件渲染時插入新的樣式規則來工作,這在基本層面上不利于性能。
2.對于CSS-in-JS,可能出錯的地方還有很多,尤其是在使用SSR或組件庫的時候。在Emotion的GitHub倉庫里,我們收到了大量這樣的問題。
我正在使用Emotion與服務器端渲染和MUI/Mantine/(另一個Emotion驅動的組件庫),它不能工作,因為...
雖然每個問題的根本原因各不相同,但有一些共同的原因:
- Emotion的多個實例被同時加載。即使多個實例都是同一版本的Emotion,這也會導致問題。(issue)
- 組件庫通常不能完全控制插入樣式的順序。(issue)
- Emotion的SSR支持在React 17和React 18之間的工作方式不同。為了與React 18的流式SSR兼容,這是必要的。(issue)
這些復雜性只是冰山一角。
性能
運行時 CSS-in-JS既有明顯的優點也有明顯的缺點。為了理解我們的團隊為什么要放棄這項技術,我們需要探索CSS-in-JS的實際性能影響。
本節重點介紹Emotion 對性能的影響,因為它被用于 Spot 代碼庫。因此,如果認為下給出的性能數據也適用于你的代碼庫,那就錯了--有很多方法可以使用Emotion,而且每一種方法都有自己的性能特點。
渲染內的序列化與渲染外的序列化
樣式序列化是指Emotion將CSS字符串或對象樣式轉換為可以插入文檔的普通CSS字符串的過程。在序列化過程中,Emotion也會計算出一個普通CSS的哈希值--這個哈希值就是你在生成的類名中看到的,例如css-15nl2r3。
雖然我沒有測量過這一點,但我相信影響Emotion如何執行的最重要因素之一是樣式序列化是在React渲染循環內部還是外部執行的。
Emotion文檔中的例子是在render里面進行序列化的,像這樣。
每次MyComponent渲染的時候,對象的樣式都會被再次序列化。如果MyComponent頻繁地渲染(例如每次按鍵),重復的序列化可能會有很高的性能代價。
一個更有效的方法是把樣式移到組件之外,這樣序列化就會在模塊加載時一次性發生,而不是在每次渲染時。這可以通過@emotion/react的css函數來實現:
當然,這種方式就無法在樣式中訪問 props,所以錯過了CSS-in-JS的主要賣點之一。
在Spot,我們在render中進行了樣式序列化,所以下面的性能分析將集中于這種情況。
對Member Browser 進行基準測試
現在通過對Spot的一個真正的組件進行分析來使事情具體化。我們將使用 Member Browser,這是一個相當簡單的列表視圖,可以顯示你的團隊中的所有用戶。

為了測試:
- Member Browser 顯示20個用戶。
- React.memo 周圍的列表項目將被刪除,并且強制最上面的<BrowseMembers>組件每秒鐘渲染一次,并記錄前10次渲染的時間。
- React嚴格模式是關閉的。(它可以效地讓我們在分析器中看到的渲染時間翻倍)。
我使用React DevTools對該頁面進行了分析,前10次渲染時間的平均值為54.3ms。
我個人的經驗是,一個React組件的渲染時間應該在16毫秒以內,因為每秒60幀的1幀是16.67毫秒。Member Browser 目前是這個數字的3倍多,所以它是一個相當重量級的組件。
這個測試是在M1 Max CPU上進行的,它比普通用戶的速度要快很多。我得到的54.3毫秒的渲染時間在性能較差的機器上可能很容易達到200毫秒。
使用火焰圖(FlameGraph)分析程序性能
下面是上述測試中單個列表項的火焰圖:

正如你所看到的,有大量的<Box>和<Flex>組件被渲染--這些是我們的 "tyle primitives",使用css prop。雖然每個<Box>只需要0.1-0.2毫秒的渲染時間,但由于<Box>組件的總數非常大,所以這就增加了。
不使用 Emotion,對 Member Browser 進行測試
為了了解這種昂貴的渲染有多少是由Emotion造成的,我使用Sass Modules而不是Emotion重寫了Member Browser 的樣式。(Sass模塊在構建時被編譯成普通的CSS,所以使用它們幾乎沒有性能損失)。
我重復了上述同樣的測試,前10次渲染的平均時間為27.7ms。這比原來的時間減少了48%!
所以,這就是我們與CSS-in-JS 說拜拜的原因:運行時的性能成本實在是太高了。
重復我上面的免責聲明:這個結果只直接適用于Spot代碼庫和我們使用Emotion的方式。如果你的代碼庫以一種更有效的方式使用Emotion(例如在render之外的樣式序列化),你可能會看到從方程中移除CSS-in-JS后的更小好處。
下面是一些數據,供那些好奇的人參考:

我們新的樣式系統
在我們下定決心不再使用CSS-in-JS之后,一個新的問題就會出現:我們應該用什么來代替?理想情況下,我們希望樣式系統的性能與普通CSS類似,同時盡可能多地保留CSS-in-JS的優點:
- 局部作用域
- 樣式與它們所應用的組件放在同個地方
- 可以在樣式中使用 JS 變量
如果你仔細看了那一節,你會記得我說過,CSS Module 還提供了局部作用域的樣式和同位。而且,CSS Module 可以編譯成普通的CSS文件,所以使用它們沒有運行時的性能成本。
在我看來,CSS模塊的主要缺點是,說到底,它們仍然是普通的CSS--而普通的CSS缺乏改善DX和減少代碼重復的功能。雖然嵌套選擇器即將出現在CSS中,但它們還沒有出現,而這個功能對我們來說是一個巨大開發質量的提升。
幸運的是,這個問題有一個簡單的解決方案--Sass模塊,它只是用Sass編寫的CSS模塊。你可以得到CSS模塊的局部范圍的樣式和Sass強大的構建時間功能,而且基本上沒有運行時間成本。這就是為什么Sass模塊將成為我們未來的通用樣式解決方案。
實用類
對于從Emotion切換到Sass Modules,團隊的一個擔心是,應用極其常見的樣式,如display: flex,會不太方便。以前,我們會寫。
為了只使用Sass模塊做到這一點,我們必須打開.module。SCSS文件并創建一個應用樣式display: flex和align-items: center的類。雖然不是世界末日,但確實不那么方便了。
如果只使用Sass模塊,我們不得在新建.module.scss文件,并創建一個類,應用樣式display: flex 和 align-items: center。這并不是災難,但肯定不那么方便。
為了改進DX,我們決定引入一個實用類系統。實用類就是是在元素上設置一個單一的CSS屬性的CSS類。通常情況下,結合多個實用類來獲得所需的樣式。對于上面的例子,可以這樣寫。
Bootstrap和Tailwind是提供實用程序類的最流行的CSS框架。這些庫在其實用程序系統中投入了大量的設計工作,所以采用其中一個而不是推出我們自己的實用程序是最有意義的。我已經使用Bootstrap多年了,所以我們選擇了Bootstrap。雖然你可以把Bootstrap的實用類作為一個預建的CSS文件,但我們需要定制這些類來適應我們現有的樣式系統,所以我把Bootstrap源代碼的相關部分復制到我們的項目中。
我們使用Sass模塊和實用類的新組件已經有幾個星期了,對它相當滿意。DX與Emotion相似,而運行時的性能則大大優于Emotion。
關于編譯時CSS-in-JS的說明
本文主要介紹運行時的CSS-in-JS庫,如 Emotion 和s tyled-components。最近,我們看到越來越多的CSS-in-JS庫在編譯時將樣式轉換為普通CSS。這些庫包括:
- Compiled
- Vanilla Extract
- Linaria
這些庫旨在提供類似于運行時CSS-in-JS的好處,而沒有性能成本。
雖然我自己沒有使用過任何編譯時的CSS-in-JS庫,但我仍然認為它們與Sass模塊相比有缺點。以下是我在觀察Compiled時看到的缺點:
- 樣式仍然是在組件第一次掛載時插入的,這迫使瀏覽器在每個DOM節點上重新計算樣式。(這個缺點已經在 "丑"一節中討論過了)。
- 像本例中的 color prop 這樣的動態樣式不能在構建時提取,所以Compiled使用 style prop(又稱內聯樣式)將該值添加為CSS變量。眾所周知,當應用許多元素時,內聯樣式會導致次優的性能
- 該庫仍然將模板組件插入你的React樹中,如圖所示。這將使React DevTools變得混亂,就像運行時的CSS-in-JS一樣。
總結
任何技術一樣,它有其優點和缺點。歸根結底,作為一個開發者,你應該評估這些優點和缺點,然后就該技術是否適合你的使用情況做出一個明智的決定。對于我們Spot公司來說,Emotion的運行時性能成本遠遠超過了DX的好處,特別是當你考慮到Sass模塊+實用類的替代方案仍然有一個很好的DX,同時提供巨大的性能。


























