CSS和佈局

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 不談)是這樣的:

  1. 層(@layer——較後宣告的層裡的規則,會贏過較早宣告的層裡的規則,不管裡面選擇器的 specificity 是多少。
  2. Specificity——只有當兩條規則屬於同一層(或者兩者都完全不屬於任何層)時,才輪到它來判定。
  3. 原始碼順序——當前兩項條件都打成平手時,最後由它裁決。

這代表在較後宣告的層裡,單一個 .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 層,放在順序的最前面——所以你的 basecomponentsutilities 永遠都能蓋過它,不管那個 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。