上星期在做一个后台管理系统,遇到个需求:表单里的输入框如果校验不通过,要红边框加红色提示文字。刚开始我打算用 JS 去监听 input 事件,然后 toggle class。后来一想,不对,这不就是 CSS 能干的活吗?直接上 :has() 选择器,一行代码搞定,还不用写 JS 逻辑。
今天我就把这几个我在项目里真实用过的 :has() 场景写出来,每一个都有完整代码,直接复制就能用。
先搞清楚 :has() 是个啥
其实它就是个选择器,作用是根据元素内部的状态来选它自己。举个例子,div:has(span) 就是选中里面包含 span 的 div。咋一听很拗口,但用熟了你就会觉得它就是 CSS 版的“包括”或“存在”。
浏览器支持已经不需要担心了,Chrome 105、Safari 15.4、Firefox 121 之后全绿,用户基本都覆盖到。
案例1:表单字段校验状态样式
我之前写了一个注册表单,里面有邮箱和手机号两个必填项。需要实现:输入合法时输入框边框变绿,非法时变红,标签文字也要变色,并且不用 JS 去盯着。
<form class="signup-form">
<div class="field">
<label for="email">邮箱</label>
<input type="email" id="email" required>
</div>
<div class="field">
<label for="phone">手机号</label>
<input type="tel" id="phone" required pattern="[0-9]{11}">
</div>
</form>
CSS 这么写就完事了:
.field:has(input:invalid) label {
color: #e44d4d;
}
.field:has(input:invalid) input {
border-color: #e44d4d;
background: #fff8f8;
}
.field:has(input:valid) label {
color: #2e9e5b;
}
.field:has(input:valid) input {
border-color: #2e9e5b;
}
看,当输入框是 :invalid 状态时,父级 .field 的 label 和 input 都会变红。浏览器自带的 HTML5 校验完全够用,根本不需要写 JS 去判断正则。我后来把这个套路用在所有表单页面上,省了不少事。
案例2:卡片悬停时让图片和文字同时响应
产品经理要求做一个卡片列表,鼠标悬停时卡片本身要微微上浮,同时卡片里的图片要放大一点。以前你要给卡片加 mouseenter 和 mouseleave 事件,然后操作 CSS class,现在写好就好。
<div class="card">
<div class="card-img">
<img src="photo.jpg" alt="示例图">
</div>
<div class="card-body">
<h3>卡片标题</h3>
<p>一段描述文本</p>
</div>
</div>
.card {
transition: transform 0.3s, box-shadow 0.3s;
}
.card:has(.card-img:hover) {
transform: translateY(-6px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.12);
}
.card:has(.card-img:hover) .card-img img {
transform: scale(1.05);
}
.card-img {
overflow: hidden;
}
.card-img img {
transition: transform 0.3s;
display: block;
width: 100%;
}
这里用到了 :has(.card-img:hover) 去判断鼠标是否悬停在图片区域。一开始我以为这么写会不会有性能问题,实测下来完全没感觉。而且这个写法特别容易维护,后续加其他交互只需要在 :has() 里加条件就好。
案例3:列表项数量不同,布局自动变化
后台有一个数据看板,要展示最近的热搜标签。有时候只有两三个,有时候十来个。我希望标签数量少的时候单行显示,数量多了就自动换行。这个用 :has() 配合 :nth-child 就能做到。
<ul class="tag-list">
<li>热门</li>
<li>爆款</li>
<li>特惠</li>
<li>新品</li>
<li>推荐</li>
</ul>
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* 当列表里的 li 在第四个及以上时,换行并限制最大宽度 */
.tag-list:has(li:nth-child(4)) {
max-width: 320px;
}
/* 当 li 数量超过 6 个时,变成两列网格 */
.tag-list:has(li:nth-child(6)) {
display: grid;
grid-template-columns: repeat(2, 1fr);
max-width: 460px;
}
这里有个小技巧::has(li:nth-child(4)) 意思是“存在至少4个 li”。如果你想知道是不是正好4个,可以结合 :not(:has(li:nth-child(5))) 来写,不过实际业务中“大于等于”比“等于”更常用。
案例4:模态框打开时锁住背景滚动
做弹窗的时候总有一个需求:弹窗出现后,后面的页面不能滚动。以前我们经常在 JS 里给 body 加 overflow: hidden,现在用 :has() 也能管。前提是模态框和 body 在同一个 DOM 树里,且模态框状态由 class 控制。
<body>
<div class="page-content">
这里是背景内容
</div>
<div class="modal-overlay hidden">
<div class="modal-box">
弹窗内容
</div>
</div>
</body>
当我去掉 .hidden 类时,body 就应该锁定滚动:
body:has(.modal-overlay:not(.hidden)) {
overflow: hidden;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
}
.modal-overlay.hidden {
display: none;
}
你只需要在 open 时移除 hidden,在 close 时添加回去,剩下的 CSS 自动处理滚动锁定,不需要写多余的 JS 去改 body 样式。
不过要留意一个坑:如果 body 里面没有直接包含模态框,而是通过组件渲染,:has() 是跨组件选不了的。这种场景还是老老实实 JS 吧。
案例5:导航菜单当前项的高亮指示
导航栏经常用 ul 和 li 来实现。以前要高亮当前菜单,往往在生成标签时判断一下当前路径,然后给当前 li 加个 active 类。其实可以用 :has() 根据 a 的 href 或者特定状态来高亮父级 li。这里我就用 aria-current 当一个例子。
<nav>
<ul>
<li><a href="/" rel="external nofollow" >首页</a></li>
<li><a href="/docs" rel="external nofollow" aria-current="page">文档</a></li>
<li><a href="/about" rel="external nofollow" >关于</a></li>
</ul>
</nav>
nav li:has(a[aria-current="page"]) {
background: #f0f0f0;
border-radius: 4px;
}
nav li:has(a[aria-current="page"]) a {
color: #07c;
font-weight: 600;
}
这样服务端渲染时只需要输出正确的 aria-current,样式就自动匹配,无需额外写 class。
再说点避坑经验
:has() 看起来美好,但用的时候有几个地方要小心。
优先级不一样。 ul:has(li) 的优先级等于 ul li 加上一个伪类。所以如果你在另外地方写了更具体的样式,可能会覆盖掉 :has() 的效果。复杂页面里最好用更明确的选择器,比如 .tag-list:has(li:nth-child(6))。
不能嵌套太多层。 比如 div:has(span:has(b)),这在 Chrome 上没问题,但你得考虑维护成本。我自己写代码的时候如果超过一层 :has,就会反思是不是该换别的写法。
性能问题不用太担心,但别在动画里用。 如果你做一个持续过渡的动画,同时用 :has() 去判断某个内部状态,可能会引起额外的重算。实测下来平时点击、悬停都没事,但别把这东西用在 requestAnimationFrame 驱动的动画上。
总结
说实话,掌握 :has() 之后,我写交互样式的速度提升了不少。以前要写事件监听、改 DOM class,现在CSS里就能把这个逻辑表述清楚。而且代码可读性还更好,看到 :has(input:invalid) 你就知道这一段是关于输入校验的样式。
希望这几个案例能给你启发,不用全盘照搬,挑一两个用到你的项目里试试,应该会回来感谢我的。

