CSS :has() 不是“父选择器”那么简单:三个真实改造案例让我删掉了 200 行 JS

2026-08-02 0 755

以前一提到“根据子元素状态给父级加样式”,第一反应就是 JS 监听事件,然后 add class。这个套路我用了五六年。直到上个月决定彻底拥抱 :has(),我才发现自己过去写了多少多余的代码。它不是简单的“父选择器”,而是一个能让你躲在 CSS 里完成复杂交互逻辑的瑞士军刀。

这篇文章不聊 API 定义,直接上三个我从项目里重写的真实场景。每一个都曾经让我头痛,改写之后神清气爽,顺便干掉了 200 多行 JS。

一、表单校验的视觉反馈,再也不用 JS 加 class

项目里有个注册表单,用户输入邮箱时,如果格式不对,输入框要变红,还要在下方提示错误。以前我要监听 input 事件,用正则测试,根据结果给外层 <div>is-invalidis-valid 类。然后 CSS 里写两个类控制的边框颜色。一个表单有六个字段,写了一大坨 JS。

现在 :has() 让我把校验逻辑直接搬到 CSS 里。思路是利用 HTML 原生表单验证的 :valid:invalid,配合 :has() 去改变外层容器的样式。

先看结构:

<div class="field-group">
    <label for="email">工作邮箱</label>
    <input type="email" id="email" required placeholder="you@example.com" />
    <p class="error-msg">请输入有效的邮箱地址</p>
</div>

CSS 写起来就是:

.field-group:has(input:invalid) .error-msg {
    display: block;
    color: #d93025;
}

.field-group:has(input:invalid) input {
    border-color: #d93025;
    background: #fff5f5;
}

.field-group:has(input:valid) .error-msg {
    display: none;
}

.field-group:has(input:valid) input {
    border-color: #188038;
}

就这么简洁。且慢,这里有个坑:页面刚加载时,空输入框也是 :invalid 的。我不想一进来就一片红。于是加了一条“未触碰”的判断:

.field-group:has(input:not(:placeholder-shown):invalid) input {
    border-color: #d93025;
}

:placeholder-shown 表示占位符可见,也就是用户没输入内容。用 :not(:placeholder-shown) 就表示输入了东西但格式不对。这样完美避免了初始全红。同理,验证通过后的样式,我也可以加上这个条件,防止初始状态显示绿色。

这个场景让我第一次察觉,CSS 已经可以处理一个简单的“状态机”了。

二、卡片悬停的“连带效果”::has() 让联动毫不费力

产品首页展示一排项目卡片,每张卡片里有一个标签,一个标题,一段简介。设计稿要求:当鼠标悬停在卡片上时,不仅卡片要上浮,里面的标签颜色也要变。以前这么做:

card.addEventListener('mouseenter', () => card.classList.add('hovered'))
card.addEventListener('mouseleave', () => card.classList.remove('hovered'))

然后 CSS 里写 .card.hovered .tag { ... }。现在一行选择器解决:

.card:hover > .tag {
    background: #2563eb;
    color: white;
    transform: translateY(-2px);
}

但这不是 :has() 的功劳。真正让 :has() 大放异彩的是另一种情况:我希望“当卡片内部有某个特殊标签时,卡片整体显示一个角标”然后再整卡变色。比如有 tag 为 “VIP” 的卡片,右上角出现一个皇冠图标,而且悬停的时候整个卡片边框发金光。结构:

<div class="card">
    <span class="tag vip">VIP</span>
    <h3>高级会员方案</h3>
    ...
    <i class="crown"></i>
</div>

以前必须用 JS 遍历卡片,判断有没有 .vip 元素,然后给这个卡片加一个类。现在直接用 :has(),比 JS 更聪明的地方在于:它会在卡片类结构变化时自动重算。如果在某个异步操作后,标签从普通 tag 变成了 tag vip,样式立刻自动更新。

.card:has(.vip) .crown {
    display: inline-flex;
    opacity: 1;
}

.card:has(.vip):hover {
    border-color: #f59e0b;
    box-shadow: 0 8px 20px rgba(245, 158, 11, 0.2);
}

代码不用任何 JS 参与,加类或删类也能实时响应。而且比起 JS 遍历,性能更好,因为浏览器原生优化了选择器匹配。

三、配合容器查询:真正的响应式组件从内部瓦解

:has() 给我最大的惊喜,是它能和容器查询结合起来,变成一个“容器状态选择器”。举个例子:我的商品卡片有两种布局,宽的时候图片在左、文本在右,窄的时候图片在上、文本在下。以前用全局媒体查询和 JS 检测屏幕宽度,但组件所在的容器宽度可能和屏幕宽度完全无关(比如侧栏里的卡片)。

在支持容器查询的浏览器里,可以给卡片容器设置 container-type: inline-size,然后基于容器宽度改变布局。但有时候我还得根据子元素数量调整排列方式。这时 :has() 就变聪明了——它可以检查容器内部一共有几个子项。

