掌握 CSS :has() 伪类用法 – wiki基地


揭秘 CSS 神器 :has() 伪类:从入门到精通,彻底掌握其强大用法

在 Web 开发的演进历程中,CSS 一直在不断突破自身的边界,为开发者提供更强大、更灵活的样式控制能力。然而,长久以来,CSS 在选择元素时存在一个显著的限制:它只能基于元素的自身属性、ID、类名、在 DOM 树中的位置(父子、兄弟等)来选择元素,但却无法直接根据其 子元素或后代元素 的存在或状态来选择 父元素或祖先元素。这种“自下而上”的选择能力缺失,导致许多需要检查内容状态的场景不得不依赖 JavaScript。

直到 CSS Selectors Level 4 规范引入了一个革命性的伪类——:has()

:has() 的出现,彻底改变了这一局面。它赋予了 CSS 前所未有的“向上看”的能力,允许我们根据一个元素的后代元素(或甚至相邻兄弟元素)来选择该元素本身。这不仅仅是增加了一个新的选择器,它更像是在 CSS 的工具箱里添置了一件“神器”,解锁了无数之前难以用纯 CSS 实现的布局和样式控制的可能性。

本文将带你深入探索 :has() 伪类,从最基础的语法到各种强大的应用场景,包括性能考量、浏览器支持以及一些潜在的限制。读完本文,你将能够彻底掌握 :has() 的精髓,并在你的项目中充分发挥其潜力。

第一部分:初识 :has() – 它是做什么的?

1.1 :has() 的核心概念

简单来说,:has() 伪类是一个 关系型伪类 (relational pseudo-class)。它的基本作用是检查一个元素(即 :has() 前面的选择器所匹配的元素)内部是否包含了符合特定条件的后代元素,或者在特定关系下是否存在满足条件的元素(如紧邻的兄弟)。如果检查结果为真,那么 :has() 前面的那个元素就会被选中并应用样式。

因为最常见的用途是根据子元素来选择父元素,:has() 也常被开发者们亲切地称为“父选择器”。但这只是其能力的一个子集,它的真正力量远不止于此。

1.2 基本语法

:has() 的基本语法如下:

css
selector:has(relative-selector) {
/* styles to apply to 'selector' */
}

  • selector: 这是你想要应用样式的元素选择器。例如,一个 div,一个 article,或者一个类名 .card
  • relative-selector: 这是 :has() 括号内的部分。它描述了你正在寻找的后代元素或相关元素。这个 relative-selector 可以是一个或多个简单选择器、组合器、伪类等的组合。重要的是,这个选择器会相对于 selector 所匹配的元素进行匹配。

工作原理示意:

当浏览器遇到 selector:has(relative-selector) 时,它会:
1. 找到所有匹配 selector 的元素。
2. 对于每一个匹配到的 selector 元素,浏览器会检查其内部(或根据 relative-selector 的指示检查其他相关位置)是否存在匹配 relative-selector 的元素。
3. 如果在一个 selector 元素的检查过程中找到了匹配 relative-selector 的元素,那么这个 selector 元素就会被最终选中,并应用后面的样式规则。

1.3 简单示例:“如果 div 包含一个 p 元素,就选中这个 div

这是 :has() 最直观的用法之一。

“`html

这是一个段落。

这是一个 span。

“`

css
.container:has(p) {
border: 2px solid blue;
padding: 10px;
}

在这个例子中:
* .container:has(p) 规则会找到所有类名为 containerdiv
* 然后,对于每一个这样的 div,它会检查其内部是否存在一个 p 元素。
* 第一个 .container 包含了一个 p 元素,所以它会被选中,并应用蓝色边框和内边距。
* 第二个 .container 不包含 p 元素(它包含一个 span),所以它不会被选中。

最终效果是只有第一个 div 会有蓝色边框。

第二部分:深入理解 relative-selector

:has() 的强大之处很大程度上取决于其括号内 relative-selector 的灵活性。这个 relative-selector 可以非常复杂,不仅仅局限于一个简单的标签名或类名。

2.1 relative-selector 可以是各种选择器

