為什么和 CSS-in-JS 說(shuō)拜拜
本文是由 Emotion 的第二大活躍維護(hù)者 Sam 分享,本文第一人稱都指的是 Sam。Emotion 是一個(gè)廣泛流行的 CSS-in-JS 庫(kù),用于React。文章 Sam 會(huì)帶大家深入探討 CSS-in-JS 最初吸引人的原因,以及為什么作者(以及Spot團(tuán)隊(duì)的其他成員)決定放棄它。
什么是 CSS-in-JS?
顧名思義,CSS-in-JS 就是在 JS 或 TS 中直接編寫 CSS,為 React 組件提供樣式,如下所示:
styled-components和Emotion是React社區(qū)中最流行的CSS-in-JS庫(kù)。雖然我只使用了Emotion,但我相信本文的所有觀點(diǎn)也適用于styled-components。
本文重點(diǎn)介紹運(yùn)行時(shí)CSS-in-JS,這個(gè)類別包括 styled-components 和 Emotion。運(yùn)行時(shí)CSS-in-JS 僅僅意味著庫(kù)在應(yīng)用程序運(yùn)行時(shí)解釋并應(yīng)用你的樣式。我們會(huì)在文章的最后簡(jiǎn)要討論編譯時(shí) CSS-in-JS。
CSS-in-JS 的好、壞、丑
在討論 CSS-in-JS 編碼模式及其對(duì)性能的影響之前,先來(lái)看看為什么有的開發(fā)者會(huì)使用 CSS-in-JS,有的不會(huì)使用。
好處
1.局部作用域的樣式。在寫普通的CSS時(shí),很容易不小心將樣式應(yīng)用到其它文件中。例如,假設(shè)我們正在寫一個(gè)列表,每一行都應(yīng)該有一些 padding 和 border 。我們可能會(huì)這樣寫:
幾個(gè)月后,當(dāng)我們完全忘記了這個(gè)列表時(shí),又創(chuàng)建了一個(gè)列表。然后也設(shè)置了 ??className="row"?
??,F(xiàn)在,新組件的行有一個(gè)難看的邊框,而我們卻不知道為什么! 雖然這類問(wèn)題可以通過(guò)使用較長(zhǎng)的類名或更具體的選擇器來(lái)解決,但作為開發(fā)者還是要確保沒有類名沖突。
CSS-in-JS 完全解決了這一問(wèn)題,它使樣式默認(rèn)為本地作用域。如果把上面的樣式寫成這樣:
這樣 padding 和 border 就不可能應(yīng)用到其它元素了。
2.托管。如果使用普通的CSS,則可以將所有.css文件放在 src/styles 目錄中,而所有的React組件都在 src/components 中。隨著應(yīng)用程序的大小的增長(zhǎng),很難判斷每個(gè)組件使用哪些樣式。很多時(shí)候,你的CSS中會(huì)出現(xiàn)死代碼,因?yàn)闆]有簡(jiǎn)單的方法可以說(shuō)出這些樣式?jīng)]有使用。
一個(gè)更好的組織代碼的方法是將所有與單個(gè)組件相關(guān)的東西放在同一個(gè)地方。這種做法被稱為colocation (托管)。
問(wèn)題是,在使用普通的CSS時(shí),很難實(shí)現(xiàn) colocation,因?yàn)镃SS和JavaScript必須放在單獨(dú)的文件中,而且無(wú)論??.css?
?文件在哪里,你的樣式都會(huì)全局應(yīng)用。另一方面,如果使用CSS-in-JS,可以直接在使用它們的React組件中編寫樣式 如果操作得當(dāng),這將極大地提高應(yīng)用程序的可維護(hù)性。
3.可以在樣式中使用JavaScript變量。CSS-in-JS 可以在樣式規(guī)則中引用JavaScript變量,例如:
如本示例所示,可以在CSS-in-JS樣式中同時(shí)使用 JS 常量(例如 colors)和 React Props/state (例如 fontSize)。
在樣式中使用 JS 常量的能力在某些情況下可以降低重復(fù),因?yàn)橥粋€(gè)常量不需要同時(shí)定義為CSS變量和 JS 常量。
使用 props 和 state 。
中立
這是一項(xiàng)熱門的新技術(shù)。許多Web開發(fā)者,包括我自己,一般會(huì)社區(qū)中最熱門的新趨勢(shì)。部分原因是這樣的,因?yàn)樵诤芏嗲闆r下,新的庫(kù)和框架已經(jīng)被證明比它們的前輩有巨大的改進(jìn)(想想React比早期的庫(kù)如jQuery提高了多少生產(chǎn)力就知道了)。
另一方面,我們對(duì)新工具的癡迷是害怕錯(cuò)過(guò)下一個(gè)大事件,在決定采用一個(gè)新的庫(kù)或框架時(shí),我們可能忽略了真正的缺點(diǎn)。我認(rèn)為這肯定是CSS-in-JS被廣泛采用的一個(gè)因素--至少對(duì)我來(lái)說(shuō)是這樣。
不好
1.CSS-in-JS增加了運(yùn)行時(shí)的開銷。當(dāng)組件渲染時(shí),CSS-in-JS庫(kù)必須將樣式 "序列化"為可以插入到文檔中的普通CSS。很明顯,這需要占用額外的CPU周期,但這是否足以對(duì)應(yīng)用程序的性能產(chǎn)生明顯的影響?我們?cè)谙乱还?jié)中深入研究這個(gè)問(wèn)題。
2 CSS-in-JS增加的包的大小。這是一個(gè)明顯的問(wèn)題--每個(gè)訪問(wèn)你網(wǎng)站的用戶都必須下載CSS-in-JS庫(kù)的JavaScript。Emotion 的最小壓縮量是7.9 kB?,styled-components 是12.7 kB。
3.CSS-in-JS會(huì)打亂React DevTools。對(duì)于每個(gè)使用css prop 的元素,Emotion會(huì)渲染<EmotionCssPropInternal>和<Insertion>組件。如果你在許多元素上使用css prop,Emotion 的內(nèi)部組件會(huì)使React DevTools變得非?;靵y,如圖所示。
丑
1.頻繁插入CSS規(guī)則迫使瀏覽器做很多額外的工作。React核心團(tuán)隊(duì)成員、React Hooks的最初設(shè)計(jì)師Sebastian Markb?ge在React 18工作組中寫了一篇非常有見地的討論,內(nèi)容是關(guān)于CSS-in-JS庫(kù)需要如何改變才能與React 18一起工作,以及總體上關(guān)于運(yùn)行時(shí)CSS-in-JS的未來(lái)。特別是,他說(shuō):
在并發(fā)渲染中,React可以在渲染之間向?yàn)g覽器讓步。如果在一個(gè)組件中插入一個(gè)新的規(guī)則,如果React 讓步了,那么瀏覽器就必須看看這些規(guī)則是否適用于現(xiàn)有的樹。所以它會(huì)重新計(jì)算樣式規(guī)則。然后React渲染下一個(gè)組件,然后該組件發(fā)現(xiàn)了一個(gè)新的規(guī)則,再次發(fā)生。引用 這有效地導(dǎo)致在React渲染時(shí),每一幀都要針對(duì)所有DOM節(jié)點(diǎn)重新計(jì)算所有CSS規(guī)則。這是很慢的。
這個(gè)問(wèn)題最糟糕的地方在于,它不是一個(gè)可修復(fù)的問(wèn)題(在運(yùn)行時(shí)CSS-in-JS的上下文中)。運(yùn)行時(shí)CSS-in-JS庫(kù)通過(guò)在組件渲染時(shí)插入新的樣式規(guī)則來(lái)工作,這在基本層面上不利于性能。
2.對(duì)于CSS-in-JS,可能出錯(cuò)的地方還有很多,尤其是在使用SSR或組件庫(kù)的時(shí)候。在Emotion的GitHub倉(cāng)庫(kù)里,我們收到了大量這樣的問(wèn)題。
我正在使用Emotion與服務(wù)器端渲染和MUI/Mantine/(另一個(gè)Emotion驅(qū)動(dòng)的組件庫(kù)),它不能工作,因?yàn)?..
雖然每個(gè)問(wèn)題的根本原因各不相同,但有一些共同的原因:
- Emotion的多個(gè)實(shí)例被同時(shí)加載。即使多個(gè)實(shí)例都是同一版本的Emotion,這也會(huì)導(dǎo)致問(wèn)題。(issue)
- 組件庫(kù)通常不能完全控制插入樣式的順序。(issue)
- Emotion的SSR支持在React 17和React 18之間的工作方式不同。為了與React 18的流式SSR兼容,這是必要的。(issue)
這些復(fù)雜性只是冰山一角。
性能
運(yùn)行時(shí) CSS-in-JS既有明顯的優(yōu)點(diǎn)也有明顯的缺點(diǎn)。為了理解我們的團(tuán)隊(duì)為什么要放棄這項(xiàng)技術(shù),我們需要探索CSS-in-JS的實(shí)際性能影響。
本節(jié)重點(diǎn)介紹Emotion 對(duì)性能的影響,因?yàn)樗挥糜?Spot 代碼庫(kù)。因此,如果認(rèn)為下給出的性能數(shù)據(jù)也適用于你的代碼庫(kù),那就錯(cuò)了--有很多方法可以使用Emotion,而且每一種方法都有自己的性能特點(diǎn)。
渲染內(nèi)的序列化與渲染外的序列化
樣式序列化是指Emotion將CSS字符串或?qū)ο髽邮睫D(zhuǎn)換為可以插入文檔的普通CSS字符串的過(guò)程。在序列化過(guò)程中,Emotion也會(huì)計(jì)算出一個(gè)普通CSS的哈希值--這個(gè)哈希值就是你在生成的類名中看到的,例如css-15nl2r3。
雖然我沒有測(cè)量過(guò)這一點(diǎn),但我相信影響Emotion如何執(zhí)行的最重要因素之一是樣式序列化是在React渲染循環(huán)內(nèi)部還是外部執(zhí)行的。
Emotion文檔中的例子是在render里面進(jìn)行序列化的,像這樣。
每次MyComponent渲染的時(shí)候,對(duì)象的樣式都會(huì)被再次序列化。如果MyComponent頻繁地渲染(例如每次按鍵),重復(fù)的序列化可能會(huì)有很高的性能代價(jià)。
一個(gè)更有效的方法是把樣式移到組件之外,這樣序列化就會(huì)在模塊加載時(shí)一次性發(fā)生,而不是在每次渲染時(shí)。這可以通過(guò)@emotion/react的css函數(shù)來(lái)實(shí)現(xiàn):
當(dāng)然,這種方式就無(wú)法在樣式中訪問(wèn) props,所以錯(cuò)過(guò)了CSS-in-JS的主要賣點(diǎn)之一。
在Spot,我們?cè)趓ender中進(jìn)行了樣式序列化,所以下面的性能分析將集中于這種情況。
對(duì)Member Browser 進(jìn)行基準(zhǔn)測(cè)試
現(xiàn)在通過(guò)對(duì)Spot的一個(gè)真正的組件進(jìn)行分析來(lái)使事情具體化。我們將使用 Member Browser,這是一個(gè)相當(dāng)簡(jiǎn)單的列表視圖,可以顯示你的團(tuán)隊(duì)中的所有用戶。
為了測(cè)試:
- Member Browser 顯示20個(gè)用戶。
- React.memo 周圍的列表項(xiàng)目將被刪除,并且強(qiáng)制最上面的<BrowseMembers>組件每秒鐘渲染一次,并記錄前10次渲染的時(shí)間。
- React嚴(yán)格模式是關(guān)閉的。(它可以效地讓我們?cè)诜治銎髦锌吹降匿秩緯r(shí)間翻倍)。
我使用React DevTools對(duì)該頁(yè)面進(jìn)行了分析,前10次渲染時(shí)間的平均值為54.3ms。
我個(gè)人的經(jīng)驗(yàn)是,一個(gè)React組件的渲染時(shí)間應(yīng)該在16毫秒以內(nèi),因?yàn)槊棵?0幀的1幀是16.67毫秒。Member Browser 目前是這個(gè)數(shù)字的3倍多,所以它是一個(gè)相當(dāng)重量級(jí)的組件。
這個(gè)測(cè)試是在M1 Max CPU上進(jìn)行的,它比普通用戶的速度要快很多。我得到的54.3毫秒的渲染時(shí)間在性能較差的機(jī)器上可能很容易達(dá)到200毫秒。
使用火焰圖(FlameGraph)分析程序性能
下面是上述測(cè)試中單個(gè)列表項(xiàng)的火焰圖:
正如你所看到的,有大量的<Box>和<Flex>組件被渲染--這些是我們的 "tyle primitives",使用css prop。雖然每個(gè)<Box>只需要0.1-0.2毫秒的渲染時(shí)間,但由于<Box>組件的總數(shù)非常大,所以這就增加了。
不使用 Emotion,對(duì) Member Browser 進(jìn)行測(cè)試
為了了解這種昂貴的渲染有多少是由Emotion造成的,我使用Sass Modules而不是Emotion重寫了Member Browser 的樣式。(Sass模塊在構(gòu)建時(shí)被編譯成普通的CSS,所以使用它們幾乎沒有性能損失)。
我重復(fù)了上述同樣的測(cè)試,前10次渲染的平均時(shí)間為27.7ms。這比原來(lái)的時(shí)間減少了48%!
所以,這就是我們與CSS-in-JS 說(shuō)拜拜的原因:運(yùn)行時(shí)的性能成本實(shí)在是太高了。
重復(fù)我上面的免責(zé)聲明:這個(gè)結(jié)果只直接適用于Spot代碼庫(kù)和我們使用Emotion的方式。如果你的代碼庫(kù)以一種更有效的方式使用Emotion(例如在render之外的樣式序列化),你可能會(huì)看到從方程中移除CSS-in-JS后的更小好處。
下面是一些數(shù)據(jù),供那些好奇的人參考:
我們新的樣式系統(tǒng)
在我們下定決心不再使用CSS-in-JS之后,一個(gè)新的問(wèn)題就會(huì)出現(xiàn):我們應(yīng)該用什么來(lái)代替?理想情況下,我們希望樣式系統(tǒng)的性能與普通CSS類似,同時(shí)盡可能多地保留CSS-in-JS的優(yōu)點(diǎn):
- 局部作用域
- 樣式與它們所應(yīng)用的組件放在同個(gè)地方
- 可以在樣式中使用 JS 變量
如果你仔細(xì)看了那一節(jié),你會(huì)記得我說(shuō)過(guò),CSS Module 還提供了局部作用域的樣式和同位。而且,CSS Module 可以編譯成普通的CSS文件,所以使用它們沒有運(yùn)行時(shí)的性能成本。
在我看來(lái),CSS模塊的主要缺點(diǎn)是,說(shuō)到底,它們?nèi)匀皇瞧胀ǖ腃SS--而普通的CSS缺乏改善DX和減少代碼重復(fù)的功能。雖然嵌套選擇器即將出現(xiàn)在CSS中,但它們還沒有出現(xiàn),而這個(gè)功能對(duì)我們來(lái)說(shuō)是一個(gè)巨大開發(fā)質(zhì)量的提升。
幸運(yùn)的是,這個(gè)問(wèn)題有一個(gè)簡(jiǎn)單的解決方案--Sass模塊,它只是用Sass編寫的CSS模塊。你可以得到CSS模塊的局部范圍的樣式和Sass強(qiáng)大的構(gòu)建時(shí)間功能,而且基本上沒有運(yùn)行時(shí)間成本。這就是為什么Sass模塊將成為我們未來(lái)的通用樣式解決方案。
實(shí)用類
對(duì)于從Emotion切換到Sass Modules,團(tuán)隊(duì)的一個(gè)擔(dān)心是,應(yīng)用極其常見的樣式,如display: flex,會(huì)不太方便。以前,我們會(huì)寫。
為了只使用Sass模塊做到這一點(diǎn),我們必須打開.module。SCSS文件并創(chuàng)建一個(gè)應(yīng)用樣式display: flex和align-items: center的類。雖然不是世界末日,但確實(shí)不那么方便了。
如果只使用Sass模塊,我們不得在新建.module.scss文件,并創(chuàng)建一個(gè)類,應(yīng)用樣式display: flex 和 align-items: center。這并不是災(zāi)難,但肯定不那么方便。
為了改進(jìn)DX,我們決定引入一個(gè)實(shí)用類系統(tǒng)。實(shí)用類就是是在元素上設(shè)置一個(gè)單一的CSS屬性的CSS類。通常情況下,結(jié)合多個(gè)實(shí)用類來(lái)獲得所需的樣式。對(duì)于上面的例子,可以這樣寫。
Bootstrap和Tailwind是提供實(shí)用程序類的最流行的CSS框架。這些庫(kù)在其實(shí)用程序系統(tǒng)中投入了大量的設(shè)計(jì)工作,所以采用其中一個(gè)而不是推出我們自己的實(shí)用程序是最有意義的。我已經(jīng)使用Bootstrap多年了,所以我們選擇了Bootstrap。雖然你可以把Bootstrap的實(shí)用類作為一個(gè)預(yù)建的CSS文件,但我們需要定制這些類來(lái)適應(yīng)我們現(xiàn)有的樣式系統(tǒng),所以我把Bootstrap源代碼的相關(guān)部分復(fù)制到我們的項(xiàng)目中。
我們使用Sass模塊和實(shí)用類的新組件已經(jīng)有幾個(gè)星期了,對(duì)它相當(dāng)滿意。DX與Emotion相似,而運(yùn)行時(shí)的性能則大大優(yōu)于Emotion。
關(guān)于編譯時(shí)CSS-in-JS的說(shuō)明
本文主要介紹運(yùn)行時(shí)的CSS-in-JS庫(kù),如 Emotion 和s tyled-components。最近,我們看到越來(lái)越多的CSS-in-JS庫(kù)在編譯時(shí)將樣式轉(zhuǎn)換為普通CSS。這些庫(kù)包括:
- Compiled
- Vanilla Extract
- Linaria
這些庫(kù)旨在提供類似于運(yùn)行時(shí)CSS-in-JS的好處,而沒有性能成本。
雖然我自己沒有使用過(guò)任何編譯時(shí)的CSS-in-JS庫(kù),但我仍然認(rèn)為它們與Sass模塊相比有缺點(diǎn)。以下是我在觀察Compiled時(shí)看到的缺點(diǎn):
- 樣式仍然是在組件第一次掛載時(shí)插入的,這迫使瀏覽器在每個(gè)DOM節(jié)點(diǎn)上重新計(jì)算樣式。(這個(gè)缺點(diǎn)已經(jīng)在 "丑"一節(jié)中討論過(guò)了)。
- 像本例中的 color prop 這樣的動(dòng)態(tài)樣式不能在構(gòu)建時(shí)提取,所以Compiled使用 style prop(又稱內(nèi)聯(lián)樣式)將該值添加為CSS變量。眾所周知,當(dāng)應(yīng)用許多元素時(shí),內(nèi)聯(lián)樣式會(huì)導(dǎo)致次優(yōu)的性能
- 該庫(kù)仍然將模板組件插入你的React樹中,如圖所示。這將使React DevTools變得混亂,就像運(yùn)行時(shí)的CSS-in-JS一樣。
總結(jié)
任何技術(shù)一樣,它有其優(yōu)點(diǎn)和缺點(diǎn)。歸根結(jié)底,作為一個(gè)開發(fā)者,你應(yīng)該評(píng)估這些優(yōu)點(diǎn)和缺點(diǎn),然后就該技術(shù)是否適合你的使用情況做出一個(gè)明智的決定。對(duì)于我們Spot公司來(lái)說(shuō),Emotion的運(yùn)行時(shí)性能成本遠(yuǎn)遠(yuǎn)超過(guò)了DX的好處,特別是當(dāng)你考慮到Sass模塊+實(shí)用類的替代方案仍然有一個(gè)很好的DX,同時(shí)提供巨大的性能。