.product-card {
    container-type: inline-size;
}

/* 当容器宽度大于 300px 时,且内部有超过3个标签,就采用横向排列 */
@container (min-width: 300px) {
    .product-card:has(.badge:nth-child(n+4)) {
        flex-direction: row;
        align-items: center;
    }

    .product-card:has(.badge:nth-child(n+4)) .badge-group {
        flex-wrap: wrap;
    }
}

这里 :has(.badge:nth-child(n+4)) 表示存在第四个、第五个甚至更多的 .badge 元素。原生的 :nth-child():has() 结合在一起,让我们可以在不计算实际数量的情况下,完成“元素够多时换布局”这种逻辑。

以前遇到这种需求,我不得不监听 DOM 变动然后给卡片挂一个类名 has-many-badges。现在?零 JS,声明式写好规则,浏览器自动匹配。

四、避坑记录:不是所有场景都能用 :has()

把 :has() 用到生产环境之前,有几个现实障碍需要知道。首先是性能。不敢说 :has() 全无代价,至少在目前的浏览器实现中,对于非常庞大的 DOM 树,复杂的选择器(比如 :has() 里再套 :has())会带来一些匹配成本。但我实际项目里没有感觉到问题,适度使用完全OK。

其次,有的伪类不能放在 :has() 里面。比如无法写出 :has(::before) 这种,也不能在 :has() 内部再使用 :has()(至少目前规范不允许嵌套)。如果需要更复杂的逻辑,可以借助 :is():where() 来抬高针对性,但嵌套就真的不行。

还有一个小坑::has() 在部分浏览器(比如老版 Safari)需要加前缀?现在最新 Safari 17+ 已经支持,但如果你要兼容 iOS 16 以下,那就得谨慎了。我的项目是后台管理系统,只要求现代 chrome,所以彻底用起来了。

五、我为什么说它“不是”父选择器

很多人管 :has() 叫“父选择器”,因为它能选中含有某个子元素的父级。但它在我的案例里已经不止一次充当了“兄弟选择器”和“祖先选择器”的复合角色。比如这段代码:

.form-section:has(~ .confirmation-box) {
    border-bottom: none;
}

这里 :has() 不是指定“本身包含”,而是“本身之后跟着某个元素”。这其实是利用 ~ 通用兄弟选择器,再把 :has() 包裹起来,让前面元素能够感知到后面的兄弟。这种能力在以前完全不可想象,因为我找不到任何纯 CSS 办法让元素根据后面的兄弟状态改变样式。

举个例子,一个两步表单:第二步有一个隐藏的“确认区域”。当确认区域可见时,我想让第一步的提交按钮隐藏。直接用 :has() 加兄弟选择器:

.step-one:has(~ .step-two.is-open) .submit-btn {
    display: none;
}

也就是说,当 .step-two 出现 .is-open 类时,它前面的 .step-one 里的按钮就消失。这种“回溯”能力彻底打破了我对 CSS 的认知。

六、从实际项目总结的缩写建议

写了这么多,如果你现在准备用 :has() 重构旧代码,我有几个建议:

  • 从最显眼的“状态类”开始替换。比如原来 JS 里 add / remove 的各种状态类,只要和 DOM 结构相关,都可以考虑用 :has() 翻译成纯 CSS。
  • 注重可读性。:has() 写得太长确实难懂,所以我会配合自定义属性或者 CSS 变量来增强语义。比如 .card:has(.vip) 注释为“VIP 卡”就一目了然。
  • 保留必要的 JS。虽然 :has() 能干掉很多逻辑,但像事件监听仍然是它不可替代的。我并没有删光 JS,只删掉了那些和“类名切换”相关的部分。

最终效果是,我的组件代码瘦身了,而且有一个额外收获:样式完全由 CSS 驱动,不容易出现 JS 和 CSS 状态不同步的 bug。以前那种“JS 加了类但 CSS 没生效”的情况再也没发生过。

七、还是想说句公道话

:has() 不是银弹。它并不会消灭 JS,但能把以前被 JS 垄断的一部分“样式逻辑”重新交还给 CSS。当你习惯这种思维后,写起页面来会变得越来越轻快。

对我来说,最直观的变化是:删掉了一堆 getElementById、classList.add、classList.remove 之后,代码可读性瞬间提升。下次再有类似的需求,我会先想想能不能用 CSS 解决,而不是第一时间打开编辑器写 addEventListener。

希望我的这三个案例能给你一些启发,让你在项目里大胆用起来。毕竟,浏览器已经给了你这个能力,不用白不用。

CSS :has() 不是“父选择器”那么简单:三个真实改造案例让我删掉了 200 行 JS
收藏 (0) 打赏

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

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

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

淘吗网 css CSS :has() 不是“父选择器”那么简单:三个真实改造案例让我删掉了 200 行 JS https://www.taomawang.com/web/css/2475.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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