CSS :has() 实战:那些原本要写 JS 的状态联动,现在用样式就能解决

2026-10-02 0 819

做表单的时候,有个需求很常见:用户输入框里填的内容不对,整个字段区域(标签、输入框、错误提示)都要变个样。以前怎么做?输入框监听 input,再判断有效性,然后给外层元素 toggle 一个 class。三个步骤,看着不多,但每个表单都要写一遍,而且 input 事件不该触发的时机也会触发,需要再加一层 debounce 或者比较。

CSS 的 :has() 出来之后,这个逻辑可以直接写进样式表。输入框无效,父级就是另一个样子。不用 JS,不用 class 切换,也不存在「状态没同步」这种问题。

很多人对 :has() 的印象还停留在「就是父选择器」。但它能表达的东西比这个宽得多。这篇文章用几个实际场景过一遍,把好用的和不那么好用的地方都说清楚。

先看它到底是什么

一句话::has() 是「谁里面包含了某样东西」的意思。它是关系选择器,不是父选择器。

.card:has(img) {
  /* 里面有 img 的卡片 */
}

.form:has(:invalid) {
  /* 里面有无效控件的表单 */
}

li:has(+ li:hover) {
  /* 后面紧跟着一个 hover 状态的 li 的 li */
}

看第三个例子。它匹配的 li 是「后面那个兄弟的 hover 状态触发了」的那个。可以理解成向前看的兄弟选择器,即「反向兄弟」。这一层能力以前 CSS 完全没有。

参数里可以放任意复杂的选择器组合,包括嵌套的 :has()。比如「包含一个包含图片的卡片的容器」也能写。

.grid:has(.card:has(img)) {
  /* 容器里有卡片,且卡片里有图 */
}

用的时候要留个心眼:它内部的选择器是「相对」的,如果你在里面再写 :hover 这种状态,触发逻辑会变得很微妙。能简单就简单,别往里堆太多。

场景一:表单整块联动

最常见的场景。有一组字段,标签、输入框、错误提示都包在一个 .field 里。要求输入框不合法时整个字段变红,标签加粗。

<div class="field">
  <label>邮箱</label>
  <input type="email" required>
  <span class="hint">请输入有效的邮箱地址</span>
</div>

CSS 部分:

.field:has(input:invalid) label {
  color: #c0392b;
  font-weight: 600;
}

.field:has(input:invalid) input {
  border-color: #c0392b;
  background-color: #fdf3f2;
}

.field:has(input:invalid) .hint {
  display: block;
}

:invalid 是浏览器原生判断的,不用自己写正则。而且它是实时的——用户一边打字一边就会更新样式,从「无效」变成「有效」的时候也会自己恢复。用 JS 写要监听好几个事件才能做到同样的及时性。

有个细节要注意::invalid 在页面刚加载、用户还没碰过输入框的时候也会触发。这是一个体验问题,别人可能以为表单一开始就有一堆红色提示。想避免这种情况,用 .field:has(input:invalid:not(:placeholder-shown))。给 input 加一个空的 placeholder="",用户没输入时 :placeholder-shown 为真,配合 :not 就变成了「用户碰过并且内容无效」。

.field:has(input:invalid:not(:placeholder-shown)) label {
  color: #c0392b;
}

这个组合是纯 CSS 里做「用户主动触发的校验」的常用套路,很好用。

场景二:卡片布局自适应

一个产品卡片,有时候有配图,有时候是纯文字。有图的时候希望图在上、文字在下;没图的时候希望文字能垂直居中、字号略大。

以前要么在模板里判断,要么加个 .card--no-image 的 class。用 :has(),一行搞定:

.card {
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 20px;
}

.card:has(img) {
  justify-content: flex-start;
  padding-top: 0;
}

.card:has(img) img {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  border-radius: 8px 8px 0 0;
}

.card:has(img) .title {
  font-size: 16px;
}

.card:not(:has(img)) .title {
  font-size: 18px;
}

关键好处是:模板只负责渲染数据。有没有图片是数据里的事,样式负责反应,两边彻底解耦了。

如果项目里有模板引擎和组件库,这个特性的意义更大。它把「根据数据决定 class」这件事从渲染逻辑里剥离出来了,改样式不需要动组件代码。

场景三:勾选数量实时显示

一个筛选面板,用户勾选了几个选项,想实时显示「已选 3 项」。以前要监听每个 checkbox,现在也可以用 :has() 加伪元素来糊一个近似效果。

思路是把可能的数量都枚举出来,每一种数量对应一个不同的文案。这个技巧有点技巧性,让我把它讲清楚:

.filter:has(input:checked:not(:has(~ input:checked)))::after {
  content: '已选 1 项';
}

.filter:has(input:checked ~ input:checked:not(:has(~ input:checked)))::after {
  content: '已选 2 项';
}

第二段选择器的意思是「已经有一个 checked,而且这个 checked 后面没有别的 checked」,也就是它是最后一个。所以整个表达式匹配「恰好两个」。三个及以上的话,模式是一样的,就是选择器会越来越长。

