這是最後一個階段:撞牆與和解。
前三週都在自己的範圍裡練習:一個人、一個功能、一個想法。這週開始要面對真實的企業系統——別人寫好的元件庫、既有的設計規範、後端的認證機制、會一直推送訊息的連線。
第一面牆,來得比我預期的快。
我們系統用 PrimeReact 當元件庫,它內建了表格、對話框、下拉選單這些企業後台必備的元件。設計師希望版面跟間距改用 Tailwind 來寫,比較好維護。於是我照著官方文件把 Tailwind 4 裝進來:
/* app.css */
@import "tailwindcss";
@import "primereact/resources/themes/lara-light-blue/theme.css";
裝完,兩件事同時壞了。
檔案詳情頁有一塊「說明」,內容是使用者用編輯器寫的 HTML,我直接把它渲染出來:
<div dangerouslySetInnerHTML={{ __html: file.description }} />
裝了 Tailwind 之後,這塊說明裡的 <h2> 變得跟內文一樣大、<ul> 的圓點全部消失、段落之間的間距也沒了。整段說明變成一坨黏在一起的文字。
原因是 Tailwind 會先載入一份叫 Preflight 的基礎樣式。它把瀏覽器的預設樣式幾乎全部歸零:
*, ::before, ::after {
margin: 0;
padding: 0;
border: 0 solid;
box-sizing: border-box;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
ol, ul {
list-style: none;
}
這是刻意的。Tailwind 的設計理念是「所有樣式都由你用 class 明確寫出來」,所以它先把瀏覽器各自不同的預設值清空,大家從同一條起跑線開始。
對我自己寫的元件來說,這很合理。問題是那塊說明不是我寫的元件,是使用者的內容。它本來就靠瀏覽器的預設樣式活著,Preflight 一清,它就什麼都不剩了。
解法是替這種「別人的 HTML」單獨套一組排版樣式。Tailwind 官方有一個 Typography 外掛,專門處理這件事:
@plugin "@tailwindcss/typography";
<div className="prose" dangerouslySetInnerHTML={{ __html: file.description }} />
prose 會在這個區塊裡,把標題、清單、段落的樣式重新補回來。
這一面牆不難翻,真正讓我卡住的是第二面。
設計師希望「刪除」按鈕是紅色的。PrimeReact 的按鈕可以傳 className,我很自然地寫:
<Button label="刪除" className="bg-red-600" />
按鈕還是藍色的。
打開 DevTools 看,bg-red-600 確實有套上去,只是被劃了一條線——被 PrimeReact 的 .p-button { background: #3B82F6; } 蓋掉了。
這讓我很困惑。兩個都是單一 class,權重(specificity)一樣是 0,1,0。權重相同的時候,CSS 的規則是後寫的贏。而我的 class 是寫在元件上的,怎麼會輸?
我試著調整 import 的順序,把 PrimeReact 的主題移到 Tailwind 前面,沒有用。又試著把 Tailwind 移到最後面,還是沒有用。
這次,順序跟權重都不是重點。
Tailwind 4 的 class 都放在 CSS 原生的 Cascade Layers(串接層)裡。打開它編譯出來的 CSS,最上面長這樣:
@layer theme, base, components, utilities;
bg-red-600 住在 utilities 這一層,Preflight 住在 base 這一層。
而 PrimeReact 的主題 CSS,是用一般的方式 import 進來的,沒有放在任何一層裡。
Cascade Layers 有三條規則,決定誰贏:
一、層的順序比權重更優先。 後宣告的層贏過先宣告的層,不管權重多少。utilities 宣告在 base 後面,所以 bg-red-600 一定贏過 Preflight。
二、權重只在同一層裡比較。 不同層之間,權重完全不算數。
三、沒有放進任何層的樣式,贏過所有層。 這是最反直覺的一條。
第三條就是我的問題。PrimeReact 的主題沒有放進任何層,所以它贏過 Tailwind 的每一層——包括 utilities。我寫的每一個 Tailwind class,只要碰到 PrimeReact 也有設定的屬性,都會輸。
這也順便解釋了第一面牆為什麼只影響說明區塊:PrimeReact 的元件都有自己的樣式,而它們沒放進任何層,所以 Preflight 根本碰不到它們。被 Preflight 清空的,只有那些「沒有人負責樣式」的地方。
既然問題是 PrimeReact 沒有放進任何層,那就把它放進去,而且放在對的位置:
/* app.css */
@layer theme, base, primereact, components, utilities;
@import "tailwindcss";
@import "primereact/resources/themes/lara-light-blue/theme.css" layer(primereact);
第一行明確宣告所有層的順序。第三行用 layer(primereact) 把 PrimeReact 的主題放進一個叫 primereact 的層。
現在的優先順序變成:
theme → base → primereact → components → utilities
↑ 最優先
| 誰跟誰比 | 結果 | 原因 |
|---|---|---|
| Preflight vs PrimeReact | PrimeReact 贏 | primereact 在 base 後面 |
PrimeReact vs bg-red-600 |
bg-red-600 贏 |
utilities 在 primereact 後面 |
| Preflight vs 說明區塊 | 交給 prose |
那塊本來就不歸元件庫管 |
元件庫的樣式蓋過 Tailwind 的歸零,而我寫的 utility class 又可以蓋過元件庫。按鈕變紅了。
PrimeReact 自己也有提供把樣式放進 layer 的設定,細節依版本而不同;另一條路是改用它的「無樣式模式」,元件只提供行為,外觀全部交給 Tailwind 處理。那條路牽涉到設計規範要怎麼放,明天再談。
!important在 bg-red-600 後面加一個驚嘆號,寫成 bg-red-600!,Tailwind 4 會產生 !important,按鈕也會變紅。
我一開始就是這樣修的。但寫到第五個之後,我意識到問題:!important 本身也有一套層的規則,而且是反過來的——在 !important 的世界裡,先宣告的層反而會贏。混著用,很快就沒有人說得清楚到底誰會贏。
一行 @layer 宣告,換來整個專案的樣式有一個清楚的優先順序,比到處加驚嘆號好太多了。
公平地說,這不是 React 獨有的問題。如果在 Angular 專案裡同時裝 Tailwind 4 跟一套沒放進 layer 的元件庫,一樣會撞到這兩面牆。全域 CSS 在哪個框架都是全域的。
但我在 Angular 很少遇到這種事,原因是 Angular 的元件樣式預設有封裝(ViewEncapsulation.Emulated)。我寫在元件裡的 CSS,會被自動加上一個獨有的屬性選擇器,只作用在這個元件上:
/* 我寫的 */
.delete-button { background: red; }
/* Angular 編譯後 */
.delete-button[_ngcontent-abc-123] { background: red; }
元件的樣式不會外洩,外面的樣式也比較難誤傷到它。大部分時候,我根本不需要思考「全域的樣式之間誰贏誰輸」,因為我只在自己的元件範圍裡寫樣式。
React 沒有這層封裝。所有樣式預設都是全域的,所以「誰贏誰輸」的問題無所不在。Cascade Layers 是 CSS 本身給的解法,而我是在 React 裡才第一次真正需要它。
至於封裝的缺席還帶來了什麼,以及 PrimeReact 跟 Tailwind 各自有一套設計規範要怎麼共存,是明天的主題。
裝 Tailwind 撞到的兩面牆,其實是同一件事的兩面。
Preflight 把瀏覽器的預設樣式歸零,所以那些「沒有人負責樣式」的內容,例如使用者的 HTML,就變得光禿禿的。而 PrimeReact 的主題因為沒有放進任何層,反而贏過 Tailwind 的每一層,讓我寫的 utility class 全部失效。
這兩件事的根源,是 CSS 在權重之上,還有一層規則:Cascade Layers。層的順序比權重優先,而沒放進任何層的樣式,贏過所有層。
修法只有一行:宣告層的順序,把元件庫放進它該在的位置。
這是 Week 4 的第一道牆,也是第一次,在 React 裡碰到一個「Angular 幫我擋掉、而我根本不知道它存在」的問題。