你可以在 relative-selector 中使用几乎所有常规的 CSS 选择器:

  • 类型选择器: div:has(p)
  • 类选择器: article:has(.highlight) (选中包含 .highlight 元素的 article)
  • ID 选择器: section:has(#main-title) (选中包含 #main-title 元素的 section)
  • 属性选择器: a:has([target="_blank"]) (选中包含带有 target="_blank" 属性的元素的 a)
  • 伪类: input:has(:checked) (选中包含被选中的元素的 input,例如 <input type="checkbox"> 的父级 divlabel),ul:has(:empty) (选中内容为空的 ul),div:has(:first-child) (选中第一个子元素是任何元素的 div)
  • 复合选择器: div:has(img.thumbnail) (选中包含类名为 thumbnailimg 元素的 div)

示例:基于复选框状态改变父容器样式

“`html

“`

“`css
.option {
border: 1px solid #ccc;
padding: 10px;
margin-bottom: 10px;
transition: border-color 0.3s;
}

.option:has(input:checked) {
border-color: green; / 如果包含一个被选中的 input,父级 .option 边框变绿 /
}
“`

这个例子中,当复选框被选中时,它所属的 .option 容器的边框会变成绿色,非常适合用来表示某个选项被激活的状态。

2.2 relative-selector 可以包含组合器

这是 :has() 变得真正强大的地方。你可以在 relative-selector 中使用子代组合器 (>), 相邻兄弟组合器 (+), 通用兄弟组合器 (~), 甚至后代组合器(空格)。

a) 子代组合器 (>):检查直接子元素

selector:has(> relative-selector) 会检查 selector 元素的 直接子元素 是否匹配 relative-selector

“`html

直接子元素段落

嵌套子元素段落

只有嵌套子元素

“`

css
.parent:has(> p) {
background-color: yellow; /* 如果直接子元素是 p,背景变黄 */
}

这里,只有第一个 .parent 会被选中,因为它有一个直接子元素 <p>。第二个 .parent 不会被选中,因为它的 <p> 是嵌套在 div 里的,不是直接子元素。

b) 相邻兄弟组合器 (+) 和 通用兄弟组合器 (~):出乎意料的用法!

这可能是 :has() 中最令人兴奋(也最容易混淆)的部分。当你在 relative-selector 中使用 +~ 时,它们并非用来选择 :has() 元素本身的兄弟,而是用来描述 relative-selector 所寻找的元素与其 紧邻的前一个兄弟之前的兄弟 之间的关系。

selectorA:has(+ selectorB):这会选中 selectorA,如果它紧接着一个 selectorB
selectorA:has(~ selectorB):这会选中 selectorA,如果它后面有任何一个兄弟元素是 selectorB

这 effectively 实现了之前 CSS 无法做到的事情:根据后面的兄弟元素来选择前面的兄弟元素!

示例:根据后面的标题调整前面的元素样式

假设你有一个布局,在某些部分标题后面跟着一个特定的内容块,你想给这个标题添加一个底部边距,以便和后面的内容块区分开。

“`html

标题 A

内容块 A

标题 B

普通段落 B

标题 C

内容块 C

“`

css
h2:has(+ .content-block) {
margin-bottom: 15px; /* 如果 h2 后面紧跟着一个 .content-block,h2 就有底部边距 */
}

在这个例子中:
* h2:has(+ .content-block) 规则会找到所有的 h2 元素。
* 对于每一个 h2,它会检查其紧邻的下一个兄弟元素是否是 .content-block
* 第一个 h2 后面的兄弟是 .content-block,所以第一个 h2 被选中并添加了底部边距。
* 第二个 h2 后面的兄弟是 <p>,不匹配,所以第二个 h2 不被选中。
* 第三个 h2 后面的兄弟是 .content-block,所以第三个 h2 被选中并添加了底部边距。

这是 +~:has() 中非常强大且独特的用法,它让我们可以实现很多基于兄弟元素前后关系的样式控制,而无需改变 HTML 结构或使用 JavaScript。

c) 后代组合器 (空格):默认行为

relative-selector 以非组合器开头时(如 div:has(p)),它默认就使用了后代组合器。div:has(p) 等同于 div:has( p) (注意 p 前面的空格)。这会选中 div,只要其内部任何层级的后代中包含一个 p 元素。

2.3 复杂的 relative-selector

relative-selector 本身可以是一个复杂的选择器列表,用逗号分隔。如果 selector 元素内部包含任何一个匹配 relative-selector 列表中任意一项的元素,selector 就会被选中。

css
div:has(p, span.highlight, > img) {
/* 选中包含 p 元素、或包含类名为 highlight 的 span 元素、或包含直接子元素 img 的 div */
border: 1px dashed purple;
}

2.4 :has() 内部的 :has() ?

规范允许 :has() 嵌套,但实际应用中需要谨慎。

css
div:has(ul:has(li:first-child)) {
/* 选中包含一个 ul,且该 ul 的第一个子元素是 li 的 div */
}

这种嵌套 :has() 的可读性较差,也可能对性能有影响,但在某些特定、复杂的结构检查中可能是必需的。

第三部分:强大的应用场景示例

