做表单的时候,有个需求很常见:用户输入框里填的内容不对,整个字段区域(标签、输入框、错误提示)都要变个样。以前怎么做?输入框监听 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 实现一个样式本来就能表达的东西?大部分时候,答案会让你少写不少代码。
老规矩,先在一个独立的小模块里试试,觉得顺手再考虑铺开。别一上来就把主布局重构一遍,翻车的可能性很大。

