CSS 选择器详解

简单 🟢CSS 布局
7 个标签
预计阅读时间:79 分钟
CSS选择器优先级特异性伪类伪元素BEM

CSS 选择器详解

CSS 选择器用于选择要样式化的 HTML 元素,是 CSS 的核心概念之一。可以把选择器理解成"一句话描述你想选中谁":越精准的描述越强大,但也越难被覆盖。理解选择器和它背后的优先级规则,是写出可维护样式的地基。

选择器的本质与重要性

浏览器渲染时会把每条 CSS 规则的选择器和页面里的每个元素做匹配,命中的元素就应用对应样式。这带来两个直接影响:

可维护性:选择器写法决定了样式好不好改。一堆高优先级、深嵌套的选择器会让后续改动变成"优先级军备竞赛",最后只能靠 `!important` 收场。
性能:浏览器从右往左匹配选择器,过于宽泛的关键选择器(如 `* {}` 或结尾是标签的复杂选择器)在超大 DOM 上会有性能成本(现代引擎已高度优化,但仍值得注意)。

基础选择器

元素选择器:

选择特定类型的元素
如:`p`、`div`、`h1`

类选择器:

选择具有特定类的元素,如:`.classname`
一个元素可以有多个类,也是团队协作里最推荐的主力选择器

ID 选择器:

选择具有特定 ID 的元素,如:`#idname`
一个页面中 ID 应该是唯一的;优先级很高,慎用于样式

通用选择器:

选择所有元素,如:`*`
通常用于重置样式(如统一 `box-sizing`)

属性选择器:

`[attr]`:具有 attr 属性的元素
`[attr=value]`:attr 属性值等于 value
`[attr^=value]`:attr 属性值以 value 开头
`[attr$=value]`:attr 属性值以 value 结尾
`[attr*=value]`:attr 属性值包含 value
`[attr~=value]`:属性值是空格分隔列表且含 value
`[attr|=value]`:属性值等于 value 或以 value- 开头(常用于语言 `lang`)
`[attr=value i]`:加 i 表示大小写不敏感

组合选择器

后代选择器:

选择后代元素,如:`div p`(div 内任意层级的所有 p)

子选择器:

选择直接子元素,如:`div > p`(div 的直接子 p)

相邻兄弟选择器:

选择紧接在后的兄弟元素,如:`h1 + p`(紧接 h1 的那个 p)

通用兄弟选择器:

选择后面的所有兄弟元素,如:`h1 ~ p`(h1 之后的所有同级 p)

分组选择器:

同时选择多个选择器,如:`h1, h2, h3`

伪类选择器

伪类描述元素的"状态"或"位置",用单冒号 `:`。

链接与用户行为伪类:

`:link`:未访问的链接
`:visited`:已访问的链接
`:hover`:鼠标悬停
`:active`:激活(按下)状态
`:focus`:获得焦点
`:focus-visible`:仅键盘聚焦时显示焦点环(更友好)
`:focus-within`:自身或后代获得焦点时命中父级

表单伪类:

`:checked`:选中的复选框/单选框
`:disabled` / `:enabled`:禁用/可用
`:required` / `:optional`:必填/选填
`:valid` / `:invalid`:表单校验状态
`:placeholder-shown`:显示占位符时(可做浮动 label)

结构/位置伪类:

`:first-child` / `:last-child`:第一个/最后一个子元素
`:nth-child(n)`:第 n 个子元素(支持 `2n`、`odd`、`3n+1` 等表达式)
`:nth-of-type(n)`:同类型元素里的第 n 个
`:only-child`:唯一的子元素
`:empty`:没有子节点的元素

逻辑伪类:

`:not()`:否定选择器
`:is()`:匹配列表中任意选择器(取列表里最高优先级)
`:where()`:与 `:is()` 类似,但自身优先级恒为 0
`:has()`:父元素/关系选择器,可根据后代或兄弟反选父级(俗称"父选择器")

伪元素选择器

伪元素创建或选中"文档树里不存在的部分",用双冒号 `::`。

`::before` / `::after`:

在元素内容前后插入生成内容,需要 `content` 属性
常用于装饰、图标、清除浮动、角标

`::first-letter` / `::first-line`:

第一个字母/第一行,用于首字下沉等排版效果

`::selection`:

用户选中的文本,自定义高亮样式

`::placeholder`:

输入框的占位符文本样式

`::marker`:

列表项的项目符号/序号

优先级(特异性)

优先级(Specificity)决定"多条规则都命中同一元素时,哪条生效"。它是一个可以理解为 (a, b, c) 三元组的计算,逐位比较。

计算规则(数值仅为直观记忆,实际是逐段比较):

行内样式 `style="..."`:1000(最高,属于独立段)
ID 选择器:100
类 / 属性 / 伪类:10
元素 / 伪元素:1
通用选择器 `*`、组合符 `>`、`+`、`~`:0(不加分)
`:where()` 内部:始终 0

示例:

`#nav`:100
`.nav .item`:20
`div#nav .item a`:112(一个 ID 100 + 一个类 10 + 两个元素 2)
`:is(#a, .b)`:取列表中最高,即 100

重要规则:

`!important` 覆盖普通声明(但两个 important 之间再比优先级)
应尽量避免 `!important`,它会破坏级联
相同优先级时,后定义的规则生效(源码顺序)
层叠还受 `@layer`(级联层)影响:低层的样式会被高层覆盖,与选择器优先级解耦

代码示例

基础选择器