:has() 的能力解放了 CSS,让我们可以用更声明式的方式解决许多布局和交互问题。以下是一些常见的强大应用场景:

3.1 根据内容调整父容器样式

  • 卡片组件的变化:
    “`html

    标题

    一段描述。

    标题

    图片

    包含图片的卡片。

    标题

    一段描述。

    css
    .card {
    border: 1px solid #ccc;
    padding: 15px;
    margin: 10px;
    background-color: #fff;
    }

    .card:has(img) {
    background-color: #f0f0f0; / 如果卡片有图片,背景变灰 /
    }

    .card:has(.card-footer) {
    padding-bottom: 0; / 如果卡片有 footer,移除底部内边距 /
    }

    / 进一步,针对有 footer 的卡片,给 footer 上方添加边框 /
    .card:has(.card-footer) .card-footer {
    border-top: 1px solid #eee;
    padding-top: 10px;
    margin-top: 10px;
    }
    * **空状态提示:**html

    css
    .item-list:has(:empty) {
    / 或者更精确地检查是否有子元素但不包含特定的列表项 /
    / .item-list:not(:has()) 或 .item-list:has(> :only-child:empty) 如果希望空 div 里有内容也算非空 /
    min-height: 100px; /
    确保空列表容器有一定高度 */
    display: flex;
    align-items: center;
    justify-content: center;
    color: #888;
    text-align: center;
    }

    .item-list:has(:empty)::before {
    content: “暂无数据”; / 利用伪元素显示空状态文本 /
    }

    / 如果不为空,移除伪元素 /
    .item-list:not(:has(:empty))::before {
    content: none;
    }
    “`
    这提供了一种纯 CSS 的空状态处理方式,无需 JavaScript 检查列表是否为空并添加特定的 class。

3.2 表单验证反馈

根据表单输入元素的状态来样式化其相关的容器或标签。

“`html





邮箱格式不正确

“`

“`css
.form-field {
margin-bottom: 15px;
padding: 10px;
border: 1px solid transparent;
}

/ 如果 field 包含一个无效的 input /
.form-field:has(input:invalid) {
border-color: red;
background-color: #ffebeb;
}

/ 如果 field 包含一个无效的 input,显示错误信息 /
.form-field:has(input:invalid) .error-message {
display: inline-block;
color: red;
font-size: 0.9em;
margin-top: 5px;
}

/ 默认隐藏错误信息 /
.form-field .error-message {
display: none;
}

/ 如果 input 有 :required 属性,且紧邻的兄弟是 span.error-message /
/ 注意这里的 :has() 内部的组合器用法 /
label:has(+ input:required) {
font-weight: bold; / 给必填项的 label 加粗 /
}
``
这个例子展示了如何根据输入框的
:invalid:required状态来改变整个表单字段容器的样式,以及如何使用+:has()内部根据后续兄弟元素 (input:required) 的存在来样式化前面的兄弟元素 (label`)。

3.3 导航菜单和下拉菜单

根据列表项是否包含嵌套列表来样式化它。

“`html

“`

“`css
.menu li {
list-style: none;
margin-right: 20px;
display: inline-block;
position: relative; / 用于定位子菜单 /
}

/ 如果 li 包含 ul (子菜单) /
.menu li:has(ul) > a {
/ 给包含子菜单的 li 里面的 a 元素添加一个指示符 /
padding-right: 15px;
position: relative;
}

.menu li:has(ul) > a::after {
content: ‘▼’; / 或者其他图标 /
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
font-size: 0.8em;
}

/ 控制子菜单的显示/隐藏 /
.menu ul.submenu {
display: none; / 默认隐藏子菜单 /
position: absolute;
top: 100%;
left: 0;
background-color: #fff;
border: 1px solid #ccc;
padding: 10px;
z-index: 10;
min-width: 150px;
}

/ 当鼠标悬停在包含子菜单的 li 上时,显示子菜单 /
.menu li:has(ul):hover > ul.submenu {
display: block;
}
``
这里,
:has(ul)选中了包含ul.submenuli元素。我们不仅用它来给li中的a元素添加指示符,还结合:hover` 伪类实现了纯 CSS 的下拉菜单显示/隐藏效果。

3.4 基于兄弟元素的存在调整布局 (实现“前面的兄弟选择器”)

前面已经提到了 h2:has(+ .content-block) 的例子,再看一个更复杂的。

假设你有一个列表,如果某个列表项后面跟着一个特定的提示信息块,你想给这个列表项底部添加更多空间。

“`html

  • 列表项 1
  • 列表项 2
  • 列表项 3 (带注意)
  • 注意:这是关于列表项 3 的提示
  • 列表项 4

“`

