写 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 名称大小写不一致、忘了加 .#)导致选择器根本比对不到任何元素,而不是比对到了却被盖掉——这两种情况在开发者工具里的表现完全不同,先分清楚是”没比对到”还是”比对到但输了”,才能对症下药。