• 作者:老汪软件技巧
  • 发表时间:2024-10-01 00:02
  • 浏览量:

原文链接:The Undeniable Utility Of CSS :has - 原文作者 Josh

本文采用意译的方式

介绍

我不知道你们是否留意到,CSS 的世界最近很火热。

在幕后,所有主流浏览器厂商和 CSS 规范的作者们一直共同努力,推出了很多高需求的 CSS 功能。比如容器查询,原生 CSS 嵌套,相对颜色语法,平衡文本等。

这些新特性中有个 :has 伪类。嗯,老实说,我并不确定它是否对我有帮助。很多的时候,我使用 React 来构建网页应用,这就意味着并不需要使用复杂的选择器。在这个上下文中,:has 伪类是否有所帮助呢?

嗯,我花费了好几个月来重建该博文,使用现代所有的 CSS 特性。天啊,我有些过度担忧 :has。这是一个非常方便的工具,即使在 CSS-in-JS 环境中。

在这篇博文中,我们将向你介绍 :has并分享一些目前我发现的很有趣的真实使用场景,这真是一次令人惊叹的实验。

目标受众

这篇文章是为那些熟悉 CSS 基础知识的开发者准备的,但不需要有关于 :has 的先前经验

部分的内容是由使用框架如 React/Vue/Angular 的 JavaScript 的开发者编写,但是,即使你没有编写过任何的 JS,这篇文章依旧很有用。

基础

历史上,CSS 选择器一直是“自上而下”的方式工作。

比如,通过一个空格来分离多个选择器,我们可以基于其父元素来选择性地为子元素设置样式。

:has 伪选择器是“自下而上”的工作方式,它允许我们基于其子元素设置父元素的样式。

这看起来不是什么大事,但是它为我们打开了一扇很有趣的新门。在过去的这几个月中,我有了一个又一个的顿悟时刻,每次都让我惊呼“哇,这意味着我们可以这样做?”。

浏览器支持

在我们演示很酷的案例前,我们先看看浏览器对其支持。4 个主流的浏览器都支持 :has,从下面指定的版本开始:

-> Safari 15.4,在 2022 年 3 月引入

-> Chrome/Edge 105,在 2022 年 8 月引入

-> Firefox 121,在 2023 年 12 月引入

因为,这篇文章是在 2024 年 9 月编写,所以 :has 大约有 92% 左右的浏览器支持。

老实说,92% 并不是一个很有的浏览器支持...这意味着大约每 12 个人就有 1 个人使用不支持的浏览器。

幸运的是,我发现 :has 的大多数案例都是“锦上添花”的功能,所以它们在不支持的浏览器上不展示问题也不大。而在其他情况下,我们可以使用特性检测来提供备用的 CSS。

特性检测

@supports 规则允许我们有条件使用 CSS,基于用于浏览器对其是否支持。比如:

p {
  /* 这里是备用样式 */
}
@supports selector(p:has(a)) {
  p:has(a) {
    /* 使用现代化样式 */
  }
}

如果当前的浏览器不理解选择气传递的 selector() 函数,那么函数里面的内容会被忽略。如果用户的浏览器甚至很老旧,设置不能识别 @supports 规则,那么其整个的代码块会被忽略。无论哪种方式,对应样式都能工作。

现在,问题在于,没有办法使用旧的 CSS 来“模仿” :has 伪类。我们的备用样式不能产生相同的效果。相反,我们应该视为两套样式,他们以不同的方式来实现相同的目标。我们将在下一节中包含一个案例。

基于状态进行样式设置

如下,我们创建了很多包含小卡片的布局。其中一些卡片有可点击的子卡片。

对于使用键盘导航的用户来说,体验有点奇怪。一些子元素会动态改变大小,导致焦点轮廓变得奇怪,比如

为了解决这个问题,我将焦点轮廓移动到父容器上。如下

这解决了我们的问题,我认为这看起来很好看!

我们来看看它是怎么工作的。下面是 HTML 的内容:

<div class="bento-card">
  <p>
    I'm
    <button>188cmbutton>
    tall.
  p>
div>

在过去,我会直接将整个 .bento-card 容器设置为 来解决问题,但这并不是一个好主意。将这么多内容塞到一个按钮里会引入很多可用性和访问性问题;比如,用户无法点击并拖动选择按钮内的文本!

幸运的是,我们可以保留我们的语义标记,并通过使用 :has 选择器来实现我们的目标。

.bento-card:has(button:focus-visible) {
  outline: 2px solid var(--color-primary);
}
/* 移除默认的按钮焦点轮廓 */
.bento-card button {
  outline: none;
}

当 .bento-card 包含一个聚焦的按钮,我们就给它添加轮廓。轮廓是应用到父元素,即 .bento-card 上,而不是按钮本身。

如果我们对 :focus-visible 伪类不熟悉,它的工作方式和 :focus 类似,但是它只是在浏览器检测到用户使用键盘(或者其他非指针的设备)进行导航时应用。当使用鼠标的用户通过点击按钮来聚焦时,:focus-visible 不会被触发,那么就不会有聚焦轮廓显示出来。

