CSS @layer:cascade layers 如何終結 specificity 戰爭
CSS @layer:cascade layers 如何終結 specificity 戰爭
假設你有一個來自 design system 元件庫的卡片組件:.card .card__header .card__title { font-size: 18px; }——選擇器裡有三個 class,因為組件作者就是這樣把它巢狀寫出來的。你想用一個工具 class .text-lg { font-size: 20px; } 蓋過它,而且這個 class 在 HTML 裡是寫在卡片的 class 之後才加上去的。結果沒有作用。標題頑固地停留在 18px,儘管 .text-lg 在程式碼裡出現得比較後面,直覺上「後加的那個應該要贏」。這不是瀏覽器的錯——選擇器裡三個 class 就是會贏過一個 class,跟它們在檔案裡的先後順序完全無關。這正是 specificity 自 1996 年以來的運作方式,而且它本來就該這樣運作。
經典的權宜之計,是把自己的規則的 specificity 拉高——寫成 .card .text-lg,如果這樣還不夠,就上 !important。這兩種做法在單一個案上都行得通,但在系統層面上會逐漸失控:每次團隊裡有人加入一條 specificity 更高的規則(或者多一個 !important),整份樣式表其餘的部分就會變得無法預測。CSS Cascade Layers 要解決的,正是這個問題——不是美觀上的問題,而是架構上的問題。
一條全新的判定優先次序軸線,不是另一種拉高優先度的方法
要理解的關鍵一點:@layer 不是另一種拉高規則優先度的工具,不像 !important,也不像在選擇器裡多加一個 class。它是 cascade 演算法裡獨立的一個階段,判定時機在 specificity 之前,而不是跟它並排。瀏覽器判定兩條 CSS 規則之間衝突的完整順序(先簡化,略過 origin 不談)是這樣的:
- 層(
@layer)——較後宣告的層裡的規則,會贏過較早宣告的層裡的規則,不管裡面選擇器的 specificity 是多少。 - Specificity——只有當兩條規則屬於同一層(或者兩者都完全不屬於任何層)時,才輪到它來判定。
- 原始碼順序——當前兩項條件都打成平手時,最後由它裁決。
這代表在較後宣告的層裡,單一個 .text-lg class 可以打贏較早那層裡一個有三個 class 的選擇器——因為這場對決根本不會走到比較 specificity 的階段。層早在那之前就已經把勝負決定了。
css
/* 層的順序只需要在最上面宣告一次——不需要跟它們的內容實際上出現在檔案裡的順序一致 */@layer reset, base, components, utilities;@layer components {.card .card__header .card__title {font-size: 18px;}}@layer utilities {.text-lg {font-size: 20px;}}
檔案最上面這行空的 @layer reset, base, components, utilities; 宣告,並不是走個形式——它是在任何一層真正拿到內容之前,就明確定下優先次序。這樣一來,層的順序就跟 import 或檔案的順序完全脫鉤——components 在程式碼裡實際出現的位置可以在 utilities 之前,但 utilities 依然會贏,因為一開始就已經宣告了這個順序。
實際範例:reset、組件與工具類,彼此永不衝突
在實踐上,這種模式——reset 排得比 components 低,components 又排得比工具類低——正正就是 Tailwind 從 3.1 版開始的層系統的運作方式:工具類永遠都要贏過 component 的規則,不管那個 component 的選擇器有多少個 class,因為這正是工具類存在的意義。
css
@layer reset, base, components, utilities;@layer reset {* {margin: 0;padding: 0;box-sizing: border-box;}}@layer base {body {font-family: system-ui, sans-serif;color: #1a1a1a;}}@layer components {.card {border: 1px solid #e0e0e0;border-radius: 12px;padding: 16px;}.card .card__header .card__title {font-size: 18px;font-weight: 600;}}@layer utilities {.text-lg {font-size: 20px !important;}.mt-4 {margin-top: 16px;}}
留意一下,utilities 層裡 .text-lg 的 !important 其實是多餘的——層本身已經確保了優先度,不需要動用 CSS 裡最重的那件武器。這正好說明了為甚麼 cascade layers 能實際減少程式碼裡 !important 的數量:你不再需要用它來繞過 specificity,因為你手上已經有一個為這個目的量身打造的工具。
同一個機制也解決了另一個同樣常見的問題——一個你無法控制其選擇器的外部 CSS library,你希望它的優先度永遠低於你自己的樣式,而且不需要拉高自己規則的 specificity:
css
@import url("some-ui-library.css") layer(vendor);@layer vendor, base, components, utilities;
整個 library 都會落入 vendor 層,放在順序的最前面——所以你的 base、components 和 utilities 永遠都能蓋過它,不管那個 library 的選擇器寫得有多兇猛。
違反直覺的陷阱:沒有層的樣式,會贏過所有層
這是整套機制裡最常被忽略、卻也後果最嚴重的細節:不屬於任何層的規則,會贏過任何層裡的規則——這是針對一般的、沒有 !important 的宣告而言。它不是「因為身處末端一個看不見的層而輸掉」——它是無條件、永遠都贏過任何一條有層的規則,即使那條規則所在的層被宣告為最高優先度也一樣。
css
@layer utilities {.text-lg {font-size: 20px;}}/* 這條規則雖然不屬於任何層,看起來像是隨手加的、一次性的override——但它會贏過上面的 .text-lg,不管它實際寫在檔案的哪個位置 */.title {font-size: 16px;}
這種行為是刻意設計的——正因如此,在一個既有的大型專案裡逐步導入 cascade layers 才會是安全的:所有還沒被歸進任何層的「舊」CSS,預設就保有最高優先度,所以不會突然輸給新加進去的層。但同一個行為,也正是那些只局部導入 @layer 的團隊,最容易搞混的 bug 來源:一條不小心留在層外面的規則——打錯字、漏掉的 import、由第三方 library 注入的 <style> 區塊——會蓋過整個精心設計的優先度系統,而且完全不會有任何警告。實際上的結論是:如果你決定採用 cascade layers,就要徹底地把所有東西都放進某一層,包括 reset 和基礎樣式在內——不要讓任何一條規則留在「外面」,因為到頭來贏得每一場衝突的,會是它,而不是 utilities 層。
第二個陷阱:!important 會反轉層的順序
如果你在層裡面用了 !important,層與層之間的優先次序就會整個反轉。對一般的宣告來說,較後宣告的層會贏。對有 !important 的宣告來說——贏的卻是較早宣告的層。
css
@layer reset, components;@layer reset {.card { padding: 0 !important; }}@layer components {.card { padding: 16px !important; }}/* 贏的是 reset(0px),因為在有 !important 的情況下,較早宣告的層擁有優先權——這剛好是沒有 !important 時那條規則的完全反轉 */
這不是規格隨意的任性設計——而是一條早已存在於 origin 層面的規則的一致延伸(用戶樣式表裡的 !important 向來都能蓋過網站作者的 !important,跟一般宣告的情況剛好相反)。實際的啟示是:層系統裡的 !important,足以讓一個對 @layer 一般順序理解得很透徹的人也感到意外——這也是另一個好理由,讓你把 !important 當成一件要有意識、而且很少動用的工具,而不是預設用來贏得對決的手段。
常見陷阱與最佳實踐
- 如果想讓系統可預測,就不要有任何規則留在層外面。 正如上面所示,就算只有一條沒有
@layer的規則,對一般宣告來說也能蓋過整套層系統——一致性比隨手「加一條小規則」的方便重要得多。 - Specificity 在同一層裡面依然有效。
@layer消除的是層與層之間的 specificity 戰爭,但同一層裡的兩條規則,仍然照舊按 specificity 判定勝負——好好命名 class、避免選擇器過度巢狀,依然值得堅持。 - 在檔案最上面用一句空的
@layer 名稱1, 名稱2, ...;明確宣告層的順序。 這樣就不用再依賴檔案 import 的順序——優先次序只需要在一個地方、清楚地定義一次,不管之後各個 CSS 檔案實際的載入順序是怎樣。 - 瀏覽器支援已經不再是需要謹慎的理由。 Cascade Layers 早在 2022 年就已經達到 Baseline(廣泛可用)狀態——Chrome 99、Firefox 97、Safari 15.4——所以在任何新專案裡都可以直接使用,不需要
@supports。
總結
@layer 不是在跟 specificity 競爭——它運作在 cascade 演算法完全不同、而且更早的一個階段,因此讓一個工具 class 可以有意識、可預測地打贏一個有三層巢狀 class 的選擇器,不需要拉高優先度,也不需要 !important。這是一個真正的架構工具,能把 reset、基礎樣式、組件和工具類分成幾層,優先次序明確宣告——但它有兩條規則違反直覺到值得單獨記住:不屬於任何層的樣式會贏過所有層,而層裡面的 !important 會反轉層的優先次序。忽略這兩件事的任何一件,都不會在 console 裡跳出錯誤——只會得到一份運作方式,跟檔案裡看似合理的層順序完全對不上的 CSS。