CSS选择器详解与优先级

张玥 2026年9月20日 阅读时间 25分钟
CSS 选择器 优先级 层叠 最佳实践
CSS选择器详解与优先级

写 CSS 的日常,十次里有八次是在和选择器打交道。样式没生效、颜色被覆盖、改了这处坏了那处——绝大多数“玄学问题”的根源,最后都能追溯到两个地方:选择器没有匹配到你以为的元素,或者匹配到了但优先级不够。这两个问题看似简单,却几乎没有人能一次性说清::is() 和 :where() 的特异性到底差在哪?:nth-child(2n+1) 的公式怎么读?为什么 !important 有时候也救不回来?@layer 又是如何改变了层叠的规则?本篇 25 分钟长文,从选择器语法讲起,逐层拆解组合器、属性选择器、伪类与伪元素,再深入到特异性计算、层叠顺序与 @layer,最后落到性能、陷阱与可落地的最佳实践。读完它,你会对“为什么这条样式生效了”有完整的答案。

一、选择器是 CSS 唯一的“提问方式”

一条 CSS 规则集由两部分构成:选择器与声明块。选择器负责“发问”——它向文档树提问:“谁符合这个条件?”;声明块负责“回答”——把所有匹配到的元素改成指定的样子。

/* └──── 选择器 ────┘ └────── 声明块 ──────┐ */
.card > .title {
  color: #0f172a;
  font-weight: 700;
}

浏览器渲染页面时会经历“构建 DOM 树 → 匹配选择器 → 计算层叠 → 生成渲染树”这条链路。选择器匹配发生在样式计算阶段,它的正确性决定了后面所有环节的结果。理解选择器,本质上是在理解浏览器如何在几万个节点中,快速找到你描述的那一批。

三条必须牢记的基本事实

事实一 选择器只做“筛选”,不做“修改”。它不会改变 DOM,只决定声明块作用在哪些元素上。
事实二 浏览器从右向左匹配选择器。最右侧的“关键选择器”决定了匹配的起点与性能上限。
事实三 当多条规则命中同一个元素时,最终生效的由“层叠”决定,而优先级只是层叠中的一个环节。

从右向左:一个被严重低估的细节

假设写了这样一条规则:

article .content p a { color: #2563eb; }

直觉上,浏览器似乎应该先找到所有 article,再在里面找 .content,逐层向下。但真实情况恰恰相反:浏览器先找到页面上所有的 a 元素,然后逐个向上回溯——这个 a 的父级是不是 p?再往上是不是 .content?再往上是不是 article?

这样做的好处是:如果页面上只有 20 个链接,就只需要做 20 次回溯;而如果按从左到右匹配,遇到一个复杂的 article 结构时,可能要遍历成千上万个节点才发现里面根本没有 a。这也直接推导出两条性能经验:

  • 关键选择器越具体越好。选择器最右边的部分如果是 .btn 而不是 * 或 a,浏览器就能快速缩小候选集合。
  • 避免过于冗长的后代链。像 .page .main .list .item .link 这样的五层后代选择器,会带来不必要的回溯开销,而且几乎无法复用。
低效写法
body div ul li a span
关键选择器是 span,页面上一抓一大把,回溯链条还特别长。
高效写法
.menu-link > .label
关键选择器 .label 精确,层级浅,复用性也更好。

二、基础选择器:八种最常用的匹配方式

CSS 的简单选择器一共只有寥寥几种,但它们是构建一切复杂选择器的原子。点击下面的卡片,查看每种选择器的语法与注意事项。

1
类型
元素名
语法:div、p、a。匹配所有该标签名的元素。特异性最低(c 位 +1),适合做基础重置与全局排版。
2
类
.class
语法:.card。最常用的选择器。一个元素可以有多个类名,一个类名也可以作用于多个元素。特异性 b 位 +1。
3
ID
#id
语法:#app。一个文档中 ID 必须唯一。特异性 a 位 +1,非常高,容易引发“优先级战争”,现代实践中应尽量少用。
4
通配符
*
语法:*。匹配所有元素,特异性为 0。常见于 * { box-sizing: border-box; }。性能影响在现代浏览器中已可忽略。
5
属性
[attr]
语法:[type="text"]。按属性名或属性值匹配,特异性与类选择器相同(b 位 +1)。变体形式非常丰富,见后文专章。
6
伪类
:hover
语法::hover、:nth-child()。表示元素的某种状态或位置关系,单冒号。特异性 b 位 +1(:where() 除外)。
7
伪元素
::before
语法:::before、::first-line。创造或选中元素的一部分,双冒号。特异性 c 位 +1,等同于类型选择器。
8
嵌套
& 父引用
原生 CSS 嵌套中,& 代表父选择器。它本身不产生特异性,但会影响最终展开后的选择器结构。

类选择器为什么成了事实标准

在类型、类、ID 三种基础选择器里,类选择器几乎统治了整个现代前端。原因并不复杂:

  • 可复用:一个 .btn 可以同时作用在 button、a、input 上,不绑定标签。
  • 可组合:<button class="btn btn-primary btn-large"> 通过多个类名叠加出不同变体,符合单一职责。
  • 特异性适中:一个类 (0,1,0),两个类 (0,2,0),在可控范围内,不至于像 ID 那样难以覆盖。
  • 工具友好:TypeScript、CSS Modules、Tailwind 等生态几乎全部围绕类名构建。

ID 选择器:能力越大,麻烦越大

ID 选择器的特异性高达 (1,0,0)。这意味着任何由类、属性、伪类组成的选择器都无法覆盖它,除非同样使用 ID 或者搬出 !important。在一个多人协作、样式层层叠加的项目里,这几乎等同于埋了一颗定时炸弹。

更现实的建议是:ID 留给 JavaScript 与锚点跳转,样式一律用类。如果确实需要给某个唯一元素加样式,用类名或者 :where(#id) 把特异性清零。

/* 危险:特异性 (1,0,0) */
#header .nav-item { color: red; }

/* 安全:用 :where() 把 ID 的特异性清零,结果仍是 (0,1,0) */
:where(#header) .nav-item { color: red; }

/* 更好:干脆不用 ID */
.site-header .nav-item { color: red; }

通配符的性能迷思

“* 选择器性能很差”是一个流传了十几年的说法,它诞生于 IE6/IE7 时代。在今天的浏览器中,通配符的匹配已经被高度优化,* { box-sizing: border-box; } 这类写法完全不必担心。

真正需要警惕的不是 * 本身,而是把通配符放在复杂选择器的末尾,比如 .list > *。这时浏览器要先枚举所有元素再逐个回溯验证,虽然现代引擎也能处理,但如果你能写成 .list > .item,语义和性能都会更好。

三、组合器:描述元素之间的结构关系

单个选择器只能描述“我是什么”,组合器才能描述“我在哪”。CSS 一共提供了五种组合器,它们决定了选择器的匹配范围。

/* 1. 后代:任意深度的子孙 */
.card p { … }

/* 2. 子代:仅直接子元素 */
.card > p { … }

/* 3. 相邻兄弟:紧邻的下一个同级 */
h2 + p { … }

/* 4. 通用兄弟:之后所有同级 */
h2 ~ p { … }

/* 5. 列组合器:表格列 */
col.selected || td { … }

/* 并集:多个选择器共用一组声明 */
h1, h2, h3 { … }
空格 范围最广,容易误伤深层元素
> 只认直接子级,结构更可控
+ 紧邻一个兄弟,常用于标题后的段落
~ 同级中所有后续兄弟,适合批量处理
|| 表格列选择,兼容性有限

后代选择器:最常用,也最容易失控

后代选择器(空格)使用频率最高,但它的“无差别穿透”特性是把双刃剑。看一个典型例子:

/* 想让卡片标题变蓝,结果卡片里所有 a 都变蓝了 */
.card a { color: #2563eb; }

/* 加上类名限定,范围立刻收敛 */
.card .card-title { color: #2563eb; }

/* 或者明确只处理直接子级 */
.card > .card-body > a { … }

一个务实的经验法则:后代选择器的层级不要超过三层。如果发现需要写四层以上,通常说明这块结构应该被拆成独立的类名,或者该用 > 把关系说清楚。

兄弟组合器:不写 JS 也能做的联动

+ 和 ~ 的实用价值常常被低估。它们能表达“当某个元素处于某种状态时,它后面的兄弟应该怎样”,这在纯 CSS 交互里非常有用。

/* 复选框被选中时,让后面的标签高亮 */
.switch:checked + .switch-label {
  color: #2563eb;
}

/* 标题之后的第一个段落去掉上边距 */
h2 + p { margin-top: 0; }

/* 标题之后的所有同级段落使用较小的字号 */
h2 ~ p { font-size: 0.95rem; }

注意两者的核心区别:+ 只看紧挨着的那一个,中间隔着任何元素都会失配;~ 则看后面所有的同级元素,但同样要求它们共享同一个父级。

组合器不产生特异性

这是一个极其重要但经常被忽略的点:空格、>、+、~、|| 本身都不贡献任何特异性。选择器的特异性完全由两边的简单选择器累加而来。

选择器 特异性 说明
.a .b (0,2,0) 两个类相加
.a > .b (0,2,0) 子代组合器不额外加权
.a + .b (0,2,0) 相邻兄弟同样不加权
div p (0,0,2) 两个类型选择器
div > p (0,0,2) 与上一行完全等价

这意味着,当两条规则发生冲突时,选择器写得多“具体”、层级写得多“深”,都不会帮你赢得层叠。真正决定胜负的,是其中类、属性、伪类与 ID 的数量。

四、属性选择器:不依赖类名的另一种精确表达

属性选择器直接针对 HTML 属性做匹配,不需要往标签上追加额外的类名。在处理表单、链接、以及由后端输出的、无法修改的 HTML 时,它几乎是唯一的选择。

写法 含义 示例匹配
[disabled] 存在该属性即可 <button disabled>
[type="text"] 属性值完全相等 type="text"
[class~="btn"] 空格分隔的列表中包含该词 class="btn primary"
[lang|="zh"] 等于该值,或以该值加连字符开头 lang="zh-CN"
[href^="https"] 以指定字符串开头 https://example.com
[href$=".pdf"] 以指定字符串结尾 report.pdf
[href*="docs"] 包含指定子串 /docs/guide
[type="text" i] 忽略大小写匹配 type="TEXT"
[type="text" s] 强制区分大小写 默认行为,显式声明

三个高频实战场景

场景一:根据链接协议或文件类型加图标。后端输出的富文本里,链接没有类名,但你可以在不修改 HTML 的前提下给所有外链加一个小箭头:

a[href^="http"]:not([href*="example.com"])::after {
  content: " ↗";
}

a[href$=".pdf"]::before {
  content: "PDF ";
}

场景二:表单状态样式。用属性选择器替代一部分伪类,可以精确控制不同类型的输入框:

input[type="email"] { border-color: #60a5fa; }
input[required] { border-left: 3px solid #ef4444; }
input[disabled] { opacity: 0.6; }
input[aria-invalid="true"] { border-color: #ef4444; }

场景三:多语言与无障碍属性联动。属性选择器能直接读取无障碍属性,让样式与语义保持一致:

[aria-expanded="true"] .chevron { transform: rotate(180deg); }
[aria-current="page"] { font-weight: 700; }
注意
[class*="btn"] 会同时匹配 .btn、.btn-large,也会误伤 .btn-group、.my-btn。子串匹配范围过宽,慎用。
建议
需要精确匹配一组类名时,用 [class~="btn"];需要前缀匹配时优先考虑改类名规范,而不是依赖模糊匹配。

五、伪类:描述状态与位置

伪类用单冒号开头,它不给文档添加新元素,只用来描述“元素当前处于什么状态”或“它在结构中排第几”。这是 CSS 里最庞大也最有用的一类选择器。

5.1 状态伪类:交互的四个阶段

链接与按钮的交互状态,是每个项目都会写的样式。它们有一个必须遵守的顺序,通常记作 LVHA:

L :link —— 尚未访问过的链接
V :visited —— 已经访问过的链接(受隐私限制,可改的属性非常少)
H :hover —— 鼠标悬停
A :active —— 正在被按下的一瞬间

为什么顺序不能乱?因为它们的特异性完全相同(都是 0,1,0),此时后写的规则会覆盖先写的。如果把 :hover 写在 :active 后面,那么按钮按下时既满足 hover 又满足 active,后写的 hover 样式就会赢,用户完全看不到“按下”的反馈。

焦点三兄弟::focus、:focus-visible、:focus-within

焦点样式直接关系到键盘用户能否使用你的页面,但它的细节常常被忽略:

  • :focus:元素获得焦点时触发,鼠标点击也会触发。
  • :focus-visible:只有当浏览器判断“用户确实需要看到焦点提示”时才触发。用鼠标点击按钮通常不会触发,用 Tab 键则会触发。它是解决“鼠标点击时也出现焦点环”这一体验问题的标准答案。
  • :focus-within:当元素自身或它的任意后代获得焦点时触发。做表单分组高亮、下拉菜单展开时极其方便。
/* 键盘用户看得到,鼠标用户不受打扰 */
.btn:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}

/* 输入框获得焦点时,整个字段组高亮 */
.field:focus-within {
  border-color: #2563eb;
  box-shadow: 0 0 0 3px rgba(37,99,235,.15);
}

千万不要写 *:focus { outline: none; }。如果实在觉得默认焦点环不好看,请用 :focus-visible 替换成自定义的样式,而不是直接抹掉。

表单状态伪类速查

:checked // 选中的复选/单选框
:disabled // 禁用的控件
:enabled // 可用的控件
:required // 必填
:optional // 选填
:valid // 校验通过
:invalid // 校验失败
:in-range // 数值在范围内
:out-of-range // 数值超出范围
:read-only // 只读
:read-write // 可编辑
:placeholder-shown // 显示占位符时
:autofill // 被浏览器自动填充
:default // 默认选中的选项
:indeterminate // 半选状态
技巧 用 :not(:placeholder-shown) 判断输入框是否已有内容,实现浮动标签
提醒 :invalid 在页面加载时就可能触发,通常需要配合 :user-invalid 使用
细节 :checked 配合兄弟组合器,可以不写 JS 实现开关与选项卡

5.2 结构伪类:用数学描述位置

结构伪类中最强大的是 :nth-child() 系列,它接受一个 an+b 形式的公式。读懂这个公式,等于掌握了 CSS 里唯一的“循环语法”。

写法 含义 匹配序号
:nth-child(3) 第 3 个 3
:nth-child(odd) 奇数位 1, 3, 5, 7…
:nth-child(even) 偶数位 2, 4, 6, 8…
:nth-child(3n) 每 3 个一组 3, 6, 9, 12…
:nth-child(3n+1) 每 3 个中的第 1 个 1, 4, 7, 10…
:nth-child(-n+3) 前 3 个 1, 2, 3
:nth-child(n+4) 第 4 个及之后 4, 5, 6, 7…
:nth-last-child(2) 倒数第 2 个 —

公式 an+b 的读法是:从 b 开始,每次加 a。当 a 为负数时,表示向前递减,通常配合 b 用来取“前 N 个”。记住这一点,-n+3 就不用死记硬背了:n 从 0 开始取值,代入得 3、2、1、0、-1……只保留大于 0 的结果,即 1、2、3。

child 与 of-type 的区别

这是结构伪类里最容易混淆的一对概念,必须彻底弄清楚:

<div class="box">
  <h2>标题</h2>
  <p>段落一</p>
  <p>段落二</p>
</div>

/* 不匹配:第 1 个子元素是 h2,不是 p */
.box p:first-child { … }

/* 匹配:同类型中的第 1 个 p */
.box p:first-of-type { … }

/* 匹配:段落一,因为它是第 2 个子元素 */
.box p:nth-child(2) { … }
-child 在所有兄弟中计数,不区分标签名
-of-type 只统计同标签名的兄弟
建议 结构可控时用 -child,混排内容用 -of-type

还有两个常被遗忘的实用伪类::empty 匹配完全没有任何子节点(包括文本节点)的元素,常用来隐藏空列表或空提示;:root 匹配文档根元素,实际上就是 html,但它的特异性是 (0,1,0) 而不是 (0,0,1),因此非常适合用来定义 CSS 变量。

5.3 逻辑伪类:选择器的函数式能力

以 :is()、:where()、:not()、:has() 为代表的逻辑伪类,把选择器从“描述一个集合”升级成了“可以对集合做运算”。它们彻底改变了复杂选择器的写法。

:is() 与 :where()

两者都接受一个选择器列表,匹配其中任意一个即可。它们唯一的区别在于特异性:

  • :is() 的特异性 = 参数列表中特异性最高的那个。
  • :where() 的特异性恒为 0,无论参数里写了什么。
/* 改造前:重复三遍相同声明 */
.card h2,
.card h3,
.card h4 { margin-top: 0; }

/* 改造后:可读性大幅提升 */
.card :is(h2, h3, h4) { margin-top: 0; }

/* :where() 版本,特异性为 (0,0,0),极易被覆盖 */
:where(h2, h3, h4) { margin-top: 0; }

/* 经典用法:基础重置样式用 :where(),特异性归零 */
:where(ul, ol) { list-style: none; }

:where() 的真正价值在于写“低优先级的基础样式库”。第三方组件库、CSS Reset、主题基础层都适合用它——使用者只需写一个普通的类选择器,就能轻松覆盖默认样式,不必再和库作者的特异性较劲。

:not() 与 :has()

:not() 用于排除,可以串联多个条件;:has() 则用于“根据后代反向选择父元素”,它是 CSS 历史上最重要的能力补充之一。

/* 排除最后一项 */
.list li:not(:last-child) { border-bottom: 1px solid #e2e8f0; }

/* 串联排除:既不是 .active 也不是 .disabled */
.item:not(.active):not(.disabled) { … }

/* :has() —— 含有图片的卡片加更大的内边距 */
.card:has(img) { padding: 0; }

/* 表单中只要有必填项未填,就高亮整组 */
.fieldset:has(input:invalid) { border-color: #ef4444; }

/* 配合兄弟组合器:选中复选框时改变整个卡片 */
.card:has(input:checked) { background: #eff6ff; }

:has() 常被称为“父选择器”,但它的能力远不止于此——它可以在任意方向上做条件判断,甚至支持相对选择器(如 :has(> .child)、:has(+ .next))。它让“内容驱动的样式”第一次成为纯 CSS 可解的问题。

性能提醒
:has() 需要浏览器在元素的后代中做反向查找,在超大列表上频繁使用时可能带来可观测的开销,应尽量避免与通配符组合。
推荐用法
给 :has() 的参数加上明确的选择器限定,例如 :has(> .badge),让匹配范围尽量小。

六、伪元素:创造不存在的元素

伪元素用双冒号开头,它可以“凭空造出”一个元素,或者选中元素的一部分。它与伪类最直观的区别是:伪类描述已存在的状态,伪元素创造新的事物。

伪元素 作用 典型用途
::before 在内容前插入 图标、装饰线、清除浮动
::after 在内容后插入 箭头、角标、提示气泡
::first-line 首行文本 首行缩进、首行加粗
::first-letter 首字母 首字下沉、杂志排版
::marker 列表项标记 自定义项目符号颜色与形状
::selection 被选中的文本 自定义选中背景色
::placeholder 占位符文本 统一输入框提示样式
::backdrop 模态框遮罩 dialog 的背板样式
::file-selector-button 文件上传按钮 统一 input[type=file] 外观

::before 与 ::after 的三条铁律

规则一 必须提供 content 属性,哪怕是空字符串 content: "",否则伪元素不会生成
规则二 它们是行内元素,需要设置宽高时通常要加 display: block 或 inline-block
规则三 生成的内容不会进入 DOM,屏幕阅读器支持不一,关键信息绝不能放在伪元素里
/* 用伪元素做装饰性下划线,不污染 DOM */
.link {
  position: relative;
}
.link::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: -2px;
  width: 0;
  height: 2px;
  background: #2563eb;
  transition: width .3s ease;
}
.link:hover::after {
  width: 100%;
}

/* 自定义列表标记 */
li::marker {
  color: #2563eb;
  font-weight: 700;
}

/* 自定义选中文本 */
::selection {
  background: #bfdbfe;
  color: #0f172a;
}

单冒号与双冒号

CSS3 引入了双冒号语法,用来把伪元素与伪类在视觉上区分开。::before、::after、::first-line、::first-letter 这四个历史悠久的伪元素,至今仍然支持单冒号写法(:before),浏览器为了向后兼容保留了这一能力。但新的伪元素如 ::marker、::selection、::backdrop 只支持双冒号。

统一使用双冒号是更清晰的选择,也能避免与伪类混淆——毕竟 :first-line 和 ::first-line 看起来太像了。

伪元素不能做什么

  • 不能承载有意义的内容:屏幕阅读器对 ::before / ::after 生成的文本支持不一致,重要信息必须写在 HTML 里。
  • 不能给 input、img、br 这类替换元素添加 ::before / ::after(表单控件除外,某些控件在部分浏览器中可以)。
  • 不能用伪元素嵌套伪元素,::before::after 是非法的。
  • 伪元素的特异性相当于类型选择器,为 c 位 +1,比伪类低一个等级。

七、优先级:特异性如何计算

当多条规则命中同一个元素、并且声明了同一个属性时,浏览器需要决定听谁的。这个决策过程叫层叠,而特异性是层叠中最核心的一环。

三元组 (a, b, c)

CSS 用三个计数器来表示选择器的特异性,通常写作 (a, b, c):

a ID 选择器的数量,如 #app、#header
b 类选择器、属性选择器、伪类的数量,如 .card、[type="text"]、:hover
c 类型选择器、伪元素的数量,如 div、::before
0 通配符 *、组合器(空格、>、+、~)不贡献任何特异性

比较时从左到右逐位比较,不是把它们当成一个整数相加。也就是说:

/* (0,1,0) —— 一个类 */
.card { … }

/* (0,0,10) —— 十个类型选择器,仍然输给上面那个类 */
html body div ul li a span em b i { … }

这一点非常重要:再多类型选择器也堆不过一个类,再多类也堆不过一个 ID。因为 a 位、b 位、c 位之间不会进位。

一张完整的特异性对照表

选择器写法 特异性 说明
* (0,0,0) 通配符不贡献权重
li (0,0,1) 一个类型选择器
ul li (0,0,2) 两个类型选择器
ul > li (0,0,2) 组合器不额外加权
::before (0,0,1) 伪元素等同类型选择器
.item (0,1,0) 一个类
[hidden] (0,1,0) 属性选择器算 b 位
:hover (0,1,0) 伪类算 b 位
.item.active (0,2,0) 两个类
li.item (0,1,1) 一个类加一个类型
#nav (1,0,0) 一个 ID
#nav .item (1,1,0) ID 加类
:is(#nav, .item) (1,0,0) 取参数中最高的特异性
:where(#nav, .item) (0,0,0) 恒为 0
:not(.item) (0,1,0) 取参数的特异性
:nth-child(2n+1) (0,1,0) 伪类自身算一个 b
行内 style (1,0,0,0) 高于所有选择器

手算三个真实例子

来看三条同时命中的一个按钮的规则,判断谁赢:

/* A: 一个类 + 一个属性选择器 */
.btn[type="submit"] { background: blue; }
→ (0, 2, 0)

/* B: 一个 ID + 一个类型选择器 */
#main button { background: green; }
→ (1, 0, 1)

/* C: 三个类 */
.form .actions .btn { background: orange; }
→ (0, 3, 0)

/* 比较:a 位 1 > 0,所以 B 获胜,最终背景为绿色 */

再看一个涉及 :is() 的例子:

.list :is(li, #special) { color: red; }
→ :is() 取最高,得到 (1,0,0),加上 .list 得到 (1,1,0)

.list :where(li, #special) { color: blue; }
→ :where() 恒为 0,结果只有 (0,1,0)

这两条规则里,红色的 :is() 版本特异性远高于蓝色的 :where() 版本,即使它们看起来只是在同一个位置换了函数名。

行内样式与 !important

在标准的特异性三元组之外,还有两个“超规格”的存在:

  • 行内样式(style="...")的特异性高于任何选择器。它可以被理解为 (1,0,0,0),因为 a 位已经满了,所以它永远赢。
  • !important 会反转层叠顺序:带 !important 的作者样式,优先级高于行内样式,也高于普通作者样式。
/* 无论选择器多简单,只要带上 !important 就能压制行内样式 */
.btn { color: red !important; }

/* 但多个 !important 之间,仍然要比较特异性 */
#main .btn { color: blue !important; } /* 这条赢 */
反模式
用 !important 去压制另一个 !important,再去压制再上一个。这是典型的“优先级战争”,最终没人敢改样式。
正确思路
追根溯源,找到真正冲突的两条规则,通过降低一方特异性(:where())或调整书写顺序来解决。

八、层叠:比优先级更大的规则

很多开发者以为“谁优先级高谁生效”,其实优先级只是层叠的第三步。完整的层叠排序规则按以下顺序依次判断,前一步能分出胜负就不会走到下一步:

  1. 来源与重要性:样式从哪里来,是否带 !important。
  2. 层叠层:样式是否定义在 @layer 中,层与层之间有明确的先后。
  3. 特异性:即上面讲的 (a, b, c)。
  4. 出现顺序:越靠后写的规则越优先。

来源与重要性的完整顺序

从低到高排列:

层级 说明
1. 浏览器默认样式 User Agent Stylesheet,最弱的起点
2. 用户普通样式 用户在浏览器设置里指定的字体、颜色等
3. 作者普通样式 你写的所有不带 !important 的 CSS
4. 动画样式 @keyframes 中正在运行的声明
5. 作者 !important 你写的带 !important 的 CSS
6. 用户 !important 用户自定义样式,用于覆盖站点强制样式
7. 浏览器 !important 最高优先级
8. 过渡(transition) 正在进行的过渡效果,优先级最高

注意第 5 到第 7 条:!important 的存在是为了保护用户,而不是为了方便开发者。用户可以用 !important 覆盖站点的强制样式,这是无障碍的重要保障。

@layer:重新定义“先后顺序”

层叠层(Cascade Layers)是近年来对 CSS 层叠模型最大的一次改动。它允许你显式声明优先级分组,彻底摆脱“靠选择器堆特异性”的困境。

/* 第一步:声明层的顺序,越靠后优先级越高 */
@layer reset, base, components, utilities;

@layer reset {
  * { margin: 0; padding: 0; }
}

@layer base {
  h1 { font-size: 2rem; }
}

@layer components {
  .card .title { font-size: 1.25rem; }
}

@layer utilities {
  .text-lg { font-size: 1.5rem; }
}

在这段代码里,utilities 层中的 .text-lg 只有一个类 (0,1,0),而 components 层中的 .card .title 有两个类 (0,2,0)。按照传统规则,后者应该获胜。但因为层的优先级高于特异性,.text-lg 会赢。

这正是层叠层最迷人的地方:你可以用最简单的选择器,稳定地覆盖最复杂的选择器,只要它写在一个更靠后的层里。样式表的可预测性因此大幅提升。

未分层的样式优先级最高

一个反直觉但必须记住的规则:没有写在任何 @layer 里的样式,优先级高于所有已声明的层。它的位置相当于“最后声明的一个匿名层”。

这个设计是有意为之——它保证了引入层叠层的项目仍然可以被外部样式覆盖。如果你想彻底控制顺序,记得把所有样式都放进层里。

匿名层与嵌套层

/* 匿名层:每次 @layer 块都是一层新的,顺序按出现先后 */
@layer { .a { color: red; } }
@layer { .a { color: blue; } } /* 这条赢 */

/* 嵌套层:用点号表示层级 */
@layer framework.base { … }
@layer framework.components { … }

/* 引用外部样式表到指定层 */
@import url("reset.css") layer(reset);

@import ... layer(...) 这个能力尤其实用:引入第三方 CSS 时,可以直接把它塞进一个低优先级层,之后你自己的样式用再简单的选择器也能覆盖它,无需再研究对方写了什么。

九、选择器性能:哪些说法是真的

选择器性能是一个被大量以讹传讹的话题。先给出结论:在绝大多数项目中,选择器本身的匹配开销可以忽略不计,真正影响性能的是它触发的样式重算范围与布局开销。但确实存在一些值得注意的边界情况。

真实存在的开销

注意 :has() 与 :nth-child(... of S) 需要在 DOM 中做反向或带条件的查找,在超大列表上可能变慢
注意 属性选择器中的 [attr*="x"]、[attr$="x"] 需要对每个候选元素的属性值做字符串扫描
注意 深层后代选择器会带来更多的回溯链条,尽管单次开销极小
结论 真正需要优化的是“样式重算的次数”,而不是“选择器的写法”

被夸大的说法

  • “通配符很慢”:现代引擎对 * 有专门优化,性能影响可以忽略。真正的问题往往是它带来的“样式命中范围过大”。
  • “ID 选择器最快”:浏览器确实可以为 ID 建立索引,但在实践中,使用 ID 带来的可维护性问题远大于它节省的那点匹配时间。
  • “类名越长越慢”:类名的长度与匹配速度无关,只影响文件体积(通常也微不足道)。
  • “少用选择器就能提升性能”:比起选择器数量,渲染层要关心的更多是重排(reflow)与重绘(repaint)的触发频率。

真正值得优化的三件事

  1. 减少样式的重算范围:给会频繁变化的元素使用更独立的结构,避免牵一发动全身的深层选择器。
  2. 避免在动画中触发重排:只用 transform 和 opacity 做动画,它们可以走合成层。
  3. 控制 :has() 的使用范围:在列表项上使用是安全的,在几千行的表格行上全局使用需要谨慎。

至于选择器本身怎么写,请把精力放在可读性、可维护性与特异性可控上。这才是选择器真正影响项目质量的地方。

十、重构实战:从特异性泥潭到扁平选择器

下面是一段典型的“特异性不断加码”的样式代码:每加一个新需求,就往上叠一层选择器,直到没人敢动它。点击按钮查看重构后的版本。

/* 需求一:卡片标题加粗 */
.card h3 { font-weight: 700; }

/* 需求二:侧栏里的卡片标题要小一点 */
.sidebar .card h3 { font-size: 0.95rem; }

/* 需求三:首页特色区的卡片标题不能加粗 */
.home .featured .card h3 {
  font-weight: 400 !important;
}

/* 需求四:移动端标题再小一点…… */
.home .featured .card h3:not(.large) {
  font-size: 0.875rem;
}

/* 现在已经没人敢删任何一行了 */

重构带来的四个改变

  • 特异性恒定:所有规则都在 (0,1,0) 到 (0,2,0) 之间,不再有 (0,3,2) 这种失控的数字。
  • 不再需要 !important:变体类天然写在后面,覆盖关系清晰。
  • 可预测:看到一个类名,就能知道它会带来哪些样式,不需要逐层追溯祖先。
  • 可迁移:同样的类名可以在任何页面上复用,不依赖 DOM 层级。

三条可落地的重构原则

  1. 选择器只描述元素本身,不描述它的位置。用 .card-title,而不是 .sidebar .card h3。
  2. 变体用修饰类,不用嵌套。用 .is-compact,而不是在父级上再加一层作用域。
  3. 基础样式放进低优先级层。用 @layer 或 :where(),让覆盖变得轻松。

十一、十个最常见的选择器陷阱

陷阱 1 把 :hover 写在 :active 后面,导致按下反馈完全看不见
陷阱 2 用 :not(.a.b) 以为能排除“同时有 a 和 b”的元素,实际上它排除了“有 a 且有 b”的,语法正确但语义常被误解
陷阱 3 误以为 :nth-child() 会区分标签名,结果在混排结构里全部错位
陷阱 4 给 input、img 添加 ::before,发现完全没有效果
陷阱 5 忘记写 content: "",导致 ::before 根本不渲染
陷阱 6 用 *:focus { outline: none } 抹掉焦点环,键盘用户彻底迷路
陷阱 7 在 :is() 里塞了一个 ID,导致整条规则特异性飙升到 (1,0,0)
陷阱 8 把内容写在 ::after 的 content 里,屏幕阅读器读不出来
陷阱 9 用 !important 解决问题,两周后自己都不知道为什么这个颜色改不掉
陷阱 10 以为后代选择器写得越深优先级越高,结果仍然被一个单类规则覆盖

三个需要展开的细节

关于 :not() 的复合参数::not(.a.b) 的参数是一个复合选择器,表示“同时具有 a 和 b 两个类”。它排除的是这类元素,而不是排除 a 或 b。如果想排除“有 a 或者有 b”的,需要写成 :not(.a):not(.b)。新规范中的 :not(.a, .b) 也可以实现同样的效果,它会匹配“既不是 .a 也不是 .b”的元素。

关于 :nth-child 与 DOM 顺序:伪类的计数基于文档中的实际顺序,而不是视觉顺序。如果用了 flex-direction: row-reverse 或 order 属性改变了排列,:first-child 仍然指向 DOM 中的第一个元素,视觉上它可能在最右边。这是一个非常容易踩的坑。

关于伪元素与无障碍:用 ::before 生成的图标(例如 content: "✓")在很多屏幕阅读器中是会被朗读的,但朗读效果取决于具体实现。如果图标只是装饰,更稳妥的做法是在 HTML 中写一个带 aria-hidden="true" 的 span,而不是依赖伪元素。

十二、选择器检查清单与常用代码基线

把全文要点浓缩成一份可以直接照着改的清单。每次写完样式表扫一遍,能挡掉绝大多数后续麻烦。

  • 样式一律用类选择器,ID 留给 JavaScript 与锚点。
  • 后代选择器不超过三层,需要更精确时用 > 明确关系。
  • 关键选择器要具体,避免 .list * 这类以大范围元素结尾的写法。
  • 状态伪类遵守 LVHA 顺序::link → :visited → :hover → :active。
  • 焦点样式用 :focus-visible,永远不要直接删掉 outline。
  • 区分 -child 与 -of-type,混排结构优先用后者。
  • 基础样式用 :where() 清零特异性,方便被业务样式覆盖。
  • :is() 里不要放 ID,否则整条规则特异性会被拉到最高。
  • 伪元素必须写 content,且不承载关键信息。
  • 不要用 !important 解决优先级问题,优先调整选择器或使用 @layer。
  • 引入第三方样式时使用 @import ... layer(...),把它压到低优先级层。
  • 能用原生伪类就不用 JS,例如 :checked、:has()、:focus-within。
  • 动画只用 transform 与 opacity,避免触发重排。
  • 提交前用 DevTools 的 Styles 面板检查,看看有没有被划掉的样式,那些就是优先级冲突点。
/* 一份可直接复用的选择器组织基线 */

/* 1. 声明层顺序 */
@layer reset, tokens, base, layout, components, utilities;

/* 2. 重置层:清零特异性,方便被覆盖 */
@layer reset {
  :where(*, *::before, *::after) {
    box-sizing: border-box;
  }
  :where(body, h1, h2, h3, p, ul, ol) {
    margin: 0;
  }
}

/* 3. 基础层:元素级排版 */
@layer base {
  body {
    font-family: system-ui, sans-serif;
    line-height: 1.6;
  }
  a:focus-visible {
    outline: 2px solid #2563eb;
    outline-offset: 2px;
  }
}

/* 4. 组件层:单一类名,修饰类做变体 */
@layer components {
  .card { … }
  .card > .card-body { … }
  .card-title { … }
  .card-title.is-compact { … }
}

/* 5. 工具层:低特异性,用于覆盖 */
@layer utilities {
  .text-center { text-align: center; }
  .visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}

选择器是 CSS 里最像“语言”的部分:它用极其精简的符号,描述了一组元素在文档树中的身份、位置与状态。而优先级与层叠,则是这套语言背后的秩序。很多人花了几年时间在“加类名、加层级、加 !important”的循环里挣扎,本质上是因为没有建立起对这套秩序的完整认知。

好消息是,这套秩序并不复杂——它只有四步判断、三个计数器、两种特殊存在。一旦你真正理解了它,样式冲突就不再是玄学,而是可以推理、可以预测、可以被设计的东西。

所以,下一次当某个样式“莫名其妙不生效”的时候,不妨先打开 DevTools 看看那条被划掉的规则——它究竟是被谁、按哪一条规则赢走的。答案往往就藏在本文的某个小节里。