我也移除了默认的按钮聚焦轮廓,以防重复的聚焦点。这个是我们需要特别留意的。实际上,我们的解决方案并不完美,因为我们需要给使用旧的浏览器的用户提供回退体验。

下面是完整的代码:

@supports selector(:has(*)) {
  .bento-card:has(button:focus-visible) {
    outline: 2px solid var(--color-primary);
  }
  .bento-card button {
    outline: none;
  }
}

在这个更新的版本中,轮廓的改动仅针对使用现代浏览器的用户生效。如果某人使用了老旧浏览器,则这些东西不会被应用,他们将看到标准的焦点轮廓。尽管这种回退体验有点奇怪,但是我认为他是个合理的解决方案。

这里我也采取了一个小捷径:不是测试我正在使用的特定选择器 .bento-card:has(button:focus-visible),而已使用了最小的有效 :has 选择器,即 :has(*)。浏览器实际上不会尝试解析我们提供的选择器,所以选择哪些元素并不重要。@supports 通过查看语法并确定其是否有效工作。

为什么不使用 :focus-within ?

:focus-within 是一个伪类,选择一个元素并包含其子元素。它允许我们实现类似的功能:

.bento-card:focus-within {
  outline: 2px solid var(--color-primary);
}

:focus-within 伪类出现的比 :has 早,并且有良好的浏览器支持。这看起来是一个很好的方法,是吧?

在这个场景中,我更倾向于 :has 是有两个原因:

:focus-within 匹配 :focus 状态,而不是 :focus-visible 状态。这意味着即使用户使用鼠标点击按钮,也会展示轮廓。这里没有 :focus-visible-within。我并不想其他子元素被聚焦的时候也显示焦点轮廓。我只是想当按钮被聚焦时候应用。其他的卡片包含可聚焦的链接:

如果我使用 :focus-within,这会对用户哪个子元素应该是交互的产生疑惑。

总结,:focus-within 伪类是有用的,但是它是一个相对粗糙的工具。我们可以使用 :has 伪类来获取更好的控制。

基于状态的另一个例子

CSS 有数十种伪类,远不止 :focus-visible,我们可以使用这些伪类与 :has 结合,来条件性地应用 CSS 样式。

我们来看看这篇博文的另外一个案例。这是一个自定义的表单控件,我们称其为 "X/Y Pad"。

(这是一个可交互的元素!我们可以点击拖动来改变 X/Y 的值。对于键盘使用者,我们可以通过键盘尖头来更改。)

请注意,当我们拖动/调整这个控件,容器的颜色会更改!代码如下:

<style>
  .xy-pad {
    --dot-color: gray;
  }
  .xy-pad:has(.handle:active),
  .xy-pad:has(.handle:focus-visible), {
    --dot-color: var(--color-primary);
  }
style>
<div class="xy-pad">
  <svg>
    
  svg>
  <button class="handle">button>
div>

如果你不熟悉,可以理解 :active 伪类就是当按钮被点击并且按住时应用。当用户拖动该控键,我们的 :has 选择器就会匹配,然后我们就会更改 CSS 变量值,--dot-color。

另外,我已经添加了带有 :focus-visible 伪类的第二个选择器,确保使用键盘的用户得到相同的体验。

CSS 的 --dot-color 变量在几个地方使用到,边框和线条和点。这些点由一系列的 SVG 圆来动态生成:

<circle fill="var(--dot-color)">

全局检测

这也许是我目前发现的最酷的用户案例。我们可以使用 :has 来进行全局的事件监听。

比如,假设我们正在构建一个“模态框/对话框”组件。当对话框打开,这时候我们想禁用页面的滚动。那么我们可以在 标签中应用一些 CSS。

/* 当下面这个设定时,滚动禁用 */
html {
  overflow: hidden;
}

下面是我使用 JS 框架(比如:React)来实现:

// Register a side-effect that runs whenever `isOpen` changes:
React.useEffect(() => {
  if (isOpen) {
    // Save the current value for `overflow`,
    // so that we can restore it later:
    const { overflow } =
      document.documentElement.getComputedStyle();
    // Apply the new value to disable scrolling:
    document.documentElement.style.overflow = "hidden";
    // Register a cleanup function that undoes this work,
    // when `isOpen` flips back to `false`:
    return () => {
      document.documentElement.style.overflow = overflow;
    };
  }
}, [isOpen]);

如果你不熟悉 React,别担心。我们主要是在这里展示过去使用如此笨重的实现方式来解决问题!

我们可以通过使用 :has 这个友好的方式:

html:has([data-disable-document-scroll="true"]) {
  overflow: hidden;
}

如果 HTML 元素包含设置的这个数据属性,不管在 DOM 的哪个位置,我们将应用 overflow: hidden。

在 Modal 组件中,我们将根据条件触发设置的数据属性:

function Modal({ isOpen, children }) {
  return (
    <div
      data-disable-document-scroll={isOpen}
    >
      {/* Modal stuff here */}
    div>
  );
}

