:has() 作为 CSS 父选择器及更多用法

长期以来,前端开发者一直梦想着能有一种方法,根据元素内部发生的情况来为该元素应用 CSS 样式。

也许我们想在文章元素顶部有英雄图像时应用一种布局,没有英雄图像时应用另一种布局。或者我们可能想根据表单中某个输入字段的状态来应用不同的样式。如果侧边栏中包含某个特定组件,给它一种背景颜色,如果该组件不存在,则给它另一种背景颜色,这又如何呢?像这样的用例已经存在很长时间了,Web 开发者们曾多次向 CSS 工作组提出请求,恳求他们发明一个“父选择器”。

在过去的 二十年里,CSS 工作组多次讨论了这种可能性。需求清晰且易于理解。定义语法是一项可行任务。但要弄清楚浏览器引擎如何处理潜在的非常复杂的循环模式,并足够快地完成计算,似乎是不可能。父选择器的早期版本曾为 CSS3 起草,但最终被推迟。最终,:has() 伪类在 CSS Selectors level 4 中正式定义。但仅仅有一个 Web 标准并不能使 :has() 成为现实。我们仍然需要一个浏览器团队来解决实际存在的性能挑战。与此同时,计算机每年都在变得更强大、更快。

2021 年,Igalia 开始在浏览器工程团队中倡导 :has()原型化他们的想法并记录他们关于性能的发现。对 :has() 的重新关注引起了 Apple WebKit 工程师的注意。我们开始实现这个伪类,思考如何进行必要的性能增强来使其工作。我们曾争论是先从一个功能非常有限的更快版本开始,然后尽可能地移除这些限制…还是从一个没有限制的版本开始,只在需要时才施加限制。我们选择了后者,并实现了功能更强大的版本。我们开发了许多新颖的 :has 特定的缓存和过滤优化,并利用了我们 CSS 引擎现有的高级优化策略。我们的方法奏效了,证明经过二十年的等待,终于有可能以卓越的性能实现这样的选择器,即使在大型 DOM 树和大量 :has() 选择器存在的情况下。

WebKit 团队于 2021 年 12 月在 Safari Technology Preview 137 中发布了 :has(),并于 2022 年 3 月 14 日在 Safari 15.4 中发布。Igalia 负责在 Chromium 中实现 :has() 的工程工作,该功能将于 2022 年 8 月 30 日随 Chrome 105 发布。据推测,基于 Chromium 的其他浏览器也将紧随其后。Mozilla 目前正在开发 Firefox 的实现。

那么,让我们一步步亲身体验一下 Web 开发者可以使用这个渴望已久的工具做些什么。事实证明,:has() 伪类不仅仅是一个“父选择器”。经过数十年的僵局,这个选择器能做的远不止这些。

将 :has() 用作父选择器的基础知识

让我们从基础开始。想象一下,我们想根据 <figure> 元素中内容的类型来样式化它。有时我们的 figure 只包含一个图像。

<figure>
  <img src="flowers.jpg" alt="spring flowers">
</figure>

而有时则是一个带标题的图像。

<figure>
  <img src="dog.jpg" alt="black dog smiling in the sun">
  <figcaption>Maggie loves being outside off-leash.</figcaption>
</figure>

现在,让我们为 figure 元素应用一些样式,这些样式只在 figure 内部有 figcaption 时才生效。

figure:has(figcaption) {
  background: white;
  padding: 0.6rem;
}

这个选择器名副其实——任何内部包含 figcaptionfigure 元素都将被选中。

这是演示,如果您想修改代码并查看效果。请确保使用支持 :has() 的浏览器——截至目前,是 Safari。

查看 Pen
:has() 演示 — Figure 变体
作者:Jen Simmons (@jensimmons)
CodePen 上。

在此演示中,我还通过使用 figure:has(pre) 来定位任何包含 pre 元素的 figure

figure:has(pre) { 
  background: rgb(252, 232, 255);
  border: 3px solid white;
  padding: 1rem;
}

并且我使用选择器特性查询 (Selector Feature Query) 来隐藏关于浏览器支持的提醒,只要当前浏览器支持 :has()

@supports selector(:has(img)) {
  small {
    display: none;
  }
}