“`css
li {
margin-bottom: 5px;
}

/ 选中 class 为 item-with-note 的 li,如果它后面紧跟着一个 class 为 note 的兄弟元素 /
li.item-with-note:has(+ li.note) {
margin-bottom: 20px; / 增加底部空间 /
}
“`
这完美地解决了根据 后面 的元素来影响 前面 元素样式的难题。

3.5 网格布局变化

根据网格项的数量或特定子项的存在来调整网格容器的样式。

“`html

1
2
3
4
1
3

“`

“`css
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 10px;
}

/ 如果 grid-container 包含 4 个或更多直接子元素 .grid-item /
/ 使用 :nth-last-child 和 :first-child 或 :last-child 结合 /
.grid-container:has(> .grid-item:nth-last-child(n+4)) {
/ 这个选择器有点复杂,它选中 .grid-container 如果它的第四个倒数子元素是 .grid-item。
这意味着它至少有 4 个 .grid-item 子元素。
/
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); / 网格列变宽 /
}

/ 如果 grid-container 包含一个 class 为 featured 的 .grid-item /
.grid-container:has(.grid-item.featured) {
gap: 20px; / 增加网格间距 /
border: 2px dashed orange;
}
“`
这个例子展示了如何根据子元素的数量(虽然选择器写法略微绕弯)或子元素的特定属性来调整父级网格容器的布局。

3.6 结合 :not() 使用

:has() 可以与 :not() 结合,实现“不包含”或“不符合条件”的样式。

  • div:not(:has(img)):选中不包含 img 元素的 div
  • ul:has(:not(li)):选中包含非 li 元素的 ul (比如混入了 divspan)。
  • article:has(p:not(:first-child)): 选中包含一个 p 元素,且这个 p 元素不是其父元素的第一个子元素的 article

示例:样式化没有图片的文章

“`html

文章 A

内容…

文章 B

图片

包含图片的文章…

“`

“`css
article {
border: 1px solid #ccc;
padding: 15px;
margin-bottom: 20px;
}

article:not(:has(img)) {
background-color: #e9e9e9; / 没有图片的文章背景变灰 /
}
“`

第四部分:性能考量与浏览器支持

4.1 性能

:has() 伪类的性能是一个重要的讨论点。它的实现确实比简单的选择器(如类选择器或ID选择器)更复杂,因为它需要浏览器进行“回溯”检查——在匹配到潜在的父元素后,还需要在其子树中查找特定的后代。

  • 早期担忧::has() 还在草案阶段或刚刚实现时,确实存在性能上的担忧,尤其是在大型、复杂的 DOM 结构上使用复杂的 :has() 选择器。
  • 现代浏览器优化: 然而,现代浏览器引擎(如 Blink, Gecko, WebKit)在过去几年里对 :has() 进行了大量的优化。它们采用了高效的算法来处理这种类型的选择器,很多情况下,:has() 的性能开销已经可以忽略不计,尤其是在处理用户交互相关的样式或页面加载时的布局。
  • 复杂性影响: 尽管如此,relative-selector 的复杂性仍然会影响性能。
    • 使用子代选择器 (>) 通常比使用后代选择器 (空格) 更快,因为搜索范围更小。div:has(> .item) 可能比 div:has(.item) 快。
    • 使用复杂的、深层嵌套的 :has() 选择器,或者在频繁变化的 DOM 上频繁应用 :has() 可能会有性能影响。
    • 在大型列表或网格的每个项目上使用 :has() 来检查其内部复杂状态,可能会比在整个容器上使用 :has() 然后再选择内部元素开销更大。
  • 最佳实践:
    • 避免在页面的根元素 (html, body) 上使用过于复杂的 :has() 选择器,因为它会强制对整个 DOM 树进行检查。
    • 尽可能缩小 :has() 前面 selector 的范围(例如,使用特定的类名 .card:has(...) 而不是通用的 div:has(...))。
    • relative-selector 越简单越好,优先使用子代组合器 (>) 而非后代组合器 (空格)。
    • 在对性能极其敏感的应用中,如果某个效果可以用 :has() 或少量 JavaScript 实现,可以进行性能测试比较。但在绝大多数 Web 开发场景下,:has() 的性能已经足够好。

4.2 浏览器支持

:has() 伪类在现代主流浏览器中已经获得了广泛的支持。

  • Chrome: 105+
  • Firefox: 104+
  • Safari: 15.4+
  • Edge: 105+

