上周做前端页面,碰到一个需求:输入框里面如果有内容,就显示旁边的清空按钮;如果是空着就隐藏。以前这种逻辑必然要写JS监听input事件,然后去切一个class。后来看到一个同事在代码里写了一个 :has(),我才想起来——原来这个选择器已经可以随便用了。试了一下,确实香,我一晚上把项目里十几个类似的交互全改了,几乎没有再写一行JS。
先说一句 :has() 是干嘛的
:has() 是一种关系选择器,它能根据某个元素的后代、兄弟或者后续状态去匹配这个元素。比如你有一个 <form>,里面有一个 <input required>,你想判断这个 <form> 是否有一个必填项,那么可以直接写 form:has(input:required)。这听起来像是“父选择器”,但它比父选择器更强大,因为它可以写任意关系。
先看最基本的写法:
/* 选择包含 .icon 元素的 div */
div:has(.icon) { background-color: #f0f0f0; }
读起来就是“一个div,它内部有.icon的,就给它加背景色”。
注意,:has() 没有改变样式优先级,也不影响DOM树,它只是一个匹配条件。
实战场景一:输入框有内容时显示清空按钮
先画一个简单的结构,把按钮放在输入框后面,利用 :has() 控制按钮的显示。
<div class="field">
<input type="text" placeholder="请输入关键词">
<button class="clear-btn">×</button>
</div>
以前用JS写是这样的:监听input事件,判断value长度,然后给父元素加一个active类。现在用 :has() 可以这样:
.clear-btn { display: none; }
.field:has(input:placeholder-shown) .clear-btn { display: none; }
.field:has(input:not(:placeholder-shown)) .clear-btn { display: block; }
简单解释一下: :placeholder-shown 这个伪类表示“占位符正在显示”,也就是说输入框为空。所以第一个规则是,输入框为空时,不显示按钮;第二个规则是,如果输入框不是空,也就是有内容时,按钮显示出来。完全不需要JS。
可能有同学问,如果不用placeholder,而是用value属性,怎么判断?可以用 input:not(:placeholder-shown) 已经是标准办法,但有些浏览器对value属性识别有兼容性问题,目前最佳实践就是配合 placeholder 使用。
实战场景二:表单校验样式
一个注册页面,当密码输入框的密码长度不够时,给整个表单项加一个红色边框。以前得用JS去监听blur或者input事件,然后添加class。现在用 :has() 加上 :invalid 伪类就能实现。
<div class="form-item">
<input type="password" required minlength="6">
</div>
.form-item { border: 1px solid #ddd; padding: 5px; }
.form-item:has(input:invalid) { border-color: red; }
你看,只要这个容器内部有无效输入(比如长度不足),这个容器边框就变红。同理,如果是邮箱格式不对,也是 :invalid。想显示错误提示文字,也可以用类似方式:
.error-msg { display: none; }
.form-item:has(input:invalid) .error-msg { display: block; }
这个组合非常实用,可以省掉一整套表单验证的JS代码。
实战场景三:卡片高亮——当卡片中有选中的选项时
做筛选列表时,卡片内部可能有多个可勾选的按钮,当用户至少勾选了一个时,我们想给卡片加一个高亮边框。利用 :has() 可以这样写:
<div class="card">
<label><input type="checkbox" class="opt"> 选项A</label>
<label><input type="checkbox" class="opt"> 选项B</label>
<label><input type="checkbox" class="opt"> 选项C</label>
</div>
.card { border: 2px solid transparent; }
.card:has(.opt:checked) { border-color: #2196f3; }
不仅边框,你还可以给卡片加阴影、改变背景色等等。甚至你可以判断“不是全部选中”的状态,比如:
.card:has(.opt:not(:checked)) { opacity: 0.8; }
不过这会把全选和未选中混在一起,你可以按需使用。
实战场景四:tab导航中当前选中项
以前设置tab高亮,要么用JS绑定点击事件,要么用 :target 或 ~ 兄弟选择器。但有些tab结构已经固定,我们不好改DOM。用 :has() 可以快速实现:
<nav class="tabs">
<a href="#tab1" rel="external nofollow" class="tab-item">首页</a>
<a href="#tab2" rel="external nofollow" class="tab-item">产品</a>
<a href="#tab3" rel="external nofollow" class="tab-item">关于</a>
</nav>
.tab-item { color: #333; }
.tabs:has(.tab-item:target) .tab-item:target { color: red; font-weight: bold; }
这一行样式就实现了一个基于锚点路由的导航高亮。当点击tab2时,URL变为#tab2,然后 .tab-item:target 匹配到它, :has() 确保父级存在target的子元素,最后改那个元素的样式。
当然,如果你不是用href锚点,而是用radio按钮的 :checked 也可以:
<div class="tab-wrap">
<input type="radio" name="tabs" id="tab1" checked>
<input type="radio" name="tabs" id="tab2">
<div class="tab-content">内容一</div>
<div class="tab-content">内容二</div>
</div>
.tab-content { display: none; }
.tab-wrap:has(#tab1:checked) .tab-content:nth-child(3) { display: block; }
.tab-wrap:has(#tab2:checked) .tab-content:nth-child(4) { display: block; }
不过这里 :has() 的作用不是特别明显,但思路是共通的。
实战场景五:基于容器内的图片来调整布局
有时候一个商品卡片,可能有图片也可能只有文字。对于有图片的卡片,我们希望图片占左,文字占右;没有图片时,文字居中。传统做法是给卡片添加一个 class,比如 has-image。现在可以这样:
<div class="product-card">
<img src="thumb.jpg">
<div class="info">商品信息</div>
</div>
.product-card { display: flex; }
.product-card:has(> img) { flex-direction: row; }
.product-card:not(:has(> img)) { flex-direction: column; text-align: center; }
这里用了 > 直接子代选择器,意思是“存在一个img子元素”。:not(:has(…)) 就是没有图片的情况。这样你不用在模板里去条件判断要不要加class,CSS自己就能区分。
实战场景六:根据子元素数量改变样式
有同学会遇到一个需求:列表中的项少于3个时,让列表横向排列;超过3个时,让列表垂直排列。用 :has() 配合 :nth-child 或者 :nth-last-child 可以取巧:
<ul class="list">
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
.list { display: grid; }
/* 如果第一个元素是最后一个元素(即只有一个子元素) */
.list:has(li:first-child:last-child) { grid-template-columns: 1; }
/* 如果第二个元素是最后一个元素(即只有两个子元素) */
.list:has(li:nth-child(2):last-child) { grid-template-columns: repeat(2, 1fr); }
/* 三个或更多使用默认grid */
很巧妙吧。 :has() 里面可以写组合选择器,这个表达能力非常强。
兼容性到底怎么样
我查了一下浏览器支持情况:Chrome 105+、Edge 105+、Safari 15.4+、Firefox 121+ 都已经完全支持 :has()。也就是说,从2023年底开始,主流浏览器已经可以放心使用了。如果你不需要兼容旧版本,那就可以直接把项目里的 :has() 用起来。
如果有兼容需求,可以用 @supports selector(:has(*)) 来判断:
@supports selector(:has(*)) {
.field:has(input:placeholder-shown) .clear-btn { display: none; }
}
这样老浏览器不会解析里面的样式,不会导致界面异常。但老浏览器功能就缺失了,只能靠JS做回退。
再聊聊和 :has() 不能做的
虽然 :has() 很强,但它不能实现“兄弟元素影响前一个兄弟”的完全回溯,因为CSS的级联逻辑是单向的,但 :has() 提供了“选择祖先或兄长的能力”。比如你想让“前面的元素”根据“后面的元素”变化,它是不行的。比如: h1 + p:has(span) 可以,因为你选择的是p,而不是h1。你无法让h1根据后面p的状态来变化,因为 :has() 只能作用于你选中的那个元素本身,而那个元素在后面,它无法回头去改前面的h1。
举个例子: p:has(+ h1) 表示“后面有h1的p”,这个可以选中前面的p。但是 h1:has(~ p) 则表示“后面有p的h1”,所以h1就能知道后面是否有p。其实这已经算能影响祖先了,只是要正确选择目标元素。
实际项目中的一个优化:减少重复class
以前我们做列表页,每个卡片根据状态不同要加几个不同的class,比如“selected”、“disabled”、“warning”等等。用了 :has() 后,很多状态可以通过内部元素去判断,比如卡片里有一个checkbox,选中了就自动高亮卡片,就不需要额外的class。代码少了很多,而且当你用框架渲染列表时,不需要在模板里写三元表达式,只要把元素结构固定好就行。
我有一个同事刚开始对 :has() 很排斥,觉得这是“非主流”,在他见过我在表单校验上的用法之后,他也开始在自己的项目里用了。只要小心点用,这绝对是一个提高编写效率的好东西。
几个能用在真实项目里的完整小例子
例子1:下拉菜单的展开状态
用checkbox或者button,轻轻一点,靠 :has() 控制菜单的显示隐藏。
<div class="dropdown">
<button class="trigger">点击展开</button>
<div class="menu">这里是菜单内容</div>
</div>
.menu { display: none; }
.dropdown:has(.trigger:active) .menu { display: block; }
但注意 :active 只有在按住鼠标时才激活,实际使用可能会松手就消失。所以还是建议用checkbox或popover属性,这里只是演示 :has() 的用途。
例子2:检测一个区域是否有滚动条并添加阴影
.scroll-box:has(> .inner) { overflow: auto; }
这个意义不大,不过说明可以用于各种结构判断。
写到最后
CSS :has() 已经火了好几年,然而身边还是有很多人不知道或者不敢用。我建议大家在个人项目里多尝试,等试过几个场景后,你就会发现以前很多JS交互本来就是CSS该干的活。最后提醒一句,:has() 会消耗一点性能,在超大页面中不要写太复杂的嵌套关系,但正常规模项目完全没问题。
好了,这篇关于 :has() 的实战分享就到这里。下次你再遇到“根据元素内部状态改变自身样式”的需求,先想想用 :has() 是不是就够了。