@supports selector() at-rule 本身支持良好。每当您想使用特性查询来测试浏览器对特定选择器的支持时,它都非常有用。

最后,在这个第一个演示中,我还使用 :not() 伪类编写了一个复杂选择器。我希望将 display: flex 应用到 figure——但仅当图像是唯一内容时。Flexbox 使图像拉伸以填充所有可用空间。

我使用一个选择器来定位任何包含任何图像元素的 figure。如果 figure 包含 figcaptionpreph1——或者除了 img 之外的任何元素——那么该选择器就不适用。

figure:not(:has(:not(img))) {
  display: flex;
}

:has() 是一个强大的东西。

使用 :has() 与 CSS Grid 的实际示例

我们来看第二个演示,其中我使用了 :has() 作为父选择器,轻松解决了一个非常实际的需求。

我有几张文章预告卡,使用 CSS Grid 进行布局。有些卡片只包含标题和文本,而另一些则包含图像。我希望带有图像的卡片在网格中占用比没有图像的卡片更多的空间。

我不想为了让我的内容管理系统应用类名或使用 JavaScript 进行布局而做额外的工作。我只想在 CSS 中编写一个简单的选择器,告诉浏览器让任何带有图像的预告卡在网格中占据两行两列的空间。

:has() 伪类使这变得简单

article:has(img) {
  grid-column: span 2;
  grid-row: span 2;
}

查看 Pen
:has() 演示 — 预告卡
作者:Jen Simmons (@jensimmons)
CodePen 上。

前两个演示使用了 CSS 早期简单的元素选择器,但所有选择器都可以与 :has() 结合使用,包括类选择器ID 选择器属性选择器——以及强大的组合器。

使用 :has() 与子组合器

首先,快速回顾一下后代组合器子组合器 (>) 之间的区别。

后代组合器从 CSS 的诞生之初就已存在。当我们把一个空格放在两个简单选择器之间时,它就是这个花哨的名字。像这样

a img { ... }

这会定位所有包含在 a 元素内的 img 元素,无论 aimg 在 HTML DOM 树中相隔多远。

<a>
  <figure>
    <img src="photo.jpg" alt="don't forget alt text" width="200" height="100">
  </figure>
</a>

子组合器是我们把 > 放在两个选择器之间时的名称——它告诉浏览器定位任何与第二个选择器匹配的元素,但仅当第二个选择器是第一个选择器的直接子元素时。

a > img { ... }

例如,这个选择器会定位所有被 a 元素包裹的 img 元素,但仅当 img 在 HTML 中紧跟在 a 之后时。

<a>
  <img src="photo.jpg" alt="don't forget alt text" width="200" height="100">
</a>

考虑到这一点,让我们思考下面两个示例之间的区别。两者都选择了 a 元素,而不是 img,因为我们使用的是 :has()

a:has(img) { ... }
a:has(> img) { ... }

第一个选择任何内部有 imga 元素——在 HTML 结构中的任何位置。而第二个只在 imga 的直接子元素时才选择该元素。

两者都有用;它们实现了不同的功能。

查看 Pen
:has() — 后代组合器 vs 子组合器
作者:Jen Simmons (@jensimmons)
CodePen 上。

还有两种额外的组合器类型——都是兄弟组合器。正是通过它们,:has() 不再仅仅是一个父选择器。

使用 :has() 与兄弟组合器

让我们回顾一下两种具有兄弟关系的选择器。有相邻兄弟组合器 (+) 和通用兄弟组合器 (~)。

相邻兄弟组合器 (+) 只选择紧接h2 元素之后的段落。

h2 + p
<h2>Headline</h2>
<p>Paragraph that is selected by `h2 + p`, because it's directly after `h2`.</p>

通用兄弟组合器 (~) 选择所有在 h2 元素之后的段落。它们必须是兄弟元素,但其间可以有任意数量的其他 HTML 元素。

h2 ~ p
<h2>Headline</h2>
<h3>Something else</h3>
<p>Paragraph that is selected by `h2 ~ p`.</p>
<p>This paragraph is also selected.</p>