这意味着绝大多数使用最新版浏览器的用户都可以正常体验 :has() 带来的效果。对于需要兼容旧版浏览器的项目,你可能需要提供回退方案(例如,使用 JavaScript 或者不同的 CSS 方法),或者在使用前进行浏览器能力检测。

你可以访问 Can I Use 网站查看最新的详细支持情况。

第五部分:限制与注意事项

尽管 :has() 功能强大,但它并非万能,也有一些限制:

  • 不能使用伪元素: :has()relative-selector 中不能直接包含伪元素(如 ::before, ::after, ::first-line, ::marker 等)。你不能写 div:has(::before) 来选中包含伪元素的 div。这是因为伪元素不是 DOM 树中的真实元素。如果你需要根据伪元素的存在或状态来影响样式,通常需要通过 JavaScript 修改元素的 class 或属性,然后用 CSS 选择这个修改后的元素。
  • 不能匹配根元素/文档元素: 规范中通常不允许 :has() 用于匹配文档树的根元素 (html) 或其直接子元素 (body) 作为 :has()relative-selector 的起点。例如,html:has(body > .wrapper) 可能是有效的,但 div:has(html)div:has(body) 是无效的。
  • 复杂性与可读性: 虽然功能强大,但过于复杂的 :has() 选择器可能会降低 CSS 的可读性和维护性。适度使用并配合清晰的注释是很重要的。
  • Specificity (特指度): :has() 伪类的特指度计算方式是:它自身的特指度为 0,但会包含其 relative-selector最高特指度的选择器 的特指度。例如:
    • div:has(p) 的特指度是 div (0,0,1) + p (0,0,1) = (0,0,2)
    • .card:has(img) 的特指度是 .card (0,1,0) + img (0,0,1) = (0,1,1)
    • .container:has(div#header) 的特指度是 .container (0,1,0) + #header (1,0,0) = (1,1,0)
      理解这一点对于处理样式冲突和覆盖非常重要。

第六部分::has() 与 JavaScript 的对比

:has() 出现之前,很多需要基于子元素状态或兄弟元素前后关系来修改父元素或前面兄弟元素样式的场景,都不得不依赖 JavaScript。例如:

  • 检查表单字段是否有错误,然后给父容器添加 .error class。
  • 检查列表是否为空,然后添加 .empty class 并显示提示。
  • 检查一个元素后面是否有特定的兄弟元素,然后给这个元素添加 class。

使用 :has() 的优势在于:

  • 纯 CSS 实现: 无需 JavaScript 代码,降低了脚本的复杂性、提高了加载速度,并且可以在 DOM 加载和 CSS 解析完成后立即应用样式,避免了可能的闪烁。
  • 声明式: CSS 是一种声明式语言,:has() 使得样式规则更能直接反映元素的结构和状态,而不是通过 JavaScript 来命令式地修改 DOM 或 class。
  • 性能 (现代浏览器): 对于很多常见场景,:has() 在现代浏览器中的性能已经非常优秀,可能比遍历 DOM 并修改 class 的 JavaScript 代码更高效。
  • 浏览器负责: 样式计算和应用由浏览器高性能的渲染引擎负责,而非依赖 JavaScript 引擎。

当然,JavaScript 仍然是处理复杂交互、动态数据和业务逻辑不可或缺的工具。:has() 只是在 基于 DOM 结构和元素状态进行样式控制 这一特定领域,提供了一个更优雅、更高效的纯 CSS 解决方案。

第七部分:未来展望

:has() 伪类的引入是 CSS 选择器能力的一次巨大飞跃。它不仅解决了长期存在的“父选择器”问题,还以一种通用的方式增强了 CSS 选择器表达元素之间关系的能力。

可以预见,未来开发者会创造出更多基于 :has() 的创新用法,简化 CSS 代码,减少对 JavaScript 的依赖。它为构建更具响应性、更易维护和性能更好的 Web 界面提供了新的可能性。

结论

CSS :has() 伪类是现代 CSS 中一个极其强大且实用的工具。它通过引入基于后代或其他相关元素来选择当前元素的能力,弥补了 CSS 长期以来在选择器方面的不足。

无论是根据子元素的存在或状态来样式化父容器,还是根据后面的兄弟元素来影响前面的元素,:has() 都提供了简洁高效的纯 CSS 解决方案。掌握 :has() 将显著提升你的 CSS 技能,让你能够编写更强大、更具表达力且更易维护的样式代码。

虽然需要注意其在复杂场景下的性能以及一些特定的限制,但在大多数日常开发任务中,:has() 已经是一个可以放心使用并且能带来巨大便利的“神器”。

现在,是时候在你的项目中尝试使用 :has(),亲自体验它带来的改变了!


发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部