寫 CSS 時最常見的困惑不是「這個屬性該填什麼值」,而是「為什麼我寫的規則沒有生效」。答案幾乎總是同一件事:瀏覽器比對了不只一條規則到同一個元素,而它選的不是你以為的那一條。要預測結果,需要知道選擇器怎麼比對元素、多條規則同時命中時怎麼決勝負。

1. 基本選擇器如何比對元素

1.1 Type、class、ID 與萬用選擇器

Type selector 用標籤名稱比對,例如 p 比對所有 <p> 元素。Class selector. 加上類別名稱比對,例如 .warning 比對 class="warning"class="alert warning" 的元素——class 屬性可以包含多個以空白分隔的類別,只要其中一個相符就算命中。ID selector# 加上 id 值比對,例如 #main-nav;一份文件裡同一個 id 理論上只會出現一次,所以 ID 選擇器通常用來比對單一元素。萬用選擇器 * 比對任何元素,常見於重置樣式(例如把所有元素的 box-sizing 統一設成 border-box)。

1.2 屬性選擇器

屬性選擇器直接比對元素的 HTML 屬性,不限於 classid[attr] 比對有該屬性的元素(不管值是什麼),[attr=value] 比對屬性值完全相等,[attr^=value][attr$=value][attr*=value] 分別比對值的開頭、結尾、任意位置包含指定子字串,[attr~=value] 比對以空白分隔的值列表中是否包含指定字詞。表單常用 input[type="email"]a[href^="https://"] 這類寫法,不需要額外加 class 就能鎖定特定元素。加上 i 旗標(例如 [lang="en" i])可以讓值的比對不分大小寫。

2. 組合器:描述元素之間的關係

後代組合器(空白)比對任意層級的後代:article p 比對 article 底下所有層級的 p,不論中間隔了幾層。子代組合器 > 只比對直接子元素:ul > li 只比對 ul 的直接 li 子元素,不會比對巢狀 ul 裡的 li相鄰兄弟組合器 + 比對緊接在後、同一層級的下一個元素:h2 + p 只比對緊跟在 h2 後面的那一個 p一般兄弟組合器 ~ 則比對同一層級、在指定元素之後出現的所有符合元素,不要求緊鄰:h2 ~ p 比對 h2 之後、同一父層底下的每一個 p。分清楚後代跟子代、相鄰跟一般兄弟,是避免選擇器比對範圍「太廣」或「太窄」最常見的關鍵。

3. 偽類與偽元素

3.1 常見偽類

偽類: 開頭,比對元素當下的狀態或在文件結構中的位置,而不是它本身固定的屬性。狀態類的例子包括 :hover:focus:checked:disabled;結構類的例子包括 :first-child:last-child:nth-child(2n)(比對第偶數個子元素)、:only-child:not(selector) 排除符合指定選擇器的元素。這些偽類讓樣式可以回應互動或結構,不需要額外用 JavaScript 切換 class。

3.2 偽元素與偽類的差異

偽元素:: 開頭(舊版寫法允許單冒號以相容早期瀏覽器),比對的不是文件裡真實存在的元素,而是元素的某個子部分,例如 ::before::after 會在元素內容前後插入生成內容(需要搭配 content 屬性才會顯示),::first-line::first-letter 比對文字內容的第一行或第一個字,::selection 比對使用者選取文字時的樣式。偽類選的是「已經存在、符合某個條件的元素」,偽元素選的是「元素的某一部分,這部分不一定對應真實的 DOM 節點」,這是兩者最本質的差異。

3.3 現代選擇器::is():where():has()

:is(selector-list):where(selector-list) 把一組選擇器合併成一個,例如 :is(h1, h2, h3) a 等同於 h1 a, h2 a, h3 a 但寫起來更精簡。兩者的差異在特異度::is() 的特異度等於它參數中最高的那一個,:where() 則永遠貢獻零特異度——這正是 :where() 存在的理由,讓作者可以寫出結構複雜的選擇器,同時不會意外把特異度墊高到難以覆蓋。:has(selector) 是關係型偽類,只要指定的相對選擇器在目前元素之內能比對到至少一個結果就算命中,等於是長期缺席的「父選擇器」能力,例如 li:has(> img) 比對「內含直接子元素 img 的 li」。自 2023 年起,主流瀏覽器都已支援 :has()