请注意,h2 + ph2 ~ p 都选择段落元素,而不是 h2 标题。像其他选择器(想想 a img),选择器定位的是列表中最后一个元素。但如果我们想定位 h2 怎么办?我们可以将兄弟组合器与 :has() 结合使用。

您有多少次想根据标题后面紧跟的元素来调整标题的边距?现在这很容易了。这段代码允许我们选择任何紧跟 p 元素的 h2

h2:has(+ p) { margin-bottom: 0; }

太棒了。

如果我们想对所有六个标题元素都这样做,而不需要写六份选择器代码呢?我们可以使用 :is 来简化我们的代码。

:is(h1, h2, h3, h4, h5, h6):has(+ p) { margin-bottom: 0; }

或者,如果我们想为更多元素(而不仅仅是段落)编写这段代码呢?让我们消除所有标题的下边距,只要它们后面跟着段落、标题、代码示例和列表。

:is(h1, h2, h3, h4, h5, h6):has(+ :is(p, figcaption, pre, dl, ul, ol)) { margin-bottom: 0; }

:has()后代组合器子组合器 (>)、相邻兄弟组合器 (+) 和通用兄弟组合器 (~) 结合使用,开启了无限可能。但是,这仍然只是一个开始。

无需 JS 即可样式化表单状态

有许多出色的伪类可以在 :has() 内部使用。事实上,它彻底改变了伪类的功能。以前,伪类仅用于根据特殊状态或样式其子元素来样式化元素。现在,伪类可以用于捕获状态,无需 JavaScript,并根据该状态样式化 DOM 中的任何内容。

表单输入字段提供了一种强大的方式来捕获这种状态。特定于表单的伪类包括 :autofill:enabled:disabled:read-only:read-write:placeholder-shown:default:checked:indeterminate:valid:invalid:in-range:out-of-range:required:optional

让我们解决我在介绍中描述的一个用例——长期以来根据输入字段的状态来样式化表单标签的需求。让我们从一个基本的表单开始。

<form>
  <div>
    <label for="name">Name</label> 
    <input type="text" id="name">
  </div>
  <div>
    <label for="site">Website</label> 
    <input type="url" id="site">
  </div>
  <div>
    <label for="email">Email</label>
    <input type="email" id="email">
  </div>
</form>

我希望当其中一个字段获得焦点时,为整个表单应用背景。

form:has(:focus-visible) { 
  background: antiquewhite;
}

现在,我本可以使用 form:focus-within,但它的行为会像 form:has(:focus)。当字段获得焦点时,:focus 伪类总是应用 CSS。而 :focus-visible 伪类提供了一种可靠的方式,仅当浏览器将原生绘制焦点指示器时才为其应用样式,它会使用浏览器用于确定是否应用焦点环的相同复杂启发式算法

现在,让我们想象一下,我希望样式化其他未获得焦点的字段——改变它们的标签文本颜色和输入框边框颜色。在 :has() 出现之前,这需要 JavaScript。现在我们可以使用这段 CSS 代码。

form:has(:focus-visible) div:has(input:not(:focus-visible)) label {
  color: peru;
}
form:has(:focus-visible) div:has(input:not(:focus-visible)) input {
  border: 2px solid peru;
}

这个选择器表达了什么?如果此表单中的一个控件获得焦点,并且此特定表单控件的输入元素没有获得焦点,则将此标签的文本颜色更改为 peru。并将输入字段的边框更改为 2px solid peru

您可以通过点击其中一个文本字段,在以下演示中看到此代码的效果。表单的背景会如我之前所述发生变化。并且未获得焦点的字段的标签和输入边框颜色也会改变。

查看 Pen
:has() 演示:表单
作者:Jen Simmons (@jensimmons)
CodePen 上。

在这个相同的演示中,我还希望改进用户填写表单出错时的警告。多年来,我们一直能够使用以下 CSS 轻松地在无效输入周围添加一个红框。

input:invalid {
  outline: 4px solid red;
  border: 2px solid red;
} 

现在有了 :has(),我们也可以将标签文本变成红色

div:has(input:invalid) label {
  color: red;
}

您可以通过在网站或电子邮件字段中输入非完整 URL 或电子邮件地址的内容来查看结果。两者都无效,因此都会触发红色边框和红色标签,并带有“X”符号。