说实话,这个纯 CSS 方案有几个限制:一是不支持「3 项及以上」这样的范围,二是超过四五个就必须收手,选择器会爆炸。

写这个场景不是为了推销某个 trick,而是想说明 :has() 能表达的逻辑边界在哪。像计数这种需要「聚合」的能力,CSS 确实做得吃力。碰到这种需求,该用 JS 还是用 JS,别硬凑。

场景四:空状态的自动切换

列表为空的时候显示一个占位图,有内容的时候隐藏它。以前也是要 JS 判断。

<div class="list">
  <div class="empty">还没有任何项目</div>
  <!-- 有数据时会插入 .item -->
</div>
.list .empty {
  display: block;
}

.list:has(.item) .empty {
  display: none;
}

很直观。它的好处是逻辑跟着 DOM 走。你不需要知道列表是用什么方式更新的、什么时机渲染的,只要 .item 出现,空状态自然就隐藏了。这一点在处理 Vue 的异步渲染时特别省心——不用纠结 useEffect 时序,也不用担心 setState 之后样式没更新。

再进一步,还能按不同条件切不同的空状态:

.list:has(.item) .empty--all {
  display: none;
}

.list:has(.item[data-favorite="1"]) .empty--favorite {
  display: none;
}

「没有收藏」和「没有全部数据」两个空状态互不干扰,切换逻辑完全交给样式。

场景五:导航菜单的当前项高亮

过去常见的做法是给当前页对应的 <li> 加个 .active class,然后在其前面或后面的元素上做样式。:has() 可以让「当前项的相邻元素」也响应。

nav li:hover {
  background: #f0f0f0;
}

/* 鼠标悬停时,前后邻居稍微变暗,视觉上有个过渡 */
nav li:has(+ li:hover),
nav li:hover + li {
  background: #fafafa;
}

效果很细腻,代码也不多。这是纯 CSS 很难做出来的效果,得靠 JS 监听 hover 事件才行。有了 :has() 就是两行。

再举一个更实用的。比如富文本工具栏,鼠标悬停在某个按钮上时,我们希望它前面的分组标题稍微亮一点提示「接下来这几个按钮属于这一组」。

.toolbar .group-title:has(~ .btn:hover:not(~ .group-title ~ .btn:not(:hover))) {
  /* 略复杂,仅示意思路 */
}

这个例子的选择器写得有点绕,实际上也不好维护。原则是::has() 里加一层状态查询就已经是复杂度上限了,再加就变成炫技。炫技的选择器在三个月后自己都看不懂。

组合玩法

:has() 和 :is()、:not()、:where() 组合使用,能表达更多。举几个实用组合:

/* 有 input 或 textarea 的表单 */
form:has(:is(input, textarea)) { }

/* 有必填项但还未填的表单 */
form:has(input[required]:placeholder-shown) { }

/* 没有图片也没有视频的媒体卡片 */
.media:not(:has(:is(img, video))) { }

/* 表格里有勾选行时,批量操作栏才显示 */
table:has(input[type="checkbox"]:checked) + .bulk-actions {
  display: flex;
}

最后那一条是我在工作中用到的。表格勾选之后,下面的批量操作栏才出现。以前是用 Vue 的 v-if 控制,写着写着就变成「table 组件和 toolbar 组件通信」这种麻烦事。现在只要它们是相邻的兄弟元素,样式上就能联动。

补充一句,:has() 和 + 组合时,方向是从后往前读的。上面那句「table 里有勾选行时,后面的 .bulk-actions 显示」,看起像是「table 影响它后面的兄弟」,写法上确实是 table:has(...) + .bulk-actions。这一点跟传统的兄弟选择器方向一致,但多了 :has() 之后容易绕晕,读的时候慢一点就行。

性能:别拿它当遍历器

:has() 的性能不是线性问题,它受 DOM 结构影响很大。用的时候有两条经验:

作用范围越小越好。 写 .field:has(input:invalid),浏览器只需要在 .field 的作用域内检查 input。写 .app:has(input:invalid),它就要在整个应用范围内找。后者会明显拖慢样式计算。

别用 :has(*) 或者深层的通配符。 有些教程会写 div:has(> *) 表示「有子元素的 div」,这种写法会让浏览器在每次 DOM 变动时重新做大量的匹配计算。有具体选择器就用具体的,不要图省事用通配符。

另外一个细节:当 DOM 频繁变动(比如列表滚动、大量节点增删)的时候,:has() 会增加样式重新计算的次数。如果你在一个每秒更新几十次的长列表上用 :has(),会明显感受到掉帧。这种情况建议还是走 JS 加 class 的路线。

判断要不要用 :has() 的一个简单标准:这个状态变化频率高吗?如果每秒几次,用;每秒几十次,慎用;每秒上百次,别用。

降级:写在支持之前

:has() 的浏览器支持现在已经很好了,Chrome 105、Safari 15.4、Firefox 121 之后都支持。但总有些用户会停在旧版本上,尤其是企业内网环境。

写法上有两种策略。

第一种是渐进增强:用 :has() 装饰那些「没生效也没关系」的样式,比如悬停时的邻居变暗、卡片有图时的微调。这些样式不影响功能,只影响观感,老浏览器看不到也不影响使用。