4. 選擇器列表與特異度

4.1 選擇器列表

逗號可以把多個選擇器合併成一份規則的比對條件,例如 h1, h2, h3 { margin-top: 1.5em; } 對三種標題套用同一組樣式,等同於分別寫三條規則。

4.2 特異度怎麼計算

當多條規則都比對到同一個元素、設定同一個屬性時,瀏覽器要決定用哪一條——這就是特異度。特異度是一個三元組 (a,b,c)(a,b,c)aa 是 ID 選擇器的數量,bb 是 class、屬性選擇器與偽類的數量,cc 是 type 選擇器與偽元素的數量;比較時先比 aaaa 相同再比 bbbb 也相同才比 cc,組合器本身(空白、>+~)不計入特異度。

#nav .menu li a:hover 為例:ID 有 1 個(#nav),class 加偽類有 2 個(.menu:hover),type 有 2 個(lia),特異度是 (1,2,2)(1,2,2)。拿它跟 .nav-menu a.active 比較:這條規則沒有 ID,class 有 2 個(.nav-menu.active),type 有 1 個(a),特異度是 (0,2,1)(0,2,1)。因為 a=1>0a=1>0,前者勝出,不管後者的 class 數量再多都無法逆轉,這正是「一個 ID 選擇器打贏一大串 class 選擇器」的原因。內聯樣式(HTML 的 style 屬性)的優先度高於任何選擇器;!important 則會整個蓋過特異度比較,但多條 !important 宣告之間仍然照特異度和後述的來源順序決勝負。

5. 層疊順序與繼承

5.1 層疊順序

同一個屬性被多條規則設定、特異度也相同時,決定生效的是層疊順序:先比對是否標了 !important!important 的宣告整體贏過沒標的),再比特異度,特異度也相同的話,後出現的規則(不論是在同一份樣式表的後面,還是引入順序在後的樣式表)覆蓋先出現的規則。完整的層疊規範還會考慮樣式來源(瀏覽器預設、使用者設定、作者樣式表)和較新的 @layer 層疊層機制,但日常開發裡「重要性 → 特異度 → 來源順序」這三步已經能解釋絕大多數情況。

5.2 繼承與 inheritinitialunset

不是所有屬性都會從父元素繼承下來。文字相關的屬性(colorfont-familyfont-sizeline-heighttext-align 等)預設會繼承,版面配置相關的屬性(marginpaddingborderwidthbackgrounddisplay 等)預設不會繼承,否則每個子元素都會意外套用到父元素的邊界或背景設定。inherit 關鍵字可以強制某個屬性繼承父元素的計算值,initial 把屬性重設回規範定義的初始值,unset 則依屬性本身預設會不會繼承而分別表現得像 inheritinitial

6. 除錯選擇器為什麼沒有生效

規則沒生效,通常不是語法錯誤,而是被另一條特異度更高、或層疊順序更後面的規則蓋掉了。瀏覽器開發者工具的「Styles」面板會列出所有比對到的規則,被覆蓋的宣告會顯示刪除線;切到「Computed」面板可以看到每個屬性最終生效的值,並能追蹤這個值來自哪一條規則。常見的踩坑包括:忘記 ID 選擇器的特異度遠高於一大串 class;濫用 !important 導致後續想覆蓋時只能用更多 !important,形成沒有終點的軍備競賽;框架用 JavaScript 動態寫入的內聯樣式,優先度高於任何樣式表規則;以及選擇器打錯字(class 名稱大小寫不一致、忘了加 .#)導致選擇器根本比對不到任何元素,而不是比對到了卻被蓋掉——這兩種情況在開發者工具裡的表現完全不同,先分清楚是「沒比對到」還是「比對到但輸了」,才能對症下藥。