前阵子在做一个内部运营后台,要求侧边栏的菜单项如果有子菜单,就必须在右边显示一个箭头图标;没有子菜单就不显示。这要是以前,我肯定得在后端渲染时多传一个字段,或者在JS里遍历DOM去判断。但这次我直接用了CSS的:has()选择器,一行CSS搞定,连HTML结构都没动。说实话,:has()刚出的时候我以为只是个花架子,真用上了才发现这玩意是能实打实省代码的。
这篇文章就用几个实际案例,带你把:has()最常见的用法过一遍。所有代码都能直接在浏览器里跑,不需要构建工具,也不依赖框架。
先理解 :has() 是什么
:has()被称为“父级选择器”,因为它允许你根据一个元素的后代节点来选中这个元素本身。比如ul:has(li.active),意思就是“选中那些内部包含.active的li的ul”。以前这是不可能做到的,因为CSS只能从父选子,不能从子选父。现在有了:has(),很多以前必须上JS的场景都可以用CSS搞定。
语法并不复杂:
选择器:has(其他选择器) {
/* 样式 */
}
注意,括号里面的选择器可以是任何复合选择器,包括类、ID、属性、伪类,甚至还能嵌套:has()。功能很强大,但是别用过火。
案例1:侧边栏菜单箭头显示/隐藏
这个就是我开头说的那个需求。菜单结构大致是这样:
<ul class="menu">
<li class="menu-item">
<a href="#" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" >仪表盘</a>
</li>
<li class="menu-item has-submenu">
<a href="#" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" >用户管理</a>
<ul class="submenu">
<li><a href="#" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" >用户列表</a></li>
<li><a href="#" rel="external nofollow" rel="external nofollow" rel="external nofollow" rel="external nofollow" >新增用户</a></li>
</ul>
</li>
</ul>
原本这两个li长得一样,没有箭头。要求是有子菜单的li右边加个小箭头。用:has()就是一句话:
.menu-item:has(.submenu) {
background-image: url(arrow.svg);
background-position: right 10px center;
background-repeat: no-repeat;
padding-right: 30px;
}
这个选择器匹配那些内部包含.submenu的.menu-item。没有子菜单的li不受影响。也就是说,你只要在HTML里写好了子菜单结构,样式自己会适应,完全不用额外加类。
如果子菜单有多个层级,你甚至可以写成这样:
.menu-item:has(.menu-item) {
font-weight: bold;
}
意思是:只要li里面还嵌套着li,就给它加粗。这种祖先判断能力,在树形组件里特别管用。
案例2:表单必填星号自动显示
做表单时,经常需要给必填项的label加个红星。如果你的HTML结构是:
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" required>
</div>
你可以在label后面放一个星号span,但多少有点占位置。用:has()可以让label自己根据内部有没有required的input来决定是否显示星号,比如用伪元素:
.form-group:has(input[required]) label::after {
content: "*";
color: red;
margin-left: 2px;
}
这样只要是包含必填输入框的表单组,label后面自动带出一个红星。如果你不想用伪元素,也可以调整label的样式,比如加粗或者变色。
再进一步,还可以根据输入框的验证状态来改变背景色。比如:
.form-group:has(input:placeholder-shown) {
background-color: #fffbe6;
}
这个选择器的意思是:如果某个表单组里的input当前显示的是placeholder(也就是没有输入内容),就把整个表单组的背景变成淡黄色。输入内容后,背景自动恢复,这在制作多步表单时很有用。
案例3:纯CSS卡片悬停效果
以前做卡片悬停时,我们想要鼠标悬停在卡片上,然后让卡片内的某个图标或文字变色,都得写两组CSS:一组hover卡片,一组hover卡片的子元素。现在可以直接用:has()去影响卡片本身,然后再通过后代选择器让内部元素联动。
比如一张商品卡片,希望鼠标悬停时整个卡片加上边框,并且价格文字变红:
<div class="card">
<img src="product.jpg" alt="">
<h3>无线耳机</h3>
<p class="price">¥399</p>
</div>
CSS可以这么写:
.card {
border: 1px solid transparent;
transition: border-color 0.3s;
}
.card:hover {
border-color: #ff5500;
}
.card:hover .price {
color: #ff5500;
}
这个不用:has()也能实现。但如果你想在鼠标悬停到卡片内部某个按钮时,让卡片边框一起变化,那通常只能给按钮外层的父级加事件。现在有了:has(),可以这么做:
.card:has(.buy-btn:hover) {
border-color: #ff5500;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
意思是:当卡片内的.buy-btn被悬停时,卡片本身也呈现出边框和阴影。这种联动效果用以前的手段几乎必须上JS,现在CSS就完事了。
案例4:导航栏 sticky 状态切换不用JS
有一个很常见的交互:页面顶部导航栏在初始状态是半透明的,一旦向下滚动到内容区某个标题后,导航栏背景变成不透明并加上阴影。传统做法要监听scroll事件,计算scrollTop。其实借助:has()也能做一个简单版的。
前提是你的页面结构里有一个可以被观测到的元素,我们假设它是#page-content。用法是利用:has()和:target或者IntersectionObserver?可惜:has()不能直接监听滚动位置。但是我们可以用“顶部的导航栏”来观察自己是否“遮住”了某个元素,这需要一些额外的CSS技巧。实际场景中,有一种做法是结合Sticky定位和:has()来增强某个状态。比如很多网站会固定一个顶部进度条,显示阅读进度。用:has()可以做到:当正文区域变得比视口矮时,隐藏侧边栏。
不过这个案例用得有点绕,我觉得不如前面几个直观。这里就不展开讲了。我更想说的是,:has()在“状态切换”上的意义不是直接监听scroll,而是让组件根据自身的内部内容或内部的选中状态来决定外部样式。用它来做tab切换很顺手。
案例5:纯CSS Tab切换
Tab切换通常需要JS去切换class。现在用:has()配合radio按钮也能实现,而且不需要写一堆事件。
结构大概是这样:
<div class="tabs">
<input type="radio" name="tab" id="tab1" checked>
<label for="tab1">热门</label>
<input type="radio" name="tab" id="tab2">
<label for="tab2">最新</label>
<div class="panel panel1">热门内容</div>
<div class="panel panel2">最新内容</div>
</div>
然后CSS用:has()根据选中的radio来切换面板的显示:
.tabs:has(#tab1:checked) .panel1 {
display: block;
}
.tabs:has(#tab2:checked) .panel2 {
display: block;
}
.panel {
display: none;
}
/* label高亮样式 */
.tabs:has(#tab1:checked) label[for="tab1"],
.tabs:has(#tab2:checked) label[for="tab2"] {
background: #eee;
}
这里#tab1:checked就是“当radio被选中时”,然后外层.tabs因为包含这个被选中的radio,就会触发面板显示。效果跟JS写的一样,而且连键盘操作和状态保持都是浏览器原生支持。
不过需要注意,这种写法需要隐藏掉radio本身,不然页面上会有小圆点。你肯定想知道怎么隐藏,用position:absolute; opacity:0即可,但这不是本文重点。
这个案例的扩展空间很大
你可以把radio换成checkbox,做成折叠菜单;也可以组合多个:has()实现更复杂的联动。比如一个表单里,只有勾选了“其他”选项,才出现一个文本输入框:
<div class="wrapper">
<label>
<input type="checkbox" id="other">
<span>其他</span>
</label>
<input type="text" class="other-input" placeholder="请输入">
</div>
.wrapper:has(#other:checked) .other-input {
display: block;
}
.other-input {
display: none;
}
这种纯CSS的条件交互完全可以替代简单JS中的show/hide操作。
实用功能和注意点
有一些小细节,如果你刚上手:has(),很容易踩坑。我把它写出来避雷:
- 别用来做全页面级别的全局选择。比如
body:has(...),因为浏览器在计算页面样式时,使用:has()可能会影响性能,尤其是页面很大很复杂的时候。尽量缩小使用范围,加一个容器类限定。 - 不要让
:has()嵌套太深。比如div:has(div:has(span))这种虽然能跑,但性能堪忧。能拆开就拆开。 - 它和样式权重的关系。伪类本身的权重跟普通的类选择器一样,所以如果你想让
:has()覆盖其他样式,可能需要写得更具体。举个例子:.a:has(.b) .c的权重等于.a .c加上`.b`的权重,反正具体可查,别搞混。 - 不要用它替代所有JS逻辑。比如拖拽排序、复杂交互,还得老老实实写脚本。但状态切换这种活,用
:has()很合适。
写在最后
我自己的感受是,:has()真正强大的地方不在于“选父元素”,而在于它把“元素内部的状态”变成了一种可以驱动外部样式的机制。以前你用JS去根据状态加class,但是现在你用CSS就能根据状态自动调整。这减轻了前端的逻辑负担,也让代码更不易出错。
尤其是配合表单和交互组件时,以前得写十几个事件监听器的地方,现在一个:has()就完成了。如果你还没在项目里用过它,建议找个简单的小组件试试手,相信我你会回来把它用满的。