无需 JS 的深色模式切换

最后,在这个相同的演示中,我使用一个复选框让用户在亮色和深色主题之间切换。

body:has(input[type="checkbox"]:checked) {
  background: blue;
  --primary-color: white;
}
body:has(input[type="checkbox"]:checked) form { 
  border: 4px solid white;
}
body:has(input[type="checkbox"]:checked) form:has(:focus-visible) {
  background: navy;
}
body:has(input[type="checkbox"]:checked) input:focus-visible {
  outline: 4px solid lightsalmon;
}

我使用自定义样式来美化深色模式复选框,但它看起来仍然像一个复选框。如果使用更复杂的样式,我可以通过 CSS 创建一个切换开关

以类似的方式,我可以使用选择菜单为用户提供我网站的多个主题

body:has(option[value="pony"]:checked) {
  --font-family: cursive;
  --text-color: #b10267;
  --body-background: #ee458e;
  --main-background: #f4b6d2;
}

查看 Pen
:has() 演示 #5 — 通过选择器实现主题选择器
作者:Jen Simmons (@jensimmons)
CodePen 上。

任何时候有机会使用 CSS 而非 JavaScript,我都会选择它。这会带来更快的体验和更健壮的网站。JavaScript 可以做很多令人惊叹的事情,当它是完成工作的正确工具时,我们应该使用它。但如果我们仅通过 HTML 和 CSS 就能达到相同的效果,那就更好了。

及更多

查看其他伪类,有许多可以与 :has() 结合使用。想象一下 :nth-child, :nth-last-child, :first-child, :last-child, :only-child, :nth-of-type, :nth-last-of-type, :first-of-type, :last-of-type, :only-of-type 的可能性。全新的 :modal 伪类在 dialog 处于打开状态时触发。使用 :has(:modal),您可以根据 dialog 的打开或关闭状态来样式化 DOM 中的任何内容。

然而,目前并非所有伪类都在所有浏览器中都支持在 :has() 内部使用,因此请务必在多个浏览器中测试您的代码。目前动态媒体伪类不起作用——例如 :playing:paused:muted 等。它们很可能在未来会起作用,所以如果您在未来阅读本文,请测试一下!此外,在某些特定情况下,表单验证支持目前缺失,因此这些伪类的动态状态变化可能不会随 :has() 更新。

Safari 16 将增加对 :has(:target) 的支持,为编写根据当前 URL 查找与特定元素 ID 匹配的片段的代码提供了有趣的可能性。例如,如果用户点击文档顶部的目录,然后跳转到页面中与该链接匹配的部分,:target 提供了一种根据用户点击链接到达该位置的事实来独特地样式化该内容的方法。而 :has() 则拓展了这种样式化能做到的事情。

需要注意的是——CSS 工作组决定不允许在 :has() 内部使用所有现有伪元素。例如,article:has(p::first-line)ol:has(li::marker) 将不起作用。::before::after 也一样。

:has() 革新

这感觉就像一场我们编写 CSS 选择器方式的革命,开启了以前不可能实现或常常不值得付出努力的无限可能性。这感觉就像我们可能立即认识到 :has() 会有多么有用,但我们也不知道真正可能实现什么。在接下来的几年里,那些制作演示并深入研究 CSS 潜力的人将提出令人惊叹的想法,将 :has() 发挥到极致。

Michelle Barker 创建了一个精彩的演示,通过使用 :has() 和悬停状态触发 Grid 轨道尺寸的动画。有关更多信息,请阅读她的博客文章。Safari 16 将支持动画网格轨道。您今天就可以在 Safari Technology Preview 或 Safari 16 beta 中试用此演示。

:has() 最困难的部分将是打开我们的思维去探索其可能性。我们已经习惯了没有父选择器给我们带来的限制。现在,我们必须打破这些习惯。

这更是使用原生 CSS 的理由,不要将自己局限于框架中定义的类。通过为您的项目编写自定义 CSS,您可以充分利用当今浏览器所有强大的功能。

您将如何使用 :has()?去年 12 月,我在 Twitter 上询问大家对 :has() 有哪些用例,收到了许多令人难以置信的想法。我迫不及待地想看到您的想法。