為什麼我仍喜歡 CSS module 勝過 Tailwind CSS
Tailwind CSS 是現在最流行的 atomic css 的 css 框架,全部使用 utility 的 class 取代傳統命名的 class,使得開發上不需要在 html 命名與 css 檔案編寫樣式之間跳來跳去,可以直接在 html 快速編寫樣式,提升開發體驗。另外由於 css 都被拆成無數個單獨的 utility css 被覆用,不只可以減少 css 的檔案體積,過往 css 彼此覆蓋干擾的問題也被解決了。的確 Tailwind CSS 這種 atomic css 框架對開發者的確會有許多質疑,但大部分上手以後都覺得回不去了,畢竟直接在 html 快速編寫樣式效率高速度快真的太香了。
Tailwind 的缺點 - 超展開的 pseudo class #
以往 SCSS 最讓我喜歡的一個特色就是 nesting 的語法,讓我可以輕鬆收攏需要重複打的 selector 變成以下的巢狀格式,隨著時代的演進,CSS nesting 的語法已經進入標準也已被主流瀏覽器給支援。
@media screen and (min-width: 1024px) {
.module {
height: 44px;
display: flex;
&:hover {
background: #f26591;
content: "";
display: block;
flex: 1 1 auto;
margin: 0 12px;
border: 2px sloid #000;
}
}
}
然而 Tailwind 並沒有類似的功能,雖然大部分時候 Tailwind 不需要下 selector,比較鼓勵你直接在該 element 下寫 class,但如果遇到像是 :hover
這種 pseudo class 或是 pseudo element 再配上 RWD,在編寫樣式上就會需要不斷的在 tailwind 的 utility class 前面加 prefix,寫起來實在有點令人煩躁。
<div
class="lg:flex lg:h-[44px] lg:hover:bg-[#f26591] lg:hover:mx-[12px] lg:hover:block lg:hover:border-1 lg:hover:border-solid lg:hover:border-[#fff] lg:hover:flex-auto"
></div>
另外長字串 class 維護上不管是閱讀、互相比較、查找,都會比一目了然的 nesting CSS 辛苦不少。
Tailwind 的缺點 - 難以被覆蓋 #
假設我們寫好了一個 Component 但這個 Component 需要因為擺放的位置不一樣調整寬度,因此我們需要幫這個 Component 開一個 props 去覆蓋掉原本的 Tailwind CSS,因此我們可能會將這個 component 設計如下:
const Cmp = ({ overrideClass = "" }) => {
return <div className={"w-12 h-8" + overideClass}></div>;
};
但 CSS 的覆蓋順序並非是直覺上 DOM class
attribute 字串中後面的 class 就可以蓋掉前面 class,實際上是如果兩個 class priority 是相同的,那在 CSS 檔案中位於比較後面的 class 會蓋掉前面的 class,也就是說 CSS 檔案中定義的順序為:
.w-8 {
width: 8px;
}
.w-12 {
width: 12px;
}
那麼即便是你在 props 設定 <Cmp overrideClass="w-8" />
,Cmp 呈現的寬度依舊是 12px。
雖然 atomic css 的設計天生就會有這種問題,好在並不是沒有 workaround,你可以選擇用 tailwind-merge 的 utility 透過字串查找把你想要 override 的 class 拔掉但就是需要些 runtime 成本,或是不保留原本的 class,要換就全換,或者是利用 CSS layer 去提高 overrideClass 的權重,雖然都不是怎麼漂亮的解法,但都是可以閃過這個問題。
StyleX 也許才是 atomic css 未來? #
StyleX 是一個 Meta 最近開源的 atomic css-in-js 方案,可以解決上面這兩個問題,首先利用 js 的 object 是巢狀的設計就可以避免 atomic css 一大串字串難以比對查找的問題。
另外利用 js object 可以用後面屬性蓋過前面的特性,可以實現比較直覺的 override component style:
import * as stylex from "@stylexjs/stylex";
const styles = stylex.create({
base: {
fontSize: 16,
lineHeight: 1.5,
color: "grey",
},
highlighted: {
color: "rebeccapurple",
},
});
const Cmp = ({ overrideClass }) => {
return <div {...stylex.props(styles.base, overrideClass)}></div>;
};
StyleX 的確在可讀性維護性與可覆蓋性都是目前 atomic CSS 做得最好的方案,但沒有辦法像是 Tailwind 一樣可以提供在 html 快速用字串編寫樣式的開發體驗。
StyleX 在更複雜的 pseudo class 跟 media query 跟純粹的 CSS 比起來真的比較囉唆,每個欄位都需要個別設定。
import * as stylex from '@stylexjs/stylex';
const styles = stylex.create({
module: {
height: '44px';
display: {
default: 'flex',
':hover': 'block'
},
background: {
default: null,
':hover': '#f26591'
},
flex: {
default: null,
':hover': '1 1 auto'
},
margin: {
default: null,
':hover': '0 12px'
},
border: {
default: null,
':hover': "2px sloid #000"
}
}
});
然而 StyleX 不支援 descendant 類似這種比較寬鬆的 selector,官方理由是因為容易影響到其他 Component 的樣式,但這就導致了這種隨著 parent state 變化後影響 child 的 selector 並不支援。
.module {
&:hover .child {
color: red;
}
}
但目前 hover 在 StyleX 的官方說法是比較推薦用 js 的做法對各種 device 的相容性比較好,css hover 根據不同的 device 會有不同的行為,詳見這個 issue,官方這個理由實在不太能說服我,但 Tailwind 發展出 group
的做法也沒有讓我覺得很漂亮就是了。
CSS module 依舊提供極佳的開發體驗 #
不得不說快十年的 CSS module 方案依舊提供極佳的開發體驗,概念簡單只是在原本的 class 命名加上後綴 hash 和前綴檔名避免 naming conflict 互相覆蓋的問題。
//SquareImage.module.css
.imgWrapper {
display: "block";
}
//output.css
.SquareImage__imgWrapper___1hd7r {
display: "block";
}
由於 class hash 是 compile phase 加上去的,與 styled component 比起來更不需要額外的 runtime 負擔,IDE 開發工具 lint autocomplete 成熟,只要會 CSS 就可以了也不需要額外的學習成本。唯一的小缺點就是 import css 順序會影響 css 檔案裡面 class 的排序,隨著開發的 Component 越多 class 的順序有的時候就會忽前忽後,造成覆蓋順序不如預期的問題。
但幸好 CSS 本身的設計就是一個容易覆蓋的語言,在原生 CSS 的幫助下,我們可以利用 CSS layer 或是 important 或是 selector 加權重的方式去解決這個問題。我自己喜歡用下面這招來增加 override class 的權重
.override {
&& {
background: red;
}
}
// = .overide.overide { background: red }
有意義的 class 命名讓很多事情輕鬆很多 #
真正讓我不把 atomic css 作為編寫樣式的優先選項是我仍喜歡 class 有意義的命名這件事,以往有意義的命名最麻煩的是 naming conflict,但 CSS module 保留了 class 的原本命名只加了 hash 後綴跟檔名作為前綴,讓查找 source code 跟避免 naming conflict 可以並存。class 有意義的命名其實在 debug 階段可以幫助我或是接手的同事可以快速的在 source code 定位是哪一個 Component 出了問題,舉例如果在 devtool 觀察到是這個 div 有 bug:
<div class="SquareImage__imgWrapper___1hd7r"></div>
就可以快速的定位是 SquareImage 這個檔案的 Component 需要修正。
此外在寫 WebdriverIO 或是 Playwright 等 e2e tesing 的 selector 也容易直觀許多,只要稍微使用 attribute selector 就可以繞過 hash 的問題。
const elem = await $("[class^=SquareImage__imgWrapper]");
await elem.click();
atomic css 在現代前端開發的必要性 #
過去的時代 html 跟 css 因為所謂的 seperation of concerns 分成兩個檔案,隨著功能的添加就會造成 css 跟 html 難以對應,很難確認刪改 class 會不會因為共用的關係造成哪裡破版,若只避免刪改 class 浮濫的添加新的 class 也會讓 css 變得越來越擁種跟擔心 naming conflict。atomic css 在過去時代的確提供了更小的 css 檔案以及解決了 css naming conflict 的問題,但在現在的前端開發把頁面拆成一個一個 Component 的概念讓我們不會再需要維護一個巨大的 CSS 檔案,css naming conflict 我們已經有其他的方案生成 class hash 去解決,例如 css-in-js 或是 CSS module,另外如果不是大型的 SPA app,在 MPA 的架構下 css 檔案也早以用頁面分類 bundle 過,頁面上的 css 檔案體積也不會隨著功能增加膨脹成很嚴重的效能問題。如果你的專案是一個大型 SPA 或者只是小型靜態頁面,那我覺得你可以試著嘗試採用 atomic css 的解決方案,不然我覺得大部分情況 CSS module 都是一個比較舒服的作法。
結語 - 公司叫我翻跟斗就翻跟斗 #
CSS 框架的挑選如果不是一個新 project,其實現在的 CSS solution 都蠻成熟的,沒有一定要選擇哪一個的必要,並沒有一定要使用哪個框架才是所有問題的萬靈丹,目前也沒有十全十美的框架,尺有所短寸有所長,框架大多有些限制也有辦法用原生 CSS 去繞過去,選擇同事大多數熟悉易上手就可以了,作為一個成熟的工程師,公司叫我用哪個我就用哪個,要翻跟斗就翻跟斗。