cssCode
/* 元素选择器 */
p  { color: #333; line-height: 1.6; }
h1 { font-size: 2rem; font-weight: 700; }

/* 类选择器(主力) */
.button {
  padding: 10px 20px;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
.button--primary   { background-color: #007bff; }
.button--secondary { background-color: #6c757d; }

/* ID 选择器(慎用) */
#header {
  background-color: #333;
  color: white;
  padding: 20px;
}

/* 通用选择器:常用于重置 */
* { box-sizing: border-box; }

/* 属性选择器 */
input[type="text"] {
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
}
a[href^="https"]        { color: #007bff; }   /* 外链 */
a[href$=".pdf"]::after  { content: ' (PDF)'; } /* PDF 链接加标记 */
img[alt*="logo"]        { border: 2px solid #007bff; }
[data-state="loading"]  { opacity: 0.5; }

组合选择器

cssCode
/* 后代选择器 */
.container p { margin-bottom: 1rem; }

/* 子选择器 */
.nav > .nav-item { padding: 10px; }

/* 相邻兄弟:段落紧跟标题时做引言样式 */
h1 + p { font-size: 1.2rem; font-weight: 600; }

/* 通用兄弟 */
h1 ~ p { color: #666; }

/* 分组 */
h1, h2, h3 { font-weight: 700; margin-bottom: 0.5rem; }

伪类选择器

cssCode
/* 链接伪类(顺序建议 LVHA) */
a:link    { color: #007bff; }
a:visited { color: #6c757d; }
a:hover   { color: #0056b3; text-decoration: underline; }
a:active  { color: #004085; }

/* 表单伪类 */
input:focus         { outline: 2px solid #007bff; outline-offset: 2px; }
input:focus-visible { outline: 2px solid #0056b3; }
input:checked       { accent-color: #007bff; }
input:disabled      { opacity: 0.5; cursor: not-allowed; }
input:invalid       { border-color: #dc3545; }

/* focus-within:输入框聚焦时高亮整个表单组 */
.form-group:focus-within { background: #f0f8ff; }

/* 结构伪类 */
li:first-child     { font-weight: 700; }
li:last-child      { margin-bottom: 0; }
li:nth-child(2n)   { background-color: #f5f5f5; }  /* 斑马纹 */
li:nth-child(3n+1) { color: #007bff; }             /* 每 3 个的第 1 个 */
li:only-child      { list-style: none; }
p:empty           { display: none; }

/* 逻辑伪类 */
.nav-item:not(.active) { opacity: 0.7; }
:is(h1, h2, h3) { font-weight: 700; }              /* 提优先级 */
:where(h1, h2, h3) { margin: 0; }                   /* 优先级 0,易覆盖 */

/* :has() 父/关系选择器 */
article:has(img)            { padding: 20px; }       /* 含图片的文章 */
.form-group:has(input:invalid) label { color: #dc3545; } /* 校验失败标红 label */
figure:has(figcaption)      { border: 1px solid #eee; }

伪元素选择器

cssCode
/* ::before / ::after */
.card::before {
  content: '';
  display: block;
  width: 100%;
  height: 4px;
  background-color: #007bff;
}
.button::after { content: ' →'; margin-left: 5px; }

/* 用 ::before 做角标 */
.badge-new { position: relative; }
.badge-new::before {
  content: 'NEW';
  position: absolute;
  top: -8px;
  right: -8px;
  font-size: 10px;
  padding: 2px 6px;
  background: #dc3545;
  color: #fff;
  border-radius: 10px;
}

/* 首字下沉 */
.article p::first-letter {
  font-size: 2rem;
  font-weight: 700;
  float: left;
  margin-right: 10px;
}
.article p::first-line { font-weight: 600; color: #333; }

/* 选中文本样式 */
::selection { background-color: #007bff; color: white; }

/* 占位符 */
input::placeholder { color: #999; font-style: italic; }

/* 列表标记 */
li::marker { color: #007bff; font-weight: 700; }

优先级示例

cssCode
/* 优先级:100(ID) */
#header { color: red; }

/* 优先级:20(两个类) */
.nav .item { color: blue; }

/* 优先级:112(一个 ID + 一个类 + 两个元素) */
div#nav .item a { color: green; }

/* 相同优先级:后定义者生效 */
.button { background-color: #007bff; }
.button { background-color: #0056b3; }  /* 最终是这个 */

/* !important 覆盖普通声明(应尽量避免) */
.button { background-color: #dc3545 !important; }

/* :where() 优先级为 0,方便被主题覆盖 */
:where(.reset) ul { margin: 0; padding: 0; }

级联层 @layer 控制覆盖顺序

cssCode
/* 无论选择器谁更"重",靠后声明的 layer 整体优先级更高 */
@layer reset, base, components, utilities;

@layer components {
  .btn { background: #007bff; color: #fff; }
}
@layer utilities {
  .bg-red { background: #dc3545; } /* utilities 在后,覆盖 components */
}

真实案例

案例 1:BEM 化解优先级战争。 某项目早期用后代选择器(`.sidebar .list .item a`)写样式,改版时新样式老被旧规则压住,工程师不断加 `!important`,最终一个组件里有 30 多个 important。重构为 BEM(`.sidebar__link` 单类)后,几乎所有选择器优先级都是 10,覆盖只靠源码顺序,important 数量降到个位数。

案例 2:`:has()` 去掉一段 JS。 某表单原来用 JavaScript 监听输入、给父容器加/删 class 来标红。用 `.field:has(input:invalid)` 后,纯 CSS 就能实现校验态联动,删掉约 40 行 JS,且状态与 DOM 始终同步、无闪烁。

案例 3:`:nth-child` 做表格斑马纹。 一个上千行的数据表格,原本后端渲染时给每行交替加 class。改用 `tr:nth-child(even)` 后,模板更干净,行增删也不用重算奇偶。

数据与对比

| 选择器类型 | 优先级贡献 | 推荐度 | 说明 |

| --- | --- | --- | --- |

| 内联 style | 1000 | 低 | 难覆盖,仅动态计算值用 |

| ID #id | 100 | 低 | 优先级过高,尽量别用于样式 |

| 类 .class | 10 | 高 | 团队协作主力 |

| 属性 [attr] | 10 | 中 | 适合状态钩子如 data-* |

| 伪类 :hover | 10 | 高 | 状态样式必备 |

| 元素 div | 1 | 中 | 基础排版、重置用 |

| 伪元素 ::after | 1 | 高 | 装饰内容利器 |

| 通用 * | 0 | 中 | 仅用于重置 |

| :where() | 0 | 高 | 写易被覆盖的默认值 |

浏览器支持参考:`:is()`/`:where()` 主流浏览器全支持;`:has()` 自 2023 年起 Chrome 105+、Safari 15.4+、Firefox 121+ 支持,覆盖率约 90%+,关键功能建议提供降级。

常见坑

1.靠 `!important` 打补丁:一旦开始用,就会连锁升级,最后无人敢动样式。优先降低对手规则的优先级,或用源码顺序 / `@layer`。
2.ID 选择器写样式:ID 优先级 100,后续用类根本盖不住,团队协作大忌。
3.`:nth-child` 与 `:nth-of-type` 混淆:`nth-child` 数的是所有兄弟里的位置,`nth-of-type` 数的是同标签里的位置,混用会选错。
4.`:not()` 里放复杂选择器提升了优先级:`:not(.a.b)` 会计入内部最高优先级,容易意外拔高。
5.误以为有真正的"父选择器"很久:`:has()` 才是父/关系选择器,老代码常用 JS 绕开,现在多数场景可用 CSS。
6.`::before`/`::after` 忘写 content:不写 `content`(哪怕空字符串)伪元素不会生成。
7.链接伪类顺序错:`:hover` 写在 `:visited` 前会被覆盖,记住 LVHA(link-visited-hover-active)。
8.过度依赖后代选择器:`.a .b .c .d` 又慢又脆,DOM 结构一变就失效。

最佳实践

以类选择器为主力,用 BEM 等命名规范让优先级扁平、可预测
避免过度嵌套(不超过 3 层),减少后代选择器链
尽量不用 ID 和 `!important` 写样式
用 `:where()` 写默认/重置样式,方便被业务覆盖
状态用伪类(`:hover`/`:focus-visible`/`:checked`)和 data-* 属性表达,别靠加 class 的 JS
善用 `:has()`、`:focus-within` 等关系伪类替代小段 JS
焦点样式优先用 `:focus-visible`,兼顾美观与无障碍
大型项目用 `@layer` 管理重置/基础/组件/工具的覆盖顺序

属性选择器深入

属性选择器常被低估,其实它是"根据元素自身携带的数据选样式"的利器,尤其配合 `data-*` 做状态机非常干净。

cssCode
/* 完整对照:不同匹配模式 */
[type]                 { }  /* 有 type 属性 */
[type="checkbox"]      { }  /* 精确等于 */
[class~="btn"]         { }  /* 空格分隔列表里含 btn(等价于 .btn) */
[lang|="zh"]           { }  /* 等于 zh 或以 zh- 开头,如 zh-CN */
[href^="mailto:"]      { }  /* 以 mailto: 开头 */
[href$=".zip"]         { }  /* 以 .zip 结尾 */
[href*="github"]       { }  /* 含 github */
[data-size="lg" i]     { }  /* i:忽略大小写,匹配 LG/Lg/lg */

/* 用 data-* 表达组件状态,样式与状态一一对应 */
.dialog[data-open="true"]     { display: block; }
.dialog[data-open="false"]    { display: none; }
.tab[aria-selected="true"]    { border-bottom: 2px solid #007bff; }
.btn[aria-busy="true"]        { pointer-events: none; opacity: 0.6; }
.row[data-status="error"]     { background: #fff5f5; }

/* 给外链、下载、不同文件类型自动加图标 */
a[target="_blank"]::after { content: ' ↗'; font-size: 0.8em; }
a[download]::before       { content: '⬇ '; }
a[href$=".pdf"]::after    { content: ' (PDF)'; color: #dc3545; }

:nth-child 表达式实战

`:nth-child` 的参数是一个 `An+B` 表达式,威力远超"隔行变色"。理解它能省掉大量给元素手动加 class 的 JS。

cssCode
/* 基础 */
li:nth-child(odd)   { }  /* 1,3,5... 奇数 */
li:nth-child(even)  { }  /* 2,4,6... 偶数 */
li:nth-child(3)     { }  /* 第 3 个 */

/* An+B 表达式 */
li:nth-child(3n)    { }  /* 3,6,9... 每三个的最后一个 */
li:nth-child(3n+1)  { }  /* 1,4,7... 每三个的第一个(每行首列) */
li:nth-child(n+4)   { }  /* 第 4 个及以后("超过 3 个就折叠") */
li:nth-child(-n+3)  { }  /* 前 3 个("只显示前三") */

/* 组合出"区间":第 3 到第 6 个 */
li:nth-child(n+3):nth-child(-n+6) { background: #f0f8ff; }

/* 从后往前数 */
li:nth-last-child(2)          { }  /* 倒数第 2 个 */
li:nth-last-child(-n+2)       { }  /* 最后两个 */

/* 结合 :only-child / :first-of-type 做边界处理 */
.tag:first-child { margin-left: 0; }
.tag:last-child  { margin-right: 0; }

一个高频真实需求:"列表超过 5 条就把多余的隐藏并显示『展开』"。纯 CSS 即可:

cssCode
.list:not(.expanded) > li:nth-child(n+6) { display: none; }
.list.expanded > li { display: list-item; }

选择器性能

浏览器从右往左匹配选择器。最右边那个(关键选择器 key selector)决定了初始候选集大小,因此它越具体越快。

cssCode
/* 慢:关键选择器是标签 a,浏览器要先找页面上所有 a,再往上回溯 */
header nav ul li a { }

/* 快:关键选择器是一个类,候选集立刻缩小 */
.nav-link { }

性能参考数据(量级示意,实际取决于引擎与 DOM 规模):

| 选择器写法 | 相对成本 | 说明 |

| --- | --- | --- |

| .single-class | 低 | 现代引擎哈希查找,接近 O(1) |

| tag(如 div) | 中 | 候选集大 |

| .a .b .c .d 深层后代 | 高 | 逐级回溯 |

| * 通用 | 中高 | 匹配所有元素 |

| :has() 复杂关系 | 中高 | 需评估关系,慎用大范围 |

结论:在绝大多数页面上选择器性能已不是瓶颈,但在超大 DOM(上万节点)高频重排场景,扁平的单类选择器仍有意义。不要为了"性能"牺牲可读性,但也别写 6 层后代链。

命名方法论对比

选择器写法背后是一套组织哲学,主流三种:

| 方法 | 核心思想 | 优点 | 代价 |

| --- | --- | --- | --- |

| BEM | Block__Element--Modifier 单类 | 优先级扁平、语义清晰、可预测 | 类名长 |

| OOCSS | 结构与皮肤分离、可复用对象 | 复用高 | 需要设计抽象 |

| 原子化/Utility(Tailwind) | 一个类一条样式 | 几乎不写自定义 CSS、体积可控 | HTML 类名冗长、学习成本 |

BEM 示例(推荐团队默认):

cssCode
/* Block */
.card { }
/* Element */
.card__title { }
.card__body  { }
/* Modifier */
.card--featured { }
.card__title--large { }

无障碍与选择器

选择器能配合 ARIA 属性表达可访问的状态,样式与语义同步,避免"看起来对但读屏软件读不出"的问题。

cssCode
/* 焦点样式优先用 :focus-visible,鼠标点击不显示、键盘导航才显示 */
.btn:focus-visible { outline: 3px solid #0056b3; outline-offset: 2px; }

/* 用 ARIA 状态驱动样式,保证视觉与语义一致 */
[aria-current="page"]      { font-weight: 700; }
[aria-expanded="true"] .caret { transform: rotate(180deg); }
[aria-disabled="true"]     { opacity: 0.5; cursor: not-allowed; }
[aria-invalid="true"]      { border-color: #dc3545; }

/* 视觉隐藏但保留给读屏(无障碍常用工具类) */
.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap; border: 0;
}

更多 :has() 关系选择器场景

`:has()` 落地后,很多以前必须靠 JS 的"根据内部状态改外部样式"都能纯 CSS 实现。

cssCode
/* 卡片内有 <video> 时改用横向布局 */
.card:has(video) { display: grid; grid-template-columns: 200px 1fr; }

/* 选中某单选框时高亮它所在的整个 option 行 */
.option:has(input:checked) { border-color: #007bff; background: #f0f8ff; }

/* 表单任一字段无效时,禁用提交按钮所在的工具条 */
form:has(:invalid) .submit-bar { opacity: 0.5; }

/* 数量选择:列表为空时显示占位提示(配合 :empty) */
.list:has(li) + .empty-hint { display: none; }

/* 前一个兄弟被 hover 时,当前元素做出反应 */
.menu-item:has(+ .menu-item:hover) { opacity: 0.6; }

更多真实案例

案例 4:下拉箭头随展开翻转。 折叠面板以前用 JS 切 class 控制箭头方向。改用 `[aria-expanded="true"] .caret { transform: rotate(180deg); }` 后,箭头状态和 ARIA 语义永远同步,读屏用户体验也正确,删掉了对应 DOM 操作代码。

案例 5:营销页"只显示前三、其余折叠"。 用 `li:nth-child(-n+3)` 与 `li:nth-child(n+4) { display:none }` 配合一个 checkbox(`:checked` 展开),实现零 JS 的"查看更多",首屏 DOM 更轻。

案例 6:打印样式。 用属性选择器给打印场景补充上下文:`@media print { a[href^="http"]::after { content: ' (' attr(href) ')'; } }`,打印出来的文章能显示链接真实地址,非常实用。

选择器匹配原理:为什么从右往左

理解浏览器如何匹配选择器,是写高效、可维护选择器的地基。

关键选择器(Key Selector):选择器最右边的那一段。浏览器先用它圈出候选元素集,再向左逐段验证祖先/兄弟关系。

cssCode
/* 浏览器视角:先找所有 <a>(关键选择器),再逐个往上确认它在
   .list 里、.list 又在 #nav 里 —— 候选集大,验证多 */
#nav .list a { }

/* 关键选择器是类,候选集立刻缩小到 .nav-link,几乎不用回溯 */
.nav-link { }

为什么不是从左往右? 若从左往右,浏览器要先找到所有 `#nav`,再遍历其全部后代找 `a`——对每个元素都要向下遍历整棵子树,成本更高。从右往左能以"关键选择器"快速锁定小候选集,再向上验证(祖先链是有限且确定的)。

这决定了一条黄金法则:让关键选择器尽量具体(用类而非标签),别让它是宽泛的 `*` 或标签。

cssCode
/* 慢:关键选择器 * */
.container * { box-sizing: border-box; }

/* 慢:关键选择器是标签,且链很长 */
body div.wrapper ul li span { }

/* 快:关键选择器是单类 */
.box-border { box-sizing: border-box; }

属性选择器:状态机与数据驱动

属性选择器配合 `data-*` 和 ARIA,是"用元素自身数据驱动样式"的利器,比加 class 更语义化、更好维护。

cssCode
/* 完整匹配模式对照 */
[disabled]              { }  /* 存在该属性 */
[type="email"]          { }  /* 精确等于 */
[class~="btn"]          { }  /* 空格列表含 btn,等价 .btn */
[lang|="en"]            { }  /* 等于 en 或 en- 开头(en-US、en-GB) */
[href^="https://"]      { }  /* 以...开头 */
[src$=".webp"]          { }  /* 以...结尾 */
[title*="警告"]         { }  /* 包含子串 */
[data-role="admin" i]   { }  /* i:忽略大小写 */
[data-role="admin" s]   { }  /* s:强制区分大小写 */

/* 用 data-* 做组件状态机:状态与样式一一对应 */
.accordion[data-state="open"]   .panel { display: block; }
.accordion[data-state="closed"] .panel { display: none; }
.toast[data-type="success"]     { border-color: #22c55e; }
.toast[data-type="error"]       { border-color: #ef4444; }
.btn[data-loading]              { pointer-events: none; opacity: 0.6; }
.step[data-status="done"]::before   { content: "✓"; color: #22c55e; }
.step[data-status="active"]::before { content: "●"; color: #3b82f6; }

/* ARIA 属性驱动样式,保证视觉与语义同步 */
[aria-expanded="true"] .chevron { transform: rotate(180deg); }
[aria-selected="true"]          { background: #eff6ff; }
[aria-current="page"]           { font-weight: 700; }
[aria-busy="true"]              { cursor: progress; }
[aria-hidden="true"]            { display: none; }

/* 属性值提取到内容:打印时展开链接地址 */
@media print {
  a[href^="http"]::after { content: " (" attr(href) ")"; font-size: 0.85em; }
}

data 属性 vs class 表达状态:

| 维度 | class(is-active 等) | data 属性(data-state) |

|---|---|---|

| 互斥状态 | 需手动清除旧类 | 一个属性天然互斥 |

| 语义 | 一般 | 强(键值对) |

| JS 操作 | classList | dataset(更直观) |

| 多值状态 | 麻烦 | 天然支持 |

结构伪类完整体系

结构伪类按"位置"选元素,是免写 JS 分配 class 的利器。关键是分清 `child` 系与 `of-type` 系。

cssCode
/* child 系:在所有兄弟中数位置 */
li:first-child        { }  /* 第一个子元素(必须是第一个兄弟) */
li:last-child         { }  /* 最后一个 */
li:only-child         { }  /* 唯一子元素 */
li:nth-child(3)       { }  /* 第 3 个兄弟 */
li:nth-last-child(2)  { }  /* 倒数第 2 个 */

/* of-type 系:在同标签兄弟中数位置 */
p:first-of-type       { }  /* 第一个 <p>(前面可以有别的标签) */
p:last-of-type        { }  /* 最后一个 <p> */
p:only-of-type        { }  /* 同级中唯一的 <p> */
p:nth-of-type(2)      { }  /* 第 2 个 <p> */
p:nth-last-of-type(1) { }  /* 倒数第 1 个 <p> */

/* 其他结构伪类 */
:empty                { }  /* 无子节点(含文本)的元素 */
:root                 { }  /* 文档根,通常是 <html>,特异性等于一个类 */

child 与 of-type 的关键差异

htmlCode
<div>
  <h2>标题</h2>   <!-- 第 1 个子元素,但是第 1 个 h2 -->
  <p>段落一</p>   <!-- 第 2 个子元素,但是第 1 个 p -->
  <p>段落二</p>   <!-- 第 3 个子元素,第 2 个 p -->
</div>
cssCode
p:first-child   { }  /* 不命中!第一个子元素是 h2,不是 p */
p:first-of-type { }  /* 命中"段落一":它是第一个 p */

这是最经典的踩坑点——想选"第一个段落"却用了 `:first-child`,因为前面有个标题而失效,应该用 `:first-of-type`。

:nth-child(An+B) 表达式大全

`nth-child` 的 `An+B` 是一门小语言,掌握它能替代大量 JS。

cssCode
/* 基础 */
:nth-child(odd)    /* 奇数 1,3,5... */
:nth-child(even)   /* 偶数 2,4,6... */
:nth-child(3)      /* 恰好第 3 个 */

/* An 步长 */
:nth-child(3n)     /* 3,6,9...(每 3 个的最后一个) */
:nth-child(4n)     /* 4,8,12... */

/* An+B 偏移 */
:nth-child(3n+1)   /* 1,4,7...(每 3 个的第一个 = 网格每行首列) */
:nth-child(2n+1)   /* 等价 odd */
:nth-child(5n-1)   /* 4,9,14... */

/* 边界选择 */
:nth-child(n+4)    /* 第 4 个及以后("超过 3 个的部分") */
:nth-child(-n+3)   /* 前 3 个 */
:nth-child(n+2):nth-child(-n+5)  /* 交集:第 2 到第 5 个 */

/* of S 语法:先过滤再计数 */
:nth-child(even of .visible)  /* 只在 .visible 里数偶数 */
:nth-child(-n+3 of li.tag)    /* 前 3 个 li.tag */

实战需求集:

cssCode
/* 需求:网格每行 3 列,给每行首列去掉左边距 */
.grid-item:nth-child(3n+1) { margin-left: 0; }

/* 需求:列表超过 5 条折叠,配合 checkbox 展开 */
.list:not(:has(.toggle:checked)) > li:nth-child(n+6) { display: none; }

/* 需求:前 3 名奖牌配色 */
.rank:nth-child(1) { color: gold; }
.rank:nth-child(2) { color: silver; }
.rank:nth-child(3) { color: #cd7f32; }

/* 需求:斑马纹只算可见行(隐藏行不打乱) */
tr:nth-child(even of :not([hidden])) { background: #f8f9fa; }

:not() 深入

`:not()` 现代已支持复杂选择器列表,但要警惕它对特异性的影响。

cssCode
/* 单参数 */
.btn:not(.disabled) { cursor: pointer; }

/* 选择器列表:任一匹配即排除 */
input:not([type="hidden"], [disabled], [readonly]) {
  border: 1px solid #ddd;
}

/* 组合链 */
li:not(:first-child):not(:last-child) { }  /* 中间的项 */
li:not(:first-child) { border-top: 1px solid #eee; }  /* 除首项外都加上边框 */

/* 与其他伪类组合 */
.card:not(:has(img)) { padding: 12px; }    /* 无图卡片紧凑 */
a:not([href]) { color: inherit; cursor: default; }  /* 无 href 的锚点 */

特异性陷阱:`:not()` 本身不加特异性,但它计入括号内特异性最高的那个

cssCode
:not(.a)      /* 特异性 = 一个类(0,1,0) */
:not(#id)     /* 特异性 = 一个 ID(1,0,0)—— 意外拔高! */
:not(.a, #id) /* 取列表最高 = ID(1,0,0) */

所以 `div:not(#foo)` 的特异性会因为里面的 ID 而飙升,覆盖时可能出乎意料。需要"零特异性排除"时可套 `:where()`:

cssCode
/* :where() 内部恒为 0,用它包住排除条件不拔高特异性 */
a:where(:not(.btn)) { text-decoration: underline; }

:is() 与 :where():特异性的两把钥匙

二者语法相同、行为相同,唯一区别是特异性计算:`:is()` 取参数中最高,`:where()` 恒为 0。

cssCode
/* :is() 简化并列,特异性 = 参数中最高者 */
:is(h1, h2, h3) a { color: var(--color-primary); }
/* 等价于展开三条,但特异性按 h_ + a 计算 */

/* :where() 简化并列,特异性 = 0,极易被覆盖 */
:where(h1, h2, h3) { margin-block: 0; }
/* 适合写"默认值/重置",业务样式无需提高特异性就能盖过 */

/* 对比:同样的分组,特异性天差地别 */
:is(.a, .b, #c) .target { }    /* 特异性含 ID = (1,0,1) */
:where(.a, .b, #c) .target { } /* 特异性 = (0,0,1),忽略内部 */

实用场景:库/框架用 `:where()` 提供默认样式,让业务方用最普通的类就能覆盖,避免"改个默认值还要提高特异性打架"。

cssCode
/* 一个可被轻松覆盖的重置层 */
:where(ul, ol) { margin: 0; padding: 0; list-style: none; }
:where(a) { color: inherit; text-decoration: none; }
:where(button) { font: inherit; cursor: pointer; }
/* 业务侧:.nav a { color: blue; } 一个类就能盖过上面的 :where(a) */

表单伪类状态机

表单是伪类最密集的地方,用好它们能做出无需 JS 的实时校验反馈。

cssCode
/* 校验状态 */
input:valid            { }  /* 满足约束 */
input:invalid          { }  /* 不满足(但页面一加载空必填就红,体验差) */
input:user-valid       { }  /* 用户交互后才判定为有效(推荐) */
input:user-invalid     { }  /* 用户交互后才判定为无效(推荐) */
input:required         { }  /* 必填 */
input:optional         { }  /* 选填 */
input:in-range         { }  /* 数值在 min/max 内 */
input:out-of-range     { }  /* 超范围 */

/* 交互与内容状态 */
input:placeholder-shown{ }  /* 正显示占位符(即为空) */
input:autofill         { }  /* 浏览器自动填充 */
input:read-only        { }  /* 只读 */
input:checked          { }  /* 勾选 */
input:indeterminate    { }  /* 半选(如全选框的中间态) */
input:default          { }  /* 默认选中项 */

/* 组合出浮动标签:占位符隐藏(有内容)或聚焦时,label 上浮 */
.field { position: relative; }
.field label {
  position: absolute; left: 12px; top: 12px;
  transition: all 0.15s; pointer-events: none;
}
.field input:focus + label,
.field input:not(:placeholder-shown) + label {
  top: -8px; font-size: 0.75rem; color: var(--color-primary);
}

/* 用 :user-invalid 做友好校验:用户动过才标红 */
input:user-invalid {
  border-color: #dc3545;
}
input:user-invalid + .error-hint { display: block; }

/* 自定义复选框(用 :checked 驱动) */
.checkbox input:checked + .box { background: var(--color-primary); }
.checkbox input:checked + .box::after { content: "✓"; color: #fff; }

伪元素完整清单

伪元素创建"文档树里不存在的部分",用双冒号 `::`。除了常见的 before/after,还有一批实用的。

cssCode
/* 生成内容 */
.tag::before { content: "#"; opacity: 0.5; }
.tag::after  { content: ""; }

/* 首字下沉与首行 */
.article p::first-letter { font-size: 3rem; float: left; line-height: 1; }
.article p::first-line   { font-variant: small-caps; }

/* 选中文本高亮 */
::selection { background: var(--color-primary); color: #fff; }

/* 占位符 */
input::placeholder { color: #9ca3af; }

/* 列表标记 */
li::marker { color: var(--color-primary); font-weight: 700; }

/* 文件上传按钮 */
input[type="file"]::file-selector-button {
  padding: 6px 12px; border-radius: 6px;
  background: var(--color-primary); color: #fff; border: none;
}

/* 对话框/弹窗遮罩 */
dialog::backdrop { background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(2px); }

/* 详情组件展开内容(新) */
details::details-content { transition: opacity 0.3s; }

/* 拼写/语法错误(新) */
::spelling-error { text-decoration: red wavy underline; }
::grammar-error  { text-decoration: green wavy underline; }

/* 高亮定位:URL 里 #:~:text= 命中的文本 */
::target-text { background: #fef08a; }

伪元素用途一览:

| 伪元素 | 用途 |

|---|---|

| ::before / ::after | 装饰、图标、角标、清浮动 |

| ::first-letter / ::first-line | 排版首字/首行 |

| ::selection | 自定义选中高亮 |

| ::placeholder | 占位符样式 |

| ::marker | 列表符号/序号 |

| ::backdrop | 全屏/弹窗遮罩 |

| ::file-selector-button | 文件上传按钮 |

| ::target-text | 文本片段定位高亮 |

特异性计算详解与练习

特异性是三元组 (ID 数, 类/属性/伪类 数, 元素/伪元素 数),逐位比较,前位大者胜,与源码顺序无关(顺序只在完全相等时才裁决)。

cssCode
/* 逐个拆解 */
*                         /* (0,0,0) */
li                        /* (0,0,1) */
li::before                /* (0,0,2) 元素 + 伪元素 */
.list                     /* (0,1,0) */
.list li                  /* (0,1,1) */
.list li.active           /* (0,2,1) */
a[href]                   /* (0,1,1) 元素 + 属性 */
#nav                      /* (1,0,0) */
#nav .list li a           /* (1,1,2) */
:is(#nav, .side) a        /* (1,0,1) :is 取最高 = ID */
:where(#nav) a            /* (0,0,1) :where 恒 0 */
:not(.a, #b)              /* (1,0,0) :not 取内部最高 = ID */

练习:谁生效?

cssCode
/* A */ #main .card .title { color: red; }    /* (1,2,0) */
/* B */ .card h2.title.big  { color: blue; }   /* (0,3,1) */
/* 答案:A 生效。第一位 1 > 0,直接判定,后面的位不用比。
   即使 B 有更多类,也敌不过一个 ID。 */
cssCode
/* C */ .btn.primary        { background: green; }  /* (0,2,0) */
/* D */ .btn[data-variant]   { background: teal; }   /* (0,2,0) */
/* 答案:C 与 D 特异性相等 (0,2,0),此时看源码顺序,后写的 D 生效。 */

这正是架构方法论的动机:让选择器尽量保持在 (0,1,0) 这一层(单类),覆盖只靠源码顺序或 @layer,就永远不会陷入"要盖过对方只能加 ID 或 !important"的军备竞赛。

@layer 与选择器优先级的关系

级联层把"覆盖顺序"从特异性比拼升级为显式层序,是选择器优先级治理的终极方案。

cssCode
/* 声明层顺序:越靠后优先级越高,与特异性无关 */
@layer reset, base, components, utilities;

@layer components {
  /* 即使这里用了高特异性 */
  #app .card .title.big { color: black; }   /* (1,3,0) */
}
@layer utilities {
  /* 这个低特异性的类照样赢,因为 utilities 层更靠后 */
  .text-red { color: red; }                 /* (0,1,0) */
}
/* 结果:.text-red 生效。层序 > 特异性。 */

优先级裁决的完整顺序(从高到低):

1.`!important`(层内 important 顺序反转)
2.行内 style
3.层外普通样式(高于所有命名层)
4.命名层,按声明顺序(后者高)
5.同层内,看特异性
6.特异性也相等,看源码顺序

选择器与无障碍

选择器配合 ARIA 与可访问性伪类,让样式与语义同步,兼顾好看与可用。

cssCode
/* 键盘聚焦才显示焦点环,鼠标点击不显示 */
:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
/* 但绝不要 :focus { outline: none } 一刀切,会害了键盘用户 */

/* 焦点在容器内任意后代时高亮容器 */
.card:focus-within { box-shadow: 0 0 0 2px var(--color-focus); }

/* ARIA 驱动状态样式 */
[aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; }
[aria-invalid="true"]  { border-color: #dc3545; }
[aria-pressed="true"]  { background: var(--color-primary); color: #fff; }

/* 尊重减少动效偏好 */
@media (prefers-reduced-motion: reduce) {
  *, ::before, ::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

/* 视觉隐藏但保留给读屏 */
.sr-only {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}

真实案例集(续)

案例 7:纯 CSS 标签页(Tabs)。 用 radio + `:checked` + 兄弟选择器实现无 JS 标签切换。

cssCode
.tabs input[type="radio"] { display: none; }
.tabs input:nth-of-type(1):checked ~ .panels .panel:nth-child(1),
.tabs input:nth-of-type(2):checked ~ .panels .panel:nth-child(2) {
  display: block;
}
.tabs .panel { display: none; }

案例 8:树形菜单展开箭头。 用 `details` + `[open]` 伪类,箭头随展开翻转,零 JS。

cssCode
details > summary { list-style: none; cursor: pointer; }
details > summary::before { content: "▶"; display: inline-block; transition: 0.2s; }
details[open] > summary::before { transform: rotate(90deg); }

案例 9:表格列 hover 高亮(配合 :has)。 鼠标在某单元格时,用 `:has()` 反选高亮整列的思路(结合 nth-child 定位)。

cssCode
/* 悬停某行时整行加深 */
tbody tr:hover { background: #f0f8ff; }
/* 表格容器在有选中行时显示批量操作条 */
.table-wrap:has(tbody input:checked) .bulk-bar { display: flex; }

案例 10:根据内容语言自动排版。 用 `[lang|="ar"]` 给阿拉伯语内容切换方向与字体。

cssCode
[lang|="ar"] { direction: rtl; font-family: "Noto Naskh Arabic", serif; }
[lang|="zh"] { font-family: "Noto Sans SC", sans-serif; letter-spacing: 0.02em; }

选择器性能实测与建议

现代引擎(Blink/WebKit/Gecko)用 Bloom filter 和快速拒绝优化,绝大多数选择器已不是瓶颈。但在超大 DOM(上万节点)高频 DOM 变更下仍需注意。

| 写法 | 相对成本 | 建议 |

|---|---|---|

| .single-class | 极低 | 首选 |

| tag(div、span) | 低 | 可用 |

| [attr] / [data-x] | 低 | 状态钩子首选 |

| .a .b .c 深层后代 | 中高 | 控制在 3 层内 |

| * 通用 | 中 | 仅重置用 |

| :has() 宽泛关系 | 中高 | 收窄作用域 |

| :not() 复杂列表 | 中 | 适度使用 |

实用结论:不要为微不足道的选择器性能牺牲可读性;但也别写 6 层后代链或全局宽泛 `:has()`。扁平单类既快又好维护,是双赢选择。

组合器深入:四种关系的实战

组合器(combinator)描述元素间的关系,用对能极大减少无谓的 class。

cssCode
/* 后代(空格):任意层级后代 */
.article a { color: var(--color-primary); }

/* 子(>):仅直接子级,不穿透更深 */
.menu > li { border-bottom: 1px solid #eee; }
/* 只给一级菜单项加线,子菜单的 li 不受影响 */

/* 相邻兄弟(+):紧接其后的那一个 */
h2 + p { margin-top: 0; }            /* 标题后第一段贴紧标题 */
.form-error + input { border-color: red; }

/* 通用兄弟(~):其后所有同级兄弟 */
h2 ~ p { color: #555; }              /* 标题之后的所有段落 */
input:checked ~ label { font-weight: 700; }

"猫头鹰"选择器(lobotomized owl):` + ` 给"除第一个外的所有元素"加间距,是经典的流式布局技巧。

cssCode
/* 给垂直排列的子元素之间统一加间距,首元素不加 */
.flow > * + * { margin-block-start: 1rem; }
/* 相比给每个元素加 margin-bottom 再清最后一个,这个更优雅 */

相邻兄弟做"上一个/下一个"联动:

cssCode
/* 手风琴:展开项的下一个标题拉开距离 */
.item[data-open] + .item { margin-top: 8px; }

/* 表单:某选项选中后显示紧邻的补充输入 */
input[value="other"]:checked ~ .other-input { display: block; }

更多状态与媒体伪类

除了常见伪类,还有一批"元素处于某种特殊状态"的伪类,用于弹窗、全屏、目标定位等。

cssCode
/* :target —— URL 锚点命中的元素(纯 CSS 路由/弹窗) */
#modal:target { display: block; }
.section:target { animation: highlight 1s; }

/* :modal —— 通过 showModal() 打开的 dialog */
dialog:modal { max-width: 500px; }

/* :popover-open —— 打开状态的 popover */
[popover]:popover-open { animation: pop-in 0.2s; }

/* :fullscreen —— 全屏元素 */
video:fullscreen { object-fit: contain; }

/* :defined —— 已注册的自定义元素(避免 FOUC) */
my-widget:not(:defined) { visibility: hidden; }

/* :dir() —— 按书写方向选择 */
:dir(rtl) .arrow { transform: scaleX(-1); }

/* :lang() —— 按语言选择(比属性选择器更懂继承) */
:lang(zh) { line-height: 1.8; }
:lang(ja) q { quotes: "「" "」"; }

/* :playing / :paused —— 媒体播放状态(新) */
video:paused { filter: grayscale(0.3); }

选择器在 JavaScript 中的复用

CSS 选择器不只用于样式,JS 的 DOM API 也用同一套语法,理解它们能少写很多手动遍历。

javascriptCode
// querySelector / querySelectorAll —— 同 CSS 选择器语法
const firstBtn = document.querySelector('.toolbar > .btn:first-child');
const invalids = document.querySelectorAll('input:invalid');

// matches —— 判断元素是否匹配某选择器(事件委托常用)
document.addEventListener('click', (e) => {
  if (e.target.matches('.btn, .btn *')) {
    handleButtonClick(e);
  }
});

// closest —— 向上找最近的匹配祖先(比手写 parentNode 循环干净)
const row = e.target.closest('tr[data-id]');
const id = row?.dataset.id;

// 属性/结构选择器在 JS 里同样有效
const openPanels = document.querySelectorAll('[data-state="open"]');
const evenRows = document.querySelectorAll('tbody tr:nth-child(even)');

事件委托 + matches 的价值:不用给每个按钮单独绑事件,只在父级绑一个,用 `matches` 判断来源,动态增删的元素也自动生效。

CSS 嵌套中的选择器

原生 CSS 嵌套让选择器组织更贴近结构,但要注意 `&` 的用法。

cssCode
.card {
  padding: 16px;

  /* & 拼接伪类 */
  &:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); }

  /* & 引用父级,写"父级在后"的关系 */
  .theme-dark & { background: #1e293b; }

  /* 嵌套后代 */
  & .title { font-weight: 700; }

  /* 嵌套 :has */
  &:has(img) { padding-block: 24px; }

  /* 嵌套媒体查询 */
  @media (min-width: 768px) { padding: 24px; }

  /* 嵌套 :is 列表 */
  & :is(h1, h2, h3) { margin: 0; }
}

注意:原生嵌套不支持 Sass 的 `&--modifier` 字符串拼接。BEM 修饰符要写全类名。

选择器调试技巧

cssCode
/* 技巧 1:outline 大法 —— 给可疑选择器加醒目轮廓,确认命中范围 */
.debug-target { outline: 2px solid magenta !important; }

/* 技巧 2:给所有元素描边,排查布局(不占空间,不用 border) */
* { outline: 1px solid rgba(255,0,0,0.2); }

/* 技巧 3:用属性存在选择器找出漏标的元素 */
img:not([alt]) { outline: 3px solid red; }        /* 缺 alt 的图 */
a:not([href]) { outline: 3px solid orange; }      /* 无 href 的链接 */
[style] { outline: 2px dashed purple; }           /* 有内联样式的(应消除) */
javascriptCode
// 在控制台快速统计某选择器命中数量
console.log(document.querySelectorAll('.card:has(img)').length);

// 找出所有用了 !important 的元素(结合 DevTools)
// 或用 getComputedStyle 验证最终生效值
const el = document.querySelector('.btn');
console.log(getComputedStyle(el).backgroundColor);

常见面试题

Q1:`div p` 和 `div > p` 的区别?

`div p` 选 div 内任意层级的 p;`div > p` 只选 div 的直接子 p。

Q2:`.a.b` 和 `.a .b` 的区别?

`.a.b`(无空格)选同时拥有 a 和 b 两个类的元素;`.a .b`(有空格)选 `.a` 后代里的 `.b`。这是初学者最常混的。

htmlCode
<div class="a b">同时有 a 和 b —— .a.b 命中</div>
<div class="a"><div class="b">.a 里的 .b —— .a .b 命中</div></div>

Q3:`:nth-child(2)` 和 `:nth-of-type(2)` 区别?

`:nth-child(2)` 要求"是父级的第 2 个子元素且匹配前面的类型";`:nth-of-type(2)` 是"同类型元素中的第 2 个",不管中间夹了别的标签。

Q4:如何选中"没有子元素的元素"?

`:empty`(连文本节点都没有)。注意有空格或注释也算非空。

Q5:`:where(.a) .b` 的特异性是多少?

(0,0,1)。`:where()` 内部特异性归零,只剩 `.b` 这个类……不对,`.b` 是类,应为 (0,1,0)?——准确说 `:where(.a)` 贡献 0,`.b` 贡献 (0,1,0),合计 (0,1,0)。这道题考的就是 `:where()` 抹掉内部特异性。

Q6:为什么 `a:hover` 要写在 `a:visited` 后面?

LVHA 顺序(link-visited-hover-active)。因为它们特异性相同,靠源码顺序裁决;hover 写在 visited 前会被 visited 覆盖,导致悬停变色失效。

:has() 场景大全(进阶)

`:has()` 是"关系选择器",能表达过去 CSS 无法表达的条件,这里汇总高频模式。

cssCode
/* 1. 根据后代存在性 */
.card:has(img)                { }  /* 含图 */
.card:not(:has(img))          { }  /* 无图 */
figure:has(figcaption)        { }  /* 有说明文字 */

/* 2. 根据后代状态 */
label:has(input:checked)      { }  /* 关联输入被选中 */
form:has(:invalid)            { }  /* 表单有无效项 */
tr:has(:checked)              { }  /* 行内有勾选 */

/* 3. 根据数量(配合 nth) */
.grid:has(> :nth-child(4))    { }  /* 至少 4 个子项 */
.list:has(> :only-child)      { }  /* 恰好 1 个 */

/* 4. 兄弟关系 */
h2:has(+ p)                   { }  /* 后面紧跟段落的标题 */
.item:has(~ .item:hover)      { }  /* 后面有兄弟被 hover */

/* 5. 全局态(挂在 body/html 上) */
body:has(dialog[open])        { overflow: hidden; }
html:has(#dark-toggle:checked){ color-scheme: dark; }

/* 6. 组合复杂条件 */
.product:has(.badge--sale):has(img) { }  /* 既打折又有图 */

降级策略:`:has()` 覆盖率虽已约 90%+,关键功能仍建议提供 JS 兜底。

javascriptCode
// 特性检测,不支持时用 JS 加 class 模拟
if (!CSS.supports('selector(:has(*))')) {
  document.querySelectorAll('.card').forEach((c) => {
    if (c.querySelector('img')) c.classList.add('has-image');
  });
}

真实案例:无 JS 的交互组件集

案例 11:纯 CSS 模态框。 用 `:target` 实现点击打开、点击遮罩关闭。

cssCode
.modal { display: none; }
.modal:target { display: flex; }              /* URL #modal 时显示 */
.modal:target .backdrop { /* 点击链接指向 # 即可关闭 */ }

案例 12:图片对比滑块。 用 `:hover` + 兄弟选择器,鼠标移动区分前后图(简化版)。

案例 13:表单进度指示。 用 `:has()` 检测各步骤填写状态,纯 CSS 点亮进度条。

cssCode
.wizard:has(#step1:valid) .progress { --step: 1; }
.wizard:has(#step2:valid) .progress { --step: 2; }
.progress::before { width: calc(var(--step) * 33.3%); }

案例 14:空状态自动切换。 列表为空时显示占位,有内容时隐藏占位,全靠 `:has`/`:empty`。

cssCode
.list:empty + .empty-state { display: block; }
.list:not(:empty) + .empty-state { display: none; }

选择器优先级速查与决策

面对"样式没生效",按这个顺序排查往往一击即中:

1.是否被更高特异性覆盖? DevTools 里看被划掉的规则。
2.是否被同特异性的后写规则覆盖? 看源码顺序。
3.是否在更低的 @layer? 层序高于特异性。
4.是否有 !important 在作怪? 尤其第三方库。
5.选择器本身写错了? `.a.b` 写成 `.a .b`、`:first-child` 该用 `:first-of-type`。
6.元素结构变了导致不匹配? 如中间多了一层 div。

| 想提高优先级 | 想降低优先级 |

|---|---|

| 加一个类(`.btn.btn`) | 用 `:where()` 包裹 |

| 用 `:is()` 引入高特异性项 | 放进更低的 @layer |

| (不推荐)加 ID / !important | 减少嵌套、去掉 ID |

综合实战:用选择器搭一个完整卡片组件

把前面的知识串起来,看一个真实卡片组件如何只用扁平、语义化的选择器完成全部交互态。

cssCode
/* 结构:单类为主,特异性统一在 (0,1,0) */
.card {
  --pad: 16px;
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: var(--pad);
  border: 1px solid var(--color-border);
  border-radius: 8px;
  transition: box-shadow 0.2s, transform 0.2s;
}

/* 交互态:伪类而非 JS 加类 */
.card:hover { box-shadow: 0 8px 24px rgba(0,0,0,0.1); transform: translateY(-2px); }
.card:focus-within { outline: 2px solid var(--color-primary); }

/* 条件布局::has 根据内容自适应 */
.card:has(.card__media) { padding-top: 0; }
.card:not(:has(.card__media)) { --pad: 20px; }

/* 结构伪类:首尾元素特殊处理 */
.card__body > p:first-of-type { margin-top: 0; }
.card__body > p:last-child { margin-bottom: 0; }

/* 状态:data 属性驱动 */
.card[data-featured] { border-color: var(--color-primary); }
.card[data-featured] .card__title::after { content: " ★"; color: gold; }
.card[data-loading] { opacity: 0.6; pointer-events: none; }

/* 数量自适应:标签超过 3 个时折叠多余的 */
.card__tags:has(> :nth-child(4)) > :nth-child(n+4) { display: none; }
.card__tags:has(> :nth-child(4))::after { content: "…"; }

/* 变体:BEM modifier */
.card--compact { --pad: 8px; gap: 6px; }
.card--horizontal { flex-direction: row; }

这个组件的选择器特征:几乎全是单类 (0,1,0),交互靠伪类、状态靠 data 属性、条件布局靠 `:has`、边界靠结构伪类——没有一个 ID、没有一个 !important、没有深层后代链。这就是"优先级扁平、可预测"的样板。

选择器团队规范建议

大型项目里,选择器写法应有明确约定并用工具强制。

| 规范项 | 建议 |

|---|---|

| 主力选择器 | 类选择器(BEM 命名) |

| 最大嵌套深度 | ≤ 3 层 |

| ID 用于样式 | 禁止(可用于 JS 钩子/锚点) |

| !important | 禁止(工具类除外,且走 @layer) |

| 状态表达 | 伪类 + data- / aria-,不靠 JS 加类改样式 |

| 默认/重置样式 | 用 `:where()` 保持 0 特异性 |

| 覆盖顺序 | 用 @layer 显式编排 |

jsCode
// stylelint 强制:最大特异性、禁 ID、限嵌套深度、BEM 命名
module.exports = {
  rules: {
    'selector-max-specificity': '0,3,0',
    'selector-max-id': 0,
    'selector-max-compound-selectors': 3,
    'selector-class-pattern':
      '^[a-z]([a-z0-9-]+)?(__([a-z0-9-]+))?(--([a-z0-9-]+))?$',
    'no-descending-specificity': true,   // 防止低特异性写在高特异性后面失效
  },
};

更多特异性计算练习

多做几道计算题,把规则内化成直觉。

cssCode
/* 逐一计算特异性 */
ul li a                    /* (0,0,3) 三个元素 */
.nav > li > a              /* (0,1,2) 一个类 + 两个元素 */
a.active:hover             /* (0,2,1) 两个类(含伪类) + 一个元素 */
input[type="text"]:focus   /* (0,2,1) 属性 + 伪类 + 元素 */
#main ul.list li:first-child /* (1,2,2) 一 ID + 两类 + 两元素 */
:is(.a, .b) :where(#x) .c  /* (0,2,0) :is 取 .a/.b 一个类, :where 归 0, .c 一个类 */
::before                   /* (0,0,1) 伪元素算元素 */
*, :where(*)               /* (0,0,0) 通用与 :where 都是 0 */

综合裁决题:

cssCode
/* 页面上一个 <a class="link active" href="#"> 同时被下列命中 */
/* R1 */ a { color: black; }                    /* (0,0,1) */
/* R2 */ .link { color: gray; }                 /* (0,1,0) */
/* R3 */ .link.active { color: blue; }           /* (0,2,0) */
/* R4 */ a.active:hover { color: red; }          /* (0,2,1) 仅 hover 时 */
/* 静止时:R3 生效(蓝色),(0,2,0) 最高。
   悬停时:R4 生效(红色),(0,2,1) > (0,2,0)。 */

属性选择器的进阶妙用

cssCode
/* 用 attr() 把属性值渲染成内容(提示、计数) */
[data-count]::after { content: " (" attr(data-count) ")"; }
[data-tooltip]:hover::after {
  content: attr(data-tooltip);
  position: absolute;
  /* ...气泡样式 */
}

/* 根据外链类型自动加图标 */
a[href^="mailto:"]::before { content: "✉ "; }
a[href^="tel:"]::before    { content: "☎ "; }
a[href$=".pdf"]::after     { content: " [PDF]"; color: #dc3545; }
a[target="_blank"]::after  { content: " ↗"; }

/* 表单必填标记 */
label:has(+ input:required)::after,
input:required + label::after { content: " *"; color: #dc3545; }

/* 多值属性匹配(class 是空格列表) */
[class~="urgent"] { border-left: 3px solid red; }  /* 等价 .urgent */

选择器与 CSS 变量的作用域联动

选择器决定了 CSS 变量在哪里"落地",二者配合能做出优雅的上下文样式。

cssCode
/* 在选择器命中的作用域内重定义变量,子树整体响应 */
.theme-accent {
  --color-primary: #7c3aed;   /* 该子树内主色变紫 */
}
.theme-accent .btn { background: var(--color-primary); }

/* 用状态伪类切换变量值 */
.card { --lift: 0; transition: transform 0.2s; transform: translateY(var(--lift)); }
.card:hover { --lift: -4px; }

/* 用属性选择器切换整套令牌(多尺寸组件) */
.btn[data-size="sm"] { --btn-pad: 4px 8px; --btn-font: 12px; }
.btn[data-size="md"] { --btn-pad: 8px 16px; --btn-font: 14px; }
.btn[data-size="lg"] { --btn-pad: 12px 24px; --btn-font: 16px; }
.btn { padding: var(--btn-pad); font-size: var(--btn-font); }

这个模式的价值:不用为每个尺寸写一套完整样式,只切换变量,组件主体样式复用。选择器负责"在什么条件下",变量负责"取什么值",职责清晰。

国际化中的选择器

多语言项目里,`:lang()`、`:dir()` 和 `[lang|=...]` 能优雅处理语言差异。

cssCode
/* :lang() 懂继承:即使子元素没标 lang,也能从祖先继承判定 */
:lang(zh) { font-family: "Noto Sans SC", sans-serif; }
:lang(ja) { font-family: "Noto Sans JP", sans-serif; }
:lang(ar) { font-family: "Noto Naskh Arabic", serif; direction: rtl; }

/* :dir() 按书写方向翻转图标 */
:dir(rtl) .icon-arrow { transform: scaleX(-1); }

/* 不同语言的引号样式 */
:lang(zh) q { quotes: "“" "”"; }
:lang(fr) q { quotes: "« " " »"; }

/* 逻辑属性配合,天然适配 RTL(配合选择器命中) */
.card { padding-inline-start: 16px; }  /* LTR 左内边距,RTL 自动右 */

选择器常见误区快查

把最容易犯的错集中列出,写代码时对照自查。

cssCode
/* 误区 1:想选第一段却用 :first-child(前面有标题就失效) */
p:first-child   { }   /* ✗ 常常不命中 */
p:first-of-type { }   /* ✓ */

/* 误区 2:.a .b 与 .a.b 混淆 */
.btn .icon { }   /* .btn 后代里的 .icon */
.btn.icon  { }   /* 同时有 .btn 和 .icon */

/* 误区 3::not() 里放 ID 意外拔高特异性 */
div:not(#x) { }  /* 特异性含 ID = (1,0,1) */

/* 误区 4:忘记 content,伪元素不生成 */
.badge::after { }               /* ✗ 无 content 不显示 */
.badge::after { content: ""; }  /* ✓ */

/* 误区 5::hover 写在 :visited 前被覆盖(LVHA) */

/* 误区 6:用 * 做关键选择器,性能与可读性双输 */
.container * { }   /* ✗ 宽泛 */
.container > .item { }  /* ✓ 关键选择器更具体 */

/* 误区 7::empty 对含空格/换行的元素不生效(那算有文本节点) */
p:empty { display: none; }   /* <p> </p> 里有空格就不命中 */

/* 误区 8:属性值大小写敏感,需要时加 i 标志 */
[data-x="ADMIN"]   { }   /* 精确匹配,区分大小写 */
[data-x="admin" i] { }   /* 忽略大小写 */

选择器学习路线小结

给不同阶段的开发者一个进阶清单:

| 阶段 | 应掌握 |

|---|---|

| 入门 | 元素/类/ID、后代/子、基础伪类(hover/focus) |

| 进阶 | 属性选择器、结构伪类、nth-child 表达式、特异性计算 |

| 熟练 | :is/:where/:not、@layer、data-*/aria 状态驱动 |

| 精通 | :has 关系选择器、of S 语法、性能与可维护性权衡 |

总结

| 场景 | 推荐选择器写法 |

| --- | --- |

| 组件通用样式 | 单类 BEM(.block__el--mod) |

| 状态切换 | 伪类 :hover/:checked 或 [data-state] |

| 根据后代反选父级 | :has() |

| 一次匹配多个选择器 | :is()(提优先级)/ :where()(0 优先级) |

| 斑马纹/隔行 | :nth-child(even/odd) |

| "同类型第 n 个" | :nth-of-type(n) |

| 过滤后再计数 | :nth-child(An+B of S) |

| 装饰性内容 | ::before / ::after + content |

| 表单实时校验 | :user-invalid / :valid + 兄弟选择器 |

| 焦点可访问 | :focus-visible / :focus-within |

| 排除某些元素 | :not()(注意特异性) |

| 按语言/方向 | :lang() / :dir() |

| 控制整体覆盖顺序 | @layer 级联层 |

选择器能力速记表:

| 我想…… | 用什么 |

| --- | --- |

| 选同时有两个类的 | `.a.b` |

| 选 a 里面的 b | `.a .b` |

| 选直接子级 | `>` |

| 选紧邻的下一个 | `+` |

| 选后面所有兄弟 | `~` |

| 给间隔加边距 | ` + ` |

| 根据内容改父样式 | `:has()` |

| 写不易覆盖不了的默认值 | `:where()` |

| 提高一组选择器优先级 | `:is()` |

一句话记忆:选择器越精准越强大,也越难被覆盖。好的样式架构追求的是"优先级尽量扁平、可预测",让覆盖靠源码顺序和分层,而不是靠越写越重的选择器和 `!important`。

从更高的视角看,选择器不只是"选中谁"的工具,它还是样式与结构、状态、语义之间的契约:用类表达组件、用伪类表达状态、用属性选择器表达数据、用 `:has()` 表达关系、用 `@layer` 表达优先级意图。当每一种意图都用最贴切的选择器表达时,样式表就会变得自解释、可预测、易维护——这才是掌握选择器的终极意义。