第二种是自己加个 class 兜底:

.form.has-invalid .field { /* 老浏览器 */ }

.form:has(input:invalid) .field { /* 新浏览器 */ }

两套规则写在一起,新浏览器会两个都匹配(因为 :has 和 .has-invalid 通常同时为真),老浏览器只匹配第一个。用一个小的 JS 检测:

if (CSS.supports('selector(:has(*))')) {
  document.documentElement.classList.add('has-support');
}

然后样式里用 .has-support .form .field 之类的规则。这种做法能保证老浏览器的表现也不至于太差。

还有一种更省事的做法是根本不检测,直接让样式降级。比如卡片没配图时字大一点——老浏览器上字还是一样大,能用就行。绝大多数产品里,没必要为了 1% 的老浏览器写两套样式。

踩坑清单

:has() 不能嵌套在 :has() 内部再放伪元素。 比如 :has(::before) 或 :has(:hover::before),绝大多数浏览器不支持。伪元素不是真实节点,:has() 是遍历真实 DOM 的,逻辑上就不通。

大小写敏感。 :has(input) 和 :has(INPUT) 在 HTML 里等价,但在 XML/XHTML 里不一样。虽然现在没人写 XHTML 了,但如果你的模板是 SVG 内嵌的,会碰到这个问题。习惯写成小写。

选择器优先级别搞混。 :has() 本身加上它参数里最高优先级的那个选择器的权重,就是整个选择器的权重。所以 .field:has(#id) 的权重相当于一个 id 选择器,会盖过一些你没预料到的规则。遇到样式覆盖得奇怪的时候,先算算权重。

不要写在 @supports 外面。 如果你想做兼容处理,把 :has() 的规则包在 @supports selector(:has(*)) 里,逻辑更清晰。写在里面的规则在支持的浏览器上生效,在老浏览器上被整段忽略,不会因为语法错误把整个样式表后面的部分干掉。

@supports selector(:has(*)) {
  .card:has(img) {
    /* ... */
  }
}

要注意的是,如果 :has() 写在不支持的环境里且没有 @supports 包裹,浏览器是「忽略这条规则」而不是「整个样式表报错」,所以即使不加 @supports 也不会把别的样式带坏。但加上 @supports 之后意图更明确。

别和 :focus-within 混用。 它们在某些场景下效果类似,但触发条件不同。:focus-within 只在焦点进入子节点时为真,:has(:focus) 也是同样意义,但 :has(:focus-visible) 只对键盘操作生效。做无障碍相关样式的时候,用 :focus-visible 会更准确。

在 Shadow DOM 里跨不过边界。 :has() 只能在同一个文档树的节点间匹配,跨 Shadow Root 是不通的。做 Web Component 的时候要留心这一点,组件对外界的响应依然要依赖事件和属性。

SSR 时的水合问题。 服务端渲染时,:has() 依赖 DOM 结构判断,如果 SSR 输出和客户端首次渲染的 DOM 不完全一致,会出现「服务端看着是 A 状态,客户端水合后突然跳到 B 状态」的闪烁。这种情况通常是因为 CSS 里的条件依赖了动态插入的节点。要么把动态节点在 SSR 阶段就输出,要么把这个样式改成 JS 控制。

什么时候别用 :has()

有两个明显的信号,出现一个就该考虑退回去用 JS。

一个是「需要知道状态变化的历史」或者「需要跨 DOM 树传递状态」。前者比如「用户改了三次才提交」这种带计数和时序的逻辑,CSS 表达不了。后者比如「顶部导航的状态影响底部工具栏」,如果它们不在同一棵子树里,:has() 也要靠很别扭的写法才能实现。

另一个是「样式依赖的是业务语义而非 DOM 结构」。比如「这个订单是 VIP 用户的,所以要特殊显示」。VIP 是业务概念,可能会变化,也可能会因为某条规则改变含义。把它写成 DOM 类更清晰:给订单元素加一个 .vip-order 的 class,样式里判断它。用 :has() 硬凑反而会让代码变得脆弱。

CSS 能处理的是「结构决定的样式」。结构能表达清楚的时候用它;结构表达不了的时候,老老实实交给业务代码。

收尾

回到开头那个表单。以前每个字段要监听 input、判断有效性、切 class,现在就是几行 CSS。这种「把状态从 JS 挪到样式层」的改动看似不大,但它减少了一整类 bug——状态不同步、动画衔接不顺、组件卸载后回调还在跑。

用 :has() 的思维习惯,其实就是多问一句:这个效果是真的需要 JS,还是我在用 JS 实现一个样式本来就能表达的东西?大部分时候,答案会让你少写不少代码。

老规矩,先在一个独立的小模块里试试,觉得顺手再考虑铺开。别一上来就把主布局重构一遍,翻车的可能性很大。

CSS :has() 实战:那些原本要写 JS 的状态联动,现在用样式就能解决
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 css CSS :has() 实战:那些原本要写 JS 的状态联动,现在用样式就能解决 https://www.taomawang.com/web/css/2853.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务