级联如何选出胜出者:重要性、具体性与来源顺序
了解浏览器如何处理相互冲突的CSS声明,如何将特异性视为四部分对比来理解,以及为何悬停规则和!important常常会让人感到意外。
当多个 CSS 规则针对同一个元素并设置相同的属性时,浏览器无法同时应用所有规则。它需要一种确定性的方法来选择其中一条声明,而这一过程正是导致诸多“为什么我的样式被忽略?”类型故障的根源。读完本指南后,你将能够计算选择器的特异性、预测哪条声明会生效,以及调试诸如 :hover 规则始终无法触发的棘手问题,而无需使用 !important。
其在渲染流程中的位置
从宏观层面来看,浏览器通过一系列阶段将标记和样式转换为像素。HTML 被解析为 DOM,CSS 被解析为 CSSOM,两者结合形成渲染树,随后通过布局和绘制步骤生成最终可见的内容:
HTML
↓
DOM
↓
CSS
↓
CSSOM
↓
Render Tree
↓
Layout
↓
Paint
↓
Pixels
CSS 规则中隐藏着三个相关问题。首先,当多个声明相互冲突时,哪一个会胜出?其次,一旦确定了胜出的声明,其值最终会变为什么?第三,如果某个元素完全没有某属性的赋值,会发生什么?答案分别是级联规则(以特异性为核心)、值处理机制以及继承机制。这些内容通常被当作相互独立的主题来讲解,但在浏览器内部其实都是完成同一任务的连续步骤。本指南主要探讨第一个问题。如需了解样式解析完成后会发生什么,可参阅浏览器如何绘制页面以及 React 的作用。
规则、声明与冲突的数值
首先了解一下相关术语。CSS规则由选择器和后面的声明块组成:
.button {
background-color: blue;
}
在那个规则中,.button是选择器,background-color: blue;是声明内容,background-color是属性名,而blue则是属性值。您所输入的该值被称为声明值。
一个实际的样式表通常会为同一元素上的同一个属性包含多个声明。某一条规则可能会针对所有按钮:
button {
background-color: red;
}
而其他规则,可能位于不同的文件中,则会针对所有按钮,或者通过id来指定某个特定的按钮:
button {
background-color: blue;
}
#submit {
background-color: green;
}
如果有一个 <button id="submit"> 元素同时符合这三种规则,它应该使用哪种背景颜色?解决这个问题的就是层叠规则。它会通过考虑每条规则的优先级、选择器的具体性以及规则出现的顺序来消除冲突。在确定了优先级之后,通常是由具体性决定最终结果。
规范中的完整层叠规则还会考虑样式表的来源(浏览器默认样式、用户自定义样式、开发者自定义样式),而在现代 CSS 中,还会考虑使用 @layer 声明的层叠层级。对于没有层级的普通开发者样式表,需要考虑的三个因素就是优先级、具体性和来源顺序。
具体性衡量什么
具体性是指浏览器用来衡量选择器定位元素精确程度的指标。并非所有的选择器都具有相同的权重。以类型选择器为例:
p {
color: red;
}
类选择器:
.text {
color: blue;
}
以及 id 选择器:
#title {
color: green;
}
如果这三种选择器都指向同一个元素,浏览器会按照固定的选择器类型层级顺序对它们进行排序,从最强到最弱:
Inline styles
↓
IDs
↓
Classes / pseudo-classes / attributes
↓
Elements / pseudo-elements
当多个具有同等重要性的声明并存时,具体性更高的选择器会胜出。在此情况下 id 规则会获胜,对应的文本将会显示为绿色。
关于最上面那一行的重要说明:通过style属性设置的行内样式并非选择器,当前规范将其视为独立步骤,其优先级高于任何基于选择器的作者声明。虽然通常会将它们视为特异性层级中的最高“列”,但实际上也能得到相同结果,因此本指南的其余部分仍采用这种简便模型。
将特异性视为四列
最常见的误解是认为特异性是一个可以相加的单一分数。实际上,它更应被理解为四个数值的元组,每个类别对应一个数值:
Inline | IDs | Classes | Elements
对于给定的选择器,需要统计各类别包含的元素数量。单个类选择器是最简单的情况:
.button {
background: blue;
}
它不包含行内样式,也没有id,仅有一个类且没有元素:
Inline styles → 0
IDs → 0
Classes → 1
Elements → 0
可以将其简洁地表示为:
0, 0, 1, 0
现在来看一个更复杂的选择器:
nav#main .button div {
background: green;
}
它包含一个 id(#main)、一个类(.button)以及两个类型选择器(nav 和 div),因此它的特异性为:
0, 1, 1, 2
要比较两个选择器时,浏览器会从左到右读取这些组合,从最重要的列开始。它们首次出现差异的那一列决定了比较结果,其右侧的列则不再起作用。这就是为什么一个 id 的权重高于任意数量的类,而一个类的权重又高于任意数量的类型选择器:因为各列之间没有数值传递,所以十个类永远无法“等同于”一个 id。你不需要记住算术运算,只需牢记比较顺序即可。
分析一个实际的冲突案例
考虑一个同时具有 class 和 id 的按钮。请注意,这是普通的 HTML 标记,因此使用的是 class 而非 JSX 中的 className:
<button class="button" id="submit">
Don't Click
</button>
现在假设样式表中包含一条 class 规则:
.button {
background: blue;
}
此外还有其他几条规则,包括类型选择器、深层后代选择器以及带有悬停状态的 id 加 class 规则:
button {
background: purple;
}
nav#main .button div {
background: green;
}
#submit.button:hover {
background: yellow;
}
所有这些规则都声明了 background 属性,因此会相互竞争。浏览器不会简单地选择最后出现的规则;它会首先比较规则的特异性。
在那个代码块中有一个容易被忽略的细节:nav#main .button div实际上指向的是类名为button的元素内部的div,而非按钮本身。由于选择器的作用对象是其最右侧的部分,因此无论该规则的精确度如何,它都永远不会匹配到我们的<button>元素。检查规则是否能够匹配始终是调试的第一步。
对于那些确实能匹配的规则,可以将类选择器:
.button
与纯类型选择器进行比较:
button
前者包含一个类,后者仅包含一个元素。因此:
.button
其优先级更高:
button
所以按钮是蓝色而非紫色,尽管紫色规则的出现顺序更靠后。
再加入id元素:
#submit.button
id属性使得该选择器所处的列位置高于仅由类和类型构成的选择器。从左到右比较时,id属性会立即决定胜负,因此一个id就能胜过由多个类构成的选择器。
为何正确的:hover规则却不起作用
这种情况在调试时会造成很多困惑。首先为按钮设定一个基础规则:
#submit.button {
background: red;
}
以及为同一元素设定的:hover规则:
#submit.button:hover {
background: yellow;
}
该:hover规则包含一个id、一个类和一个伪类,因此其特异性高于基础规则,这样悬停时按钮就会如预期般变为黄色。
现在假设代码库中的另一部分通过更长的选择器来为同一个按钮设置样式:
nav#main div#container #submit.button {
background: red;
}
而:hover规则保持不变:
#submit.button:hover {
background: yellow;
}
该:hover规则仍然包含一个伪类:
:hover
伪类确实会计入“类别”一栏,但只会增加一个类别级别的分数。由于长选择器包含三个ID,而:hover规则仅有一个ID,因此在比较类别之前,长选择器已在“ID”一栏中胜出。结果就是:该::hover规则在语法上完全正确,也能匹配到相应元素,但屏幕上却没有任何变化。
由此可知,当交互状态出现异常时,伪类很少是罪魁祸首。真正的问题通常是其他某个声明的特异性更高。浏览器开发者工具可以帮我们看清这一点:样式面板会列出所有匹配的规则,并将落败的声明用斜线标出,从而明确指出是哪个选择器胜过了你的规则。
当特异性相同时按出现顺序决定胜负
有时两个选择器的特异性完全相同。比如有两个使用相同类别选择器的规则:
.button {
background: red;
}
.button {
background: blue;
}
它们的具体性和重要性都相同,因此前两个层级都无法做出决定。此时浏览器会退而采用声明顺序:出现较晚的声明获胜。按照这样的顺序:
.button {
background: red;
}
.button {
background: blue;
}
按钮最终会呈现蓝色。
整个决策过程可以看作是一系列用于解决平局的情况:
Importance
↓
Specificity
↓
Source Order
只有当上一层级无法确定胜出者时,才会考虑下一层级。首先判断重要性,如果仍平局则看具体性,再平局则由最后出现的声明获胜。
使用!important的代价
几乎每位开发者都至少使用过这个应急方法:
color: red !important;
添加 !important 可以提高声明的优先级。由于优先级会在特异性之前被检查,带有此标记的声明能够胜过特异性高得多的其他声明。例如:
.button {
background: purple !important;
}
在包含大量 id 的长选择器上,带有 !important 的声明会比普通声明更具优势。当两个 !important 声明相互竞争时,浏览器会先比较它们的特异性,然后再看出现顺序。
正是这种强大功能带来了风险。常见的调试困境如下所示:
"My style isn't working."
↓
"Let's increase the specificity."
↓
"Still not working."
↓
"Let's add !important."
↓
"It works!"
样式最终显示出来了,但潜在的冲突并未消失;它只是被转嫁给了下一个需要覆盖该属性的人,而这个人现在又得与自己设置的!important规则相冲突。这类情况越多,样式表就越难以理解。应将!important视为最后手段,若突然需要使用它,则说明该CSS很可能需要重构。
在动手之前:
!important
先提出一个更有意义的问题:为什么我的声明没有生效?然后按顺序检查各个层级:
- 是否有更重要的冲突声明?
- 是否有更具体的冲突选择器?
- 冲突声明是否出现在源代码的更后面?
确实存在合理的应用场景,比如那些旨在始终生效的工具类,或是用于覆盖你无法修改的第三方组件注入的内联样式的类,但这些使用应当是经过深思熟虑的,而非本能反应。
编写能自然生效的选择器
这里还涉及到更广泛的可维护性问题。当某个样式没有生效时,人们往往会不断延长选择器长度直到其生效:
body div section nav ul li a.button {
color: red;
}
这种方法虽然可行,但每增加一个部分都会提高未来覆盖该样式的难度,还会使样式与特定的DOM结构绑定在一起,进而让样式表更难理解。与其想着不惜一切代价让选择器生效,不如思考如何组织CSS结构,使得预期的样式声明能够自动生效。将大多数选择器限制在单个类上,避免使用id进行样式设置,并把更具体的覆盖规则放在它们要修改的规则附近,这些方法都有助于提升可维护性。
在有许多人负责编写 CSS 的大型代码库中,这一点尤为重要。特定性的存在是为了让结果可预测,而非引发选择器之间的军备竞赛。
在第三方样式表中使用加载顺序
当将自定义样式与重置样式表或第三方样式表结合使用时,加载顺序便成为一种实用工具。这类文件通常会为常见元素设置样式,而你希望自己的规则能够覆盖这些默认样式。在加载它们的之后再加载自己的样式表,就能轻松实现这一目标:
<link rel="stylesheet" href="reset.css">
<link rel="stylesheet" href="style.css">
当你的选择器与库中的选择器具有相同的特定性时,后加载的文件会生效,因此将 style.css 放在 reset.css 之后,就能让你的样式规则生效,而无需增加选择器的复杂度。
依赖代码顺序也有其代价。如果有人后来重新排序了<link>标签或打包工具入口处的导入项,样式就可能会悄然改变。在可能的情况下,应选择那些能明确确定最终生效规则的规则,并将代码顺序作为其原本设计的最终决胜因素。在目标浏览器支持的情况下,使用层叠机制是一种更明确的方式来表达“库优先,我们的样式在后”的理念。
确定最终值:层叠机制
至此,第一个问题已经得到解答。浏览器会收集该属性的所有声明,先考虑重要性,再考虑规则的具体性,最后考虑代码顺序,从而选定一个值。这个被选中的值就被称为层叠值。
不过工作还远未结束。假设最终被选中的声明是:
width: 66%;
这样的百分比无法直接绘制;浏览器仍需对其进行处理,通过与包含它的块进行对比来确定实际长度。这种数值处理,再加上对那些根本没有声明的属性的继承机制,构成了将CSS转换为像素的下一阶段。
关键要点
- 级联规则按照固定顺序解决冲突:优先级、特异性,最后是来源顺序。
- 特异性是通过四列对比来确定的(内联样式、ID、类/伪类/属性、元素/伪元素),从左到右读取;较低列的数值不会影响较高列的判定结果。
- 在比较规则的特异性之前,需先确认该规则确实与目标元素匹配;选择器中最右侧的部分就是其作用的目标。
:hover或其他伪类规则仅能提供类级权重,可能会被更具体的基础规则覆盖。!important是通过改变重要性来取胜,而非解决冲突;应谨慎且适度地使用它。