写 CSS 的日常,十次里有八次是在和选择器打交道。样式没生效、颜色被覆盖、改了这处坏了那处——绝大多数“玄学问题”的根源,最后都能追溯到两个地方:选择器没有匹配到你以为的元素,或者匹配到了但优先级不够。这两个问题看似简单,却几乎没有人能一次性说清::is() 和 :where() 的特异性到底差在哪?:nth-child(2n+1) 的公式怎么读?为什么 !important 有时候也救不回来?@layer 又是如何改变了层叠的规则?本篇 25 分钟长文,从选择器语法讲起,逐层拆解组合器、属性选择器、伪类与伪元素,再深入到特异性计算、层叠顺序与 @layer,最后落到性能、陷阱与可落地的最佳实践。读完它,你会对“为什么这条样式生效了”有完整的答案。
一、选择器是 CSS 唯一的“提问方式”
一条 CSS 规则集由两部分构成:选择器与声明块。选择器负责“发问”——它向文档树提问:“谁符合这个条件?”;声明块负责“回答”——把所有匹配到的元素改成指定的样子。
.card > .title {
color: #0f172a;
font-weight: 700;
}
浏览器渲染页面时会经历“构建 DOM 树 → 匹配选择器 → 计算层叠 → 生成渲染树”这条链路。选择器匹配发生在样式计算阶段,它的正确性决定了后面所有环节的结果。理解选择器,本质上是在理解浏览器如何在几万个节点中,快速找到你描述的那一批。
三条必须牢记的基本事实
从右向左:一个被严重低估的细节
假设写了这样一条规则:
直觉上,浏览器似乎应该先找到所有 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 的简单选择器一共只有寥寥几种,但它们是构建一切复杂选择器的原子。点击下面的卡片,查看每种选择器的语法与注意事项。
div、p、a。匹配所有该标签名的元素。特异性最低(c 位 +1),适合做基础重置与全局排版。.card。最常用的选择器。一个元素可以有多个类名,一个类名也可以作用于多个元素。特异性 b 位 +1。#app。一个文档中 ID 必须唯一。特异性 a 位 +1,非常高,容易引发“优先级战争”,现代实践中应尽量少用。*。匹配所有元素,特异性为 0。常见于 * { box-sizing: border-box; }。性能影响在现代浏览器中已可忽略。[type="text"]。按属性名或属性值匹配,特异性与类选择器相同(b 位 +1)。变体形式非常丰富,见后文专章。:hover、:nth-child()。表示元素的某种状态或位置关系,单冒号。特异性 b 位 +1(:where() 除外)。::before、::first-line。创造或选中元素的一部分,双冒号。特异性 c 位 +1,等同于类型选择器。& 代表父选择器。它本身不产生特异性,但会影响最终展开后的选择器结构。类选择器为什么成了事实标准
在类型、类、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) 把特异性清零。
#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 一共提供了五种组合器,它们决定了选择器的匹配范围。
.card p { … }
/* 2. 子代:仅直接子元素 */
.card > p { … }
/* 3. 相邻兄弟:紧邻的下一个同级 */
h2 + p { … }
/* 4. 通用兄弟:之后所有同级 */
h2 ~ p { … }
/* 5. 列组合器:表格列 */
col.selected || td { … }
/* 并集:多个选择器共用一组声明 */
h1, h2, h3 { … }
后代选择器:最常用,也最容易失控
后代选择器(空格)使用频率最高,但它的“无差别穿透”特性是把双刃剑。看一个典型例子:
.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 的前提下给所有外链加一个小箭头:
content: " ↗";
}
a[href$=".pdf"]::before {
content: "PDF ";
}
场景二:表单状态样式。用属性选择器替代一部分伪类,可以精确控制不同类型的输入框:
input[required] { border-left: 3px solid #ef4444; }
input[disabled] { opacity: 0.6; }
input[aria-invalid="true"] { border-color: #ef4444; }
场景三:多语言与无障碍属性联动。属性选择器能直接读取无障碍属性,让样式与语义保持一致:
[aria-current="page"] { font-weight: 700; }
[class*="btn"] 会同时匹配 .btn、.btn-large,也会误伤 .btn-group、.my-btn。子串匹配范围过宽,慎用。
[class~="btn"];需要前缀匹配时优先考虑改类名规范,而不是依赖模糊匹配。
五、伪类:描述状态与位置
伪类用单冒号开头,它不给文档添加新元素,只用来描述“元素当前处于什么状态”或“它在结构中排第几”。这是 CSS 里最庞大也最有用的一类选择器。
5.1 状态伪类:交互的四个阶段
链接与按钮的交互状态,是每个项目都会写的样式。它们有一个必须遵守的顺序,通常记作 LVHA:
:link —— 尚未访问过的链接
:visited —— 已经访问过的链接(受隐私限制,可改的属性非常少)
:hover —— 鼠标悬停
: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 替换成自定义的样式,而不是直接抹掉。
表单状态伪类速查
: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 的区别
这是结构伪类里最容易混淆的一对概念,必须彻底弄清楚:
<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
还有两个常被遗忘的实用伪类::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
.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):
#app、#header
.card、[type="text"]、:hover
div、::before
*、组合器(空格、>、+、~)不贡献任何特异性
比较时从左到右逐位比较,不是把它们当成一个整数相加。也就是说:
.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) | 高于所有选择器 |
手算三个真实例子
来看三条同时命中的一个按钮的规则,判断谁赢:
.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() 的例子:
→ :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的作者样式,优先级高于行内样式,也高于普通作者样式。
.btn { color: red !important; }
/* 但多个 !important 之间,仍然要比较特异性 */
#main .btn { color: blue !important; } /* 这条赢 */
!important 去压制另一个 !important,再去压制再上一个。这是典型的“优先级战争”,最终没人敢改样式。
:where())或调整书写顺序来解决。
八、层叠:比优先级更大的规则
很多开发者以为“谁优先级高谁生效”,其实优先级只是层叠的第三步。完整的层叠排序规则按以下顺序依次判断,前一步能分出胜负就不会走到下一步:
- 来源与重要性:样式从哪里来,是否带
!important。 - 层叠层:样式是否定义在
@layer中,层与层之间有明确的先后。 - 特异性:即上面讲的 (a, b, c)。
- 出现顺序:越靠后写的规则越优先。
来源与重要性的完整顺序
从低到高排列:
| 层级 | 说明 |
|---|---|
| 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 { .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)的触发频率。
真正值得优化的三件事
- 减少样式的重算范围:给会频繁变化的元素使用更独立的结构,避免牵一发动全身的深层选择器。
- 避免在动画中触发重排:只用
transform和opacity做动画,它们可以走合成层。 - 控制
: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 层级。
三条可落地的重构原则
- 选择器只描述元素本身,不描述它的位置。用
.card-title,而不是.sidebar .card h3。 - 变体用修饰类,不用嵌套。用
.is-compact,而不是在父级上再加一层作用域。 - 基础样式放进低优先级层。用
@layer或:where(),让覆盖变得轻松。
十一、十个最常见的选择器陷阱
:hover 写在 :active 后面,导致按下反馈完全看不见
:not(.a.b) 以为能排除“同时有 a 和 b”的元素,实际上它排除了“有 a 且有 b”的,语法正确但语义常被误解
:nth-child() 会区分标签名,结果在混排结构里全部错位
input、img 添加 ::before,发现完全没有效果
content: "",导致 ::before 根本不渲染
*:focus { outline: none } 抹掉焦点环,键盘用户彻底迷路
:is() 里塞了一个 ID,导致整条规则特异性飙升到 (1,0,0)
::after 的 content 里,屏幕阅读器读不出来
!important 解决问题,两周后自己都不知道为什么这个颜色改不掉
三个需要展开的细节
关于 :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 看看那条被划掉的规则——它究竟是被谁、按哪一条规则赢走的。答案往往就藏在本文的某个小节里。