這篇文章將深入的挖掘我當時為什么會在項目中使用 css-in-JS (本文使用 Emotion 方案 ),而現在為什么正在放棄這樣的方案。
什么是 CSS-in-JS
CSS-in-JS 允許你直接使用 JAVAScript 或者 TypeScript 修改你的 React 組件的樣式
import styled from '@emotion/styled'
const ErrorMessageRed = styled.div`
color: red;
font-weight: bold;
`;
function App() {
return (
<div>
<ErrorMessageRed>
hello ErrorMessageRed !!
</ErrorMessageRed>
</div>
);
}
export default App;
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 的模式和它對性能的影響之前,我們先從總體的了解一下為什么我們會使用這項技術以及為什么要逐步放棄
優點
1.Locally-scoped styles: 當我們在裸寫 CSS 的時候,很容易就污染到其他我們意想不到的組件。比如我們寫了一個列表,每一行的需要加一個內邊距和邊框的樣式。我們可能會寫這樣的 CSS 代碼
.row {
padding: 0.5rem;
border: 1px solid #ddd;
}
幾個月之后可能你已經忘記了這個列表的代碼了,然后你寫了 className="row" 在另外的組件上,那么這個新的組件有了內邊距合邊框樣式,你甚至都不知道為什么會這樣。你可以使用更長的類名或者更加明確的選擇器來避免這樣的情況發生,但是你還是無法完全保證不會再出現這樣的樣式沖突。
CSS-in-JS 就可以通過 Locally-scoped styles 來完全解決這個問題。如果你的列表代碼這么寫的話:
<div className={css`
padding: 0.5rem;
border: 1px solid #ddd;
`}>
...row item...
</div>
這樣的話,內邊距和邊框的樣式永遠不會影響到其他組件。
提示:CSS Modules 也提供了 Locally-scoped styles
2. Colocation: 你的 React 組件是寫在 src/components 目錄中的,當你裸寫 CSS 的時候,你的 .css 文件可能是放置在 src/styles 目錄中。隨著項目越來越大,你很難明確哪些 CSS 樣式是用在哪些組件上,這樣最后你會冗余很多樣式代碼。
一個更好的組織代碼的方式可能是將相關的代碼文件放在同個地方。這種做法成為「共置」,可以通過這篇文章了解一下。
問題在于其實很難實現所謂的「共置」。如果在項目中裸寫 CSS 的話,你的樣式和可能會作用于全局不管你的 .css 文件被放置在哪里。另一方面,如果你使用 CSS-in-JS,你可以直接在 React 組件內部書寫樣式,如果組織得好,那么你的項目的可維護性將大大提升。
提示:CSS Modules 也提供了「共置」的能力
3. 在樣式中使用 JavaScript 變量: CSS-in-JS 提供了讓你在樣式中訪問 JavaScript 變量的能力
function App(props) {
const color = "red";
const ErrorMessageRed = styled.div`
color: ${props.color || color};
font-weight: bold;
`;
return (
<div>
<ErrorMessageRed>
hello ErrorMessageRed !!
</ErrorMessageRed>
</div>
);
}
上面的例子展示了,我們可以在 CSS-in-JS 方案中使用 JavaScript 的 const 變量 或者是 React 組件的 props。這樣可以減少很多重復代碼,當我們需要同時在 JavaScript 和 CSS 兩側定義相同的變量的時候。我們通過這樣的能力可以不需要使用 inline styles 這樣的方式來完成高度自定義的樣式。( inline styles 對性能不是特別友好,當我們有很多相同的樣式寫在不同的組件的時候)
中立點
1. 這是熱門的新技術: 許多的開發者包括我自己,會更熱衷于使用 JavaScript 社區中熱門的新技術。一個重要的原因是,很多新的框架或者庫,能夠提升帶來巨大的性能或者體驗上的提升(想象一下,React 對比 jQuery 帶來的開發效率提升)。另一個原因就是,我們對新技術抱有比較開放的態度,我們不愿意錯過每個大事件。當然了,我們在選擇新的技術的時候也會考慮到它帶來的負面影響。這大概就是我之前選擇 CSS-in-JS 的原因。
缺點
- CSS-in-JS 的運行時問題。當你的組件進行渲染的時候,CSS-in-JS 庫會在運行時將你的樣式代碼 ”序列化” 為可以插入文檔的 CSS 。這無疑會消耗瀏覽器更多的 CPU 性能
- CSS-in-JS 讓你的包體積更大了。 這是一個明顯的問題。每個訪問你的站點的用戶都不得不加載關于 CSS-in-JS 的 JavaScript。Emotion 的包體積壓縮之后是 7.9k ,而 styled-components 則是 12.7 kB 。雖然這些包都不算是特別大,但是如果再加上 react & react-dom 的話,那也是不小的開銷。
- CSS-in-JS 讓 React DevTools 變得難看。 每一個使用 css prop 的 react 元素, Emotion 都會渲染成 <EmotionCssPropInternal> 和 <Insertion> 組件。如果你使用很多的 css prop,那么你會在 React DevTools 看到下面這樣的場景
- 頻繁的插入 CSS 樣式規則會迫使瀏覽器做更多的工作。 React 團隊核心成員&React Hooks 設計者 Sebasian 寫了一篇關于 CSS-in-JS 庫如何與 React 18 一起工作的文章。他特別說到
在 concurrent 渲染模式下,React 可以在渲染之間讓出瀏覽器的控制權。如果你為一個組件插入一個新的 CSS 規則,然后 React 讓出控制權,瀏覽器會檢查這個新的規則是否作用到了已有的樹上。所以瀏覽器重新計算了樣式規則。然后 React 渲染下一個組件,該組件發現一個新的規則,那么又會重新觸發樣式規則的計算。
實際上 React 進行渲染的每一幀,所有 DOM 元素上的 CSS 規則都會重新計算。這會非常非常的慢
更壞的是,這個問題好像是無解的(針對運行時 CSS-in-JS)。運行時 CSS-in-JS 庫會在組件渲染的時候插入新的樣式規則,這對性能來說是一個很大的損耗。
- 使用 CSS-in-JS ,會有更大的概率導致項目報錯,特別是在 SSR 或者組件庫這樣的項目中。在 Emotion 的 GitHub 倉庫,我們可以看到很多向如下的 issue
我在我的 SSR 項目中使用了 Emotion,但是它報錯了,因為…….
在這些海量的 issue 中,我們可以找到一些共同特征:
- 多個 Emotion 實例被同時加載。如果多個被同時加載的實例是相同的Emotion 版本,這將會引起很多問題(比如說)
- 組件庫通常無法讓您完全控制插入樣式的順序(比如說)
- Emotion 的 SSR 能力支持對于 React 17 和 18 兩個版本是不相同的。我們需要做一些兼容性的工作來兼容 React 18 的 stream SSR(比如說)
相信我,上述的這些問題僅僅是冰山一角。
性能檢測
在這一點上,很明顯,CSS-in-JS 有著顯著的優點和缺點。為了明白我們為什么正在移除這項技術,我們需要更加真實的 CSS-in-JS 性能場景。這里我們會著重關注 Emotion 對于性能的影響。Emotion 有很多種使用方式,每種方式都有其各自的性能表現特點。
內部序列化渲染 vs. 外部序列化渲染
樣式序列化指的是 Emotion 將你的 CSS 字符串或者樣式對象轉化成可以插入文檔的純 CSS 字符串。Emotion 同時也會在序列化的過程中根據生成的存 CSS 字符串計算出相應的哈希值——這個哈希值就是你可以看到的動態生成的類名,比如 css-an61r6
在測試前,我預感到這個樣式序列化是在 React 組件渲染周期里面完成還是外面完成,將對 Emotion 的性能表現起到比較大的影響。
在渲染周期內完成的代碼如下
function MyComponent() {
return (
<div
css={{
backgroundColor: 'blue',
width: 100,
height: 100,
}}
/>
);
}
每次 MyComponent 渲染,樣式對象都會被序列化一次。如果 MyComponent 渲染的比較頻繁,重復的序列化將有很大的性能開銷
一個性能更好的方案是把樣式移到組件的外面,所以序列化過程只會在組件模塊被載入的時候發生,而不是每次都要執行一遍。你可以使用 @emotion/react 的 css 方法
const myCss = css({
backgroundColor: 'blue',
width: 100,
height: 100,
});
function MyComponent() {
return <div css={myCss} />;
}
當然,這樣使得你無法在樣式種獲得組件的 props,所以你會錯失 CSS-in-JS 的一個主要的賣點。
測試「成員檢索」功能
我們接下來將使用在一個頁面上實現「成員檢索」的能力,就是使用一個列表展示團隊成員的一個簡單的功能。列表上幾乎所有的樣式都是通過 Emotion 來實現,特別是使用 css prop
(為了保障信息安全,我截圖了網絡上一張類似的圖片,功能幾乎一樣)
測試如下:
- 「成員檢索」會在頁面上顯示 20 個用戶
- 去除 react.memo 對列表的包裹
- 每秒都強制渲染 組件,記錄前 10 次渲染的時間
- 關閉 React Strict 模式 (不然會觸發重復渲染,時間可能是現在的 2 倍)
我使用 React DevTools 進行記錄,得到前 10 次的平均渲染時間為 54.3 毫秒。
以往的經驗告訴我,一個 React 組件最好的渲染時間大概是 16 毫秒(每秒 60 幀計算)。 < BrowseMembers > 組件的渲染時間是經驗值的 3 倍左右,所以它是一個比較「重」的組件。
如果我去除 Emotion,而使用 Sass Modules 來實現頁面的樣式,平均的渲染時間大概是在 27.7 毫秒。這比原來使用 Emotion 少了將近 48% !!!
這就是為什么我們開始放棄使用 CSS-in-JS 的原因:運行時的性能消耗實在太嚴重了!!!
我們的新樣式方案
在我們下定決心要移除 CSS-in-JS 之后,剩下的問題就是:我們應該什么方案來代替。我們既想要有裸寫 CSS 這樣的性能,又想要盡可能保留 CSS-in-JS 的優點。這里再次簡單梳理一下 CSS-in-JS 的優點(忘記的同學可以翻回上面再看看):
- locally-scoped styles
- colocated
- 在 CSS 中使用 JS 變量
如果你有認真看這篇文章,那你應該還記得我在上文中提到,CSS Modules 其實也是可以提供 locally-scoped styles 和 colocated 這樣類似的能力的。并且 CSS Modules 編譯成原生 CSS 文件之后,沒有運行時的性能開銷。
在我看來,CSS Modules 的缺點在于,他們依然是原生的 CSS —— 原生 CSS 缺少提升開發體驗以及減少冗余代碼的能力。但是,如果當原生CSS 具備 nested selectors 的能力之后,情況將會改善很多。
幸好,市面上已經有了一個很簡單的方案來解決這個問題—— Sass Modules ( 使用 Sass 來寫 CSS Modules ) 。你既可以享受 CSS Modules 的 locally-scoped styles 能力,又可以享受 Sass 強大的編譯時功能(去除運行時性能開銷)。這就是我們會使用 Sass Modules 的一個重要原因。
注意:使用 Sass Modules ,你將無法享受到 CSS-in-JS 的第 3 個優點(在 CSS 中使用 JS 變量)。但是你可以使用 :export 塊將 Sass 代碼的常量導出到 JS 代碼中。這個用起來不是特別方便,但是會使你的代碼更加清晰。
Utility Classes
比較擔心我們團隊從 Emotion 切換到 Sass Modules 之后,會在寫一些極度常用的樣式的時候不是很方便,比如 display: flex 。之前我們是這樣寫的
<FlexH alignItems="center">...</FlexH>
如果改用 Sass Modules 之后,我們需要創建一個 .module.scss 文件,然后寫一個 display: flex 和 align-item: center 。這不是世界末日,但肯定是不夠方便的。
為了提升開發體驗,我們決定引入一個 Utility Classes。如果你對 Utility Classes 還不是很熟悉,用一句話概括就是,“他們是一些只包含一個 CSS 屬性的 CSS 類”。通常情況下,你會在你的元素上使用多個這樣的類,通過組合的方式來修改元素的樣式。對于上面的這個例子,你可能需要這樣寫:
<div className="d-flex align-items-center">...</div>
Bootstrap 和 Tailwind 是目前最流行的提供 Utility Classes 的解決方案。這些庫在設計方案上做了非常多的努力,這使得我們可以放心的使用他們,而不是自己重新搭建一個。因為我使用 Bootstrap 已經很多年了,所以我們選擇了 Bootstrap。我們使用 Bootstrap 作為我們項目的預設樣式方案。
我們已經在新組件上使用 Sass Modules 和 Utility Classes 好幾個星期了。我們覺得都不錯。它的開發體驗跟 Emotion 差不多,但是運行時的性能更加的好。
我們也使用 typed-scss-modules 來為 Sass Modules 生成 TypeScript 的類型文件。也許這樣做最大的好處就是允許我們定一個幫助函數 utils() ,這樣我們可以像使用 classnames 去操作樣式。
一些關于 構建時CSS-in-JS 方案
本文主要關注的是 運行時 CSS-in- JS 方案,比如 Emotion 和 styled-components 。最近,我們也關注到了一些將樣式轉換是純 CSS 的構建時CSS-in-JS 方案。包括
- Compiled
- Vanilla Extract
- Linaria
這些庫的目標是為了提供類似于運行時 CSS-in-JS 的能力,但是沒有性能損耗。
目前我還沒有在真實項目中使用構建時 CSS-in-JS 方案。但我想這些方案對比 Sass Modules 大概會有以下的缺點:
- 依然會在組件 mount 的時候完成樣式的第一次插入,這還是會使得瀏覽器重新計算每個 DOM 節點的樣式
- 動態樣式無法被抽取出來,所以會使用 CSS 變量加上行內樣式的方法來替代。過多的行內樣式依然會影響性能
- 這些庫依然會插入一些特定的組件到項目的 React 樹中,依然會導致 React DevTools 的可讀性變得比較差
結論
感謝你閱讀到這里~任何事情都是,有它好的一面也有它不好的一面。最終,作為開發人員,你必須評估這些優缺點,然后就該技術是否適合你的項目,然后做出決定。而對于目前我所在的團隊來說,Emotion 帶來的運行時性能消耗的影響已經大于它帶來的開發體驗的好處。而我們目前所使用的 Sass Modules 加上 Utility Classes 方案,在一定程度上也彌補了開發體驗的問題。