这非常酷。我们的模态实例打开,这个数据属性获得 true 的值,这意味着 :has 选择器已经触发,然后禁用滚动。如果这个数据属性变回 false,或者从 DOM 移除这个元素,滚动会自动恢复。✨

这个例子是使用 React,但是我们也可以在纯 JavaScript 环境中使用相同的技巧。如下:

function toggleModal(isOpen) {
  const element = document.querySelector('...');
  element.dataset.disableDocumentScroll = isOpen;
}

性能怎样?

我们可以想知道这个策略对性能的影响。根 HTML 标签上有 :has,这是否意味着浏览器需要检查整个 DOM 才能判断条件是否满足?

我决定在 Chrome devtools 上使用 “Selector Stats”feature 来测试。

在这个博文中,有超过 2500的 DOM 点,该选择器平均需要 0.1 毫秒来解析。我在我职业生涯中最慢的一台计算机上测试,这是一台价值 $100 的英特尔内核笔记本电脑,它在显示图像等方面遇到困难。但是在我的 2021 年的 MacBook Pro 上快 10x 倍。

浏览器已经在计算处理样式方面很棒。这不需要我们关心。

无需 JavaScript 的暗黑模式

Jen Simmons 发现我们可以使用这个技巧来创建一个没有 JavaScript 的暗黑模式的切换功能。如下:

<style>
  /* Default (light mode) colors: */
  body {
    --color-text: black;
    --color-background: white;
  }
  /* Dark mode colors: */
  body:has(#dark-mode-toggle:checked) {
    --color-text: white;
    --color-background: black;
  }
style>

<input id="dark-mode-toggle" type="checkbox">
<label for="dark-mode-toggle">
  Enable Dark Mode
label>

当用户点击复选框,:checked 伪类会应用上去,这就会触发我们 :has 选择器生效。我们使用新的暗黑模式重写基准的 CSS 变量,并且主题有效的交换了!

需要表明,暗黑模式是一个令人惊喜且复杂的事情,我们这个方法并没有完全实现(比如,它并没有保存用户的喜好选项,或者继承系统默认的主题)。另外,我不希望核心功能依赖于只有 92% 左右支持的 CSS 功能。但是,我们可以只用一个 CSS 规则而不需要 JS 就添加“暗黑模式”切换,这真是太酷了。

我们可以在 Jen’s wonderful blogpost 的文章中了解这个方法,或者其他更酷的案例。

缺失的选择器

目前,所有我们看到的案例都是对子元素的父元素进行样式调整。这很酷,但是,这只是冰山一角。

看下面这个:

<style>
  p:has(+ figure) {
    font-weight: bold;
  }
style>
<p>
  This is a regular paragraph, with no
  custom styles applied.
p>
<p>
  This paragraph introduces this figure:
p>
<figure>
  <img
    src="/images/css-has/punk-cat.png"
    alt="Photo of a hairless cat with a doodled mohawk"
  >
figure>
<p>
  See how the paragraph right before the <em>figureem> was given bold text, while the other paragraphs like this one are untouched?
p>

在这个场景中,我们选择了所有在 标签之前的段落。最大的不同点是这里没有 parent/child 的关系;p 和 figure 是兄弟关系。

接下来的几个案例大同小异,感兴趣读者可直接戳 The Undeniable Utility Of CSS :has

工作的最佳工具

正如我们看到,:has 选择器很强大。之前需要使用 JavaScript 实现的功能,现在可以用 CSS。

但是,只是因为我们可以像这样解决问题,是否意味着我们需要这样做呢?

我非常喜欢使用能够最小的复杂性解决问题的工具。如果一个问题能够使用 CSS 和 JavaScript 来解决,那么使用 CSS 解决方案会更简单。

然而,使用 :has 我们可能编写更加复杂。这是刚才我们看到的“最终”版本的片段,包含了为 mobile/keyboad 设计的选择控件:

html:where(
  :has([data-category="sci-fi"]:hover),
  :has([data-category="sci-fi"]:focus-visible),
  :has([data-category="sci-fi"]:active),
) [data-category="sci-fi"],
html:where(
  :has([data-category="fantasy"]:hover),
  :has([data-category="fantasy"]:focus-visible),
  :has([data-category="fantasy"]:active),
) [data-category="fantasy"],
html:where(
  :has([data-category="romance"]:hover),
  :has([data-category="romance"]:focus-visible),
  :has([data-category="romance"]:active),
) [data-category="romance"] {
  background: var(--highlight-color);
}

(:where 伪类允许我们将相关的选择器分组,这相当于将每个子句写为单独的选择器。)

如果我使用框架,比如 React,来构建这个 UI,我认为创建一个状态来跟踪当前处于活动状态的类别会更加简单。它们会更加灵活;我们可以有动态类别,而不是硬编码的类别。书籍可以属于多个类别。它可以在 Internet explorer 中运行。

我引入这个例子,是因为它确实令人难以置信地演示了 :has 的功能,但是如果我们构建这个特定的 UI,我将在 JavaScript 中实现此逻辑。

【完✅ - 感谢阅读】