以前一提到“根据子元素状态给父级加样式”,第一反应就是 JS 监听事件,然后 add class。这个套路我用了五六年。直到上个月决定彻底拥抱 :has(),我才发现自己过去写了多少多余的代码。它不是简单的“父选择器”,而是一个能让你躲在 CSS 里完成复杂交互逻辑的瑞士军刀。
这篇文章不聊 API 定义,直接上三个我从项目里重写的真实场景。每一个都曾经让我头痛,改写之后神清气爽,顺便干掉了 200 多行 JS。
一、表单校验的视觉反馈,再也不用 JS 加 class
项目里有个注册表单,用户输入邮箱时,如果格式不对,输入框要变红,还要在下方提示错误。以前我要监听 input 事件,用正则测试,根据结果给外层 <div> 加 is-invalid 或 is-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。
希望我的这三个案例能给你一些启发,让你在项目里大胆用起来。毕竟,浏览器已经给了你这个能力,不用白不用。

