去年年底给一个后台管理系统做重构,产品经理提了一堆交互细节需求,全是同一类:「用户选了东西之后,那块区域要变个样子」。
按老习惯就是加事件监听、toggle class、再写个 MutationObserver 兜住动态渲染的列表。一个筛选项配一段 JS,五个筛选条件就是五段。我盯着那个文件看了两分钟,决定先拿 :has() 试一把。
结果是这段逻辑从 87 行 JS 变成了 19 行 CSS,而且后面新增筛选条件不用再动 JS。这篇文章就把当时用的几个写法,连同踩到的坑,一起讲清楚。
先弄清楚 :has() 到底在选什么
:has() 被叫「父选择器」其实是叫窄了。它本质上是一个「相对选择器」:只要元素内部(或者它的兄弟节点里)存在匹配的东西,这个元素就被选中。
三个方向都能用:
/* 1. 后代方向:卡片里有图片 */
.card:has(img) { }
/* 2. 子元素方向:表单域里有错误提示 */
.field:has(> .error[data-show]) { }
/* 3. 兄弟方向:它后面紧跟着一个提示条 */
.tooltip-trigger:has(+ .tooltip[data-open]) { }
第三个写法特别值得单独记一下。CSS 一直没有「选择前一个兄弟」的能力,:has(+ x) 是目前唯一的正路子。以前想在鼠标悬停时高亮前面的图标,只能把 DOM 顺序调过来然后用 flex 反向排,现在不用了。
浏览器支持方面:Safari 15.4、Chrome 105、Firefox 121 开始都支持了。到今天(2025 年)主流浏览器早就覆盖完了,只有一些内嵌 WebView 和政企单位还在跑的老版本 Chromium 需要照顾一下。后面会讲怎么给它们兜底。
场景一:筛选面板的「已选」状态
需求很朴素:用户勾了任意一个筛选条件后,右上角出现「清空筛选」按钮,同时面板底色变一下。以前这活是给每个 checkbox 绑 change 事件。
用 :has() 配合表单的 reset 原生命令,一行 JS 都不用:
<form class="filter-bar">
<fieldset>
<legend>分类</legend>
<label><input type="checkbox" name="cat" value="A"> 服装</label>
<label><input type="checkbox" name="cat" value="B"> 数码</label>
<label><input type="checkbox" name="cat" value="C"> 家居</label>
</fieldset>
<fieldset>
<legend>状态</legend>
<label><input type="radio" name="st" value="1"> 在售</label>
<label><input type="radio" name="st" value="2"> 下架</label>
</fieldset>
<button type="reset" class="clear-btn">清空筛选</button>
</form>
.filter-bar {
border: 1px solid #d8dce3;
border-radius: 6px;
transition: border-color .18s, background-color .18s;
}
.filter-bar .clear-btn {
opacity: 0;
pointer-events: none;
transition: opacity .18s;
}
.filter-bar:has(input:checked) {
border-color: #3b82f6;
background-color: #f5f9ff;
}
.filter-bar:has(input:checked) .clear-btn {
opacity: 1;
pointer-events: auto;
}
关键在 button type="reset"。它会把整个 form 里的复选框、单选框、文本框全部还原成初始状态,浏览器原生就干这事,比你手写一个遍历 dom 的循环靠谱。
注意按钮必须写在 <form> 内部,而且不能有 type="button",否则 reset 行为不会触发。
场景二:表单校验的父级反馈
第二个场景是登录页。以前的做法是监听 blur 事件,自己维护一个 touched 状态,然后给外层容器加 class 控制红框。
现在用 :user-invalid 搭配 :has() 就能覆盖绝大部分情况:
<div class="field">
<label for="email">邮箱</label>
<input id="email" type="email" required>
<span class="hint">请输入有效的邮箱地址</span>
</div>
.field {
position: relative;
padding-bottom: 22px;
}
.field .hint {
position: absolute;
left: 0;
bottom: 0;
font-size: 12px;
color: #dc2626;
opacity: 0;
transform: translateY(-2px);
transition: opacity .15s, transform .15s;
}
.field:has(input:user-invalid) input {
border-color: #dc2626;
outline-color: #dc2626;
}
.field:has(input:user-invalid) .hint {
opacity: 1;
transform: translateY(0);
}
:user-invalid 和 :invalid 的区别得说清楚,不然做出来的效果会很别扭。
:invalid 是「值不合法」,页面一加载、用户还没碰过输入框,只要是空的且 required,它就已经是 :invalid 了。结果就是用户一进来整个表单全是红的,体验极差。
:user-invalid 是「用户已经交互过,且值不合法」。它会在用户输入后又删空、或者失焦时值不合法的情况下触发,之后再重新变合法就会自动摘掉。这才是我们想要的时机。
支持情况:Firefox 从 88 就有了,Chrome 119、Safari 16.5 开始跟进。如果你要兼容更老的 Chrome,可以先按 :invalid 写一版,再用 @supports selector(:user-invalid) 覆盖。不过我的建议是别折腾,直接在提交时做一次 JS 校验兜底,视觉上让老浏览器就用 :invalid 也无所谓。
场景三:卡片的结构变体
商品列表里的卡片长得不一样:有图的顶部没有内边距,只有文字的要多留白;评论区有图的卡片用两栏,没图的用单栏。
以前的写法是在渲染模板里判断,给不同的卡片加 .card--with-image。问题在于这个判断散在各个模板文件里,一个新来的同事根本猜不到有哪些修饰类。
交给 CSS 自己判断:
.card {
padding: 20px;
display: grid;
gap: 12px;
}
/* 有封面图:图片顶到边,内容区缩进 */
.card:has(> .card__cover) {
padding-top: 0;
grid-template-columns: 200px 1fr;
}
/* 没有封面图:单栏,上下留白更大 */
.card:not(:has(> .card__cover)) {
padding-block: 28px;
}
/* 标题超过两行时,副标题收窄 */
.card:has(> .card__title:first-child) .card__sub {
max-width: 34ch;
}
这里有个容易翻车的点::not(:has(...)) 里 :has() 是嵌套在 :not() 中的,这是合法的。但反过来,:has(:has(...)) —— 也就是 :has() 里面再套 :has() —— 是不合法的,整条规则会被浏览器直接丢弃。
规范明确禁止了 :has() 的嵌套。我为了写「卡片里有个包含了图片的容器」这种条件,卡了快半小时,最后只能改用别的写法:
/* 想要的:.card:has(.media:has(img)) —— 无效 */
/* 等价写法:直接定位到最终条件 */
.card:has(.media img) { }
大部分情况下,你去掉中间那层 :has(),直接用后代选择器表达,语义是一样的。
场景四:不用 JS 的主题切换
这个写法我第一次见的时候愣了一下。
常规做法是把 checkbox 放在 body 最前面,然后用 input:checked ~ .content 那种兄弟选择器去影响后面所有内容。缺点非常明显:checkbox 必须在 DOM 里排第一,稍微想把它挪到页面底部或者塞进某个工具栏,整个选择器就断了。
用 :has() 就没有这个约束,因为它是从 html 元素往下找:
<body>
<!-- 想放哪放哪 -->
<header>
<label class="theme-switch">
<input type="checkbox" id="dark-mode">
<span>深色模式</span>
</label>
</header>
<main>...</main>
</body>
:root {
--bg: #ffffff;
--fg: #1f2328;
--line: #e4e7ec;
}
html:has(#dark-mode:checked) {
--bg: #16181d;
--fg: #e6e8eb;
--line: #2c3038;
}
body {
background: var(--bg);
color: var(--fg);
transition: background-color .25s, color .25s;
}
只改变量,不写具体样式,切换的时候不会有闪烁。而且因为变量定义在 :root 上,所有用了这些变量的地方会自动跟着变,不用挨个去找。
再加点细节,让勾选框靠近的时候有反馈:
.theme-switch:has(input:hover) {
background-color: color-mix(in srgb, var(--fg) 6%, transparent);
}
color-mix() 现在支持度也够了,Chrome 111、Safari 16.2、Firefox 113。用它来做这种「当前前景色混合一点底色」的悬浮态,比硬编码一个 rgba 值更抗主题切换 —— 深色模式下它自动就是对的。
场景五:跟容器查询配合
单个场景说完了,说一下组合起来用的效果。
后台的订单卡片在不同的位置用:主列表里宽度大概 720px,侧边栏的「最近订单」里只有 280px。以前要写两套卡片样式,或者用媒体查询硬猜。
.card-wrap {
container-type: inline-size;
container-name: card;
}
@container card (max-width: 320px) {
.card:has(> .card__avatar) {
grid-template-columns: 1fr;
text-align: center;
}
.card:has(> .card__avatar) .card__avatar {
margin: 0 auto;
}
}
容器查询负责「这块区域有多宽」,:has() 负责「这张卡片里有什么」。两个条件叠加,基本能覆盖我遇到过的绝大多数自适应需求。
注意 container-type: inline-size 要加在外层包裹元素上,不是卡片自己身上。写在卡片自身上,卡片就永远测不到自己相对于别人的宽度了。这个我一开始也写错了,表现是断点完全不生效,排查了好一会儿。
性能:什么时候 :has() 会拖慢页面
:has() 不是免费的。浏览器的样式引擎在遇到它的时候,失效范围会扩大 —— 一个节点变了,可能要让一大片祖先重新评估匹配。
几种明显要避免的写法:
一是往结构不稳定的容器上加。
/* 别这么写 */
.list:has(li) { }
/* 也别这么写 */
body:has(.modal) { }
.list 里的 li 只要增删一条,整个 list 都要重新匹配。如果这是个带虚拟滚动的长列表,滚动时不断增删节点,这块开销就上来了。
我实测过一版:一个 500 条目的列表,每行都写了 .row:has(.tag),滚动时的样式计算耗时从 1.2ms 涨到 6ms 左右。单看不多,但叠加上重排重绘,低端机上能感觉到卡顿。改成给 .row 加一个 class 之后回到 1.5ms。
结论不是「:has() 慢」,而是「别把它用在会被高频改动的节点上」。静态结构上用,随意用都没事。
二是条件本身太宽泛。
/* 匹配范围太大 */
:has(input:checked) { }
/* 缩到具体容器上 */
.filter-bar:has(input:checked) { }
前者会让浏览器从根节点开始找,后者有明确的范围。
Chrome DevTools 的 Performance 面板里有个 Selector Stats 功能,打开后录制一次交互,能看到每个选择器的匹配耗时和匹配次数。我的一般做法是:改完之后录一遍,把耗时超过 1ms 的选择器挑出来看,基本都是 :has() 写宽了。
三个我实际踩过的坑
坑一:动态插入的内容不触发匹配。
有次用 :has() 做了一个「弹窗打开时页面禁止滚动」的效果,但弹窗是 JS 动态插入的,效果死活不出现。后来发现是我把选择器写成了 body:has(> .modal[data-open]),而弹窗实际是挂在 #app 里的,多了一层。把 > 去掉就好了。
这类问题的排查思路是:先用 document.querySelectorAll('body:has(.modal)') 在控制台跑一下,看能不能拿到元素。能拿到说明是样式问题,拿不到说明是选择器路径写错了。
坑二:伪元素不能当条件。
/* 无效,整条规则会被丢弃 */
.item:has(::before) { }
::before 是伪元素,不是真实节点,永远不行。想表达「这个元素有前置装饰」只能靠类名或者属性。
坑三:以为它是万能的父选择器。
写过一段时间之后确实容易上瘾,什么需求都想先试试 :has()。但有一类场景是真的不适合:需要在状态变化时执行副作用的时候。
比如「按钮被选中后发送埋点」,CSS 能改变外观,但它发不了请求。再比如「选中超过三个时禁用其余选项」,虽然用 :has() 加上计数兄弟选择器理论上能凑出来,但可读性会烂到没人敢改。
我的判断标准很简单:只影响视觉的就用 CSS,需要触发行为或者数据变更的就老老实实回 JS。混着来最后两边都难维护。
给老浏览器的兜底
用 @supports 做特性检测,语法长这样:
/* 默认降级:按钮一直显示 */
.clear-btn {
opacity: 1;
pointer-events: auto;
}
@supports selector(:has(*)) {
.clear-btn {
opacity: 0;
pointer-events: none;
transition: opacity .18s;
}
.filter-bar:has(input:checked) .clear-btn {
opacity: 1;
pointer-events: auto;
}
}
思路是反过来的:先写「功能可用但不智能」的基础样式,再用 @supports 在里面加增强效果。这样即使 :has() 完全不被支持,页面依然是可用的,只是按钮不会自动隐藏而已。
顺序很重要,别写反了。如果先写 :has() 的规则再写基础规则,支持 :has() 的浏览器也会被后面的基础规则覆盖掉,功能直接失效。CSS 层叠规则在这儿没有任何例外。
最后
回到开头那个项目。改造完之后筛选面板的 JS 从 87 行变成 19 行 CSS,后面产品又陆续加了三个筛选维度,我一行 JS 都没动。
但我想说的不是「:has() 能替代 JS」。它在很多地方替不了,也不该替。真正有价值的是它让「结构决定外观」这件事回到了 CSS 该在的地方 —— 用什么标签、放什么子节点,这件事本身就是样式的一部分,没必要非得用 JS 再翻译一遍成类名。
顺手做的一件事是:把项目里那些 .card--with-image、.form--has-error、.panel--active 之类的修饰类清了一遍,能改成 :has() 的都改了。改完之后模板干净不少,新来的同事看 HTML 结构就能猜到样式,不用再去翻 JS 找 class 是在哪儿加的。

