最近在重复开发几个后台表单的时候,被一个旧逻辑膈应到了。为了给输入框加一个“错误”状态,我得在JS里监听input事件,然后给父容器加一个class,再根据这个class去改边框颜色。接着我又得去处理清除错误、焦点状态、验证逻辑…… 繁琐得让人头大。
后来我试着用:has()去重构了一下,发现很多状态其实可以在CSS层直接“推导”出来,JS只需要触发原生事件就好。真有种脑子被打开的感觉。
从父选择器到状态选择器
:has()刚出的时候大家都叫它“父选择器”,因为它确实能让你根据子元素选中父元素。但用多了你会发现它更像个“状态依赖选择器”——只要某个元素里出现了我们关心的状态,父元素、兄弟元素都能被选中。
拿一个非常常见的表单举例:
<div class="field"> <label for="mail">Email地址</label> <input type="email" id="mail" required> <span class="error">请输入正确的邮箱格式</span> </div>
按照以前的思路,输入框不符合格式时,我们可能要从JS里给这个div加个“has-error”类。现在有了:has(),可以直接用:invalid状态来驱动:
<style>
.field:has(input:invalid:not(:placeholder-shown)) {
border-left: 3px solid #d44;
}
.field:has(input:invalid:not(:placeholder-shown)) .error {
display: block;
}
</style>
看到没?我不需要在JS里同步状态,只要用户输入了不合法内容,这一组规则就会自动命中。省掉的是加class、减class、判断时机的麻烦。
注意,我用了:placeholder-shown来排除“空值但不提示”的情况。这里纯粹是思路上的区别——我们把“数据状态”交给了浏览器,而不是交给JS来手动记录。
真正的“响应式布局”是看内容,不是看屏幕
做卡片列表时,常常会遇到“每行到底显示几个”的问题。传统做法是用媒体查询,根据屏幕宽度去猜。但是卡片里面的文字长短、图片比例、标题行数等等因素,都会影响布局效果。
我最近写了一种更贴近内容的写法:使用容器查询+子元素数量判断。容器查询负责尺寸,:has()负责“数量选择”。
假设现在有一个容器,里面可能放3张卡片,也可能放5张。我希望卡片数量多的时候让每张卡片占的宽度小一点。
<div class="card-wrap"> <article class="card">...</article> <article class="card">...</article> <article class="card">...</article> <article class="card">...</article> </div>
那么可以用这样的CSS:
<style>
.card-wrap {
container-type: inline-size;
display: grid;
gap: 1rem;
}
/* 默认每行1个卡片 */
.card { grid-column: 1; }
/* 至少3个卡片时,每行2个 */
.card-wrap:has(.card:nth-child(3)) .card {
grid-column: span 1;
}
/* 至少4个卡片时,每行3个 —— 利用容器宽度 */
@container (min-width: 600px) {
.card-wrap:has(.card:nth-child(4)) .card {
grid-column: span 1;
grid-template-columns: repeat(3, 1fr);
}
}
</style>
这个例子可能不是最优雅的,但我想表达的是:我们可以基于“实际渲染了多少个子元素”来调整布局,而不是预定义一堆断点。尤其适合博客标签、分类列表这种元素数量不固定的场景。
面包屑或导航栏的动态分割线
之前做导航菜单时,如果菜单项很多,我想让最后一个菜单项跟前面的视觉上区分开。通常是用:last-child,但如果菜单是动态生成的,还可能有隐藏项,就比较麻烦。
:has()能轻易地判断“某个元素后面还有没有兄弟元素”。比如我想让除最后一个以外的菜单项都要右边距:
<nav class="menu"> <a href="#" rel="external nofollow" rel="external nofollow" rel="external nofollow" >首页</a> <a href="#" rel="external nofollow" rel="external nofollow" rel="external nofollow" >文章</a> <a href="#" rel="external nofollow" rel="external nofollow" rel="external nofollow" >关于</a> </nav>
用:has()我可以反着写:选中那些“后面还有兄弟a”的a,然后给它们加分隔线:
<style>
.menu a:has(+ a)::after {
content: "/";
margin-left: 8px;
color: #c0c0c0;
}
</style>
这里面+是相邻兄弟选择器,配上:has()就代表“这个a后面跟着另一个a”。这个写法比:not(:last-child)直观得多,也更符合自然语言:“后面还有东西的就加斜杠”。
配合状态,做一套不用JS的暗黑模式开关
现在提到暗黑模式,大多数方案都是通过给html根节点加一个class来切换。你仍然需要写两条变量分支。但有没有想过,我们可以不切换class,而是利用本地的某个开关状态去改变整页主题?
我的思路是:在页面顶部放一个隐藏的checkbox,然后整个页面的主题由它来驱动。这一步不需要在html上加任何class,也不用JS去监听。
<input type="checkbox" id="darkModeToggle" hidden> <body> <header>页面头部</header> <main>所有内容</main> <footer>充数的底部</footer> </body>
配合:has()就能做到:
<style>
/* 默认亮色 */
html {
--bg: #ffffff;
--text: #1a1a1a;
}
/* 当body下某个checkbox被勾选中时,切换全局变量 */
body:has(#darkModeToggle:checked) {
--bg: #1a1a1a;
--text: #f0f0f0;
}
body {
background-color: var(--bg);
color: var(--text);
}
</style>
是不是很神奇?checkbox虽然藏起来了,但只要它被选中,整个body里的所有var()都会重新取到暗黑变量的值。这个模式很适合那种不需要记住用户偏好的小工具页面,代码量少到可怜。
当然,真正做产品暗黑模式肯定要配合localStorage和媒体查询。但这个小技巧可以让你在原型阶段快速体验暗黑效果,不用碰一行JS。
踩坑小记
:has()虽然强大,但也不是万能的。我实际用下来有几个注意点:
- 性能上要避免在容器上添加极其复杂的:has()条件,因为浏览器会重新计算匹配范围。幸好常规业务树不大,感知不明显。
- 伪元素本身无法被:has()直接选中,因为它不是真实元素。比如你无法写:.card:has(::after)。
- 在Safari 17.2之后都已经支持了,但一些旧浏览器比如Chrome 105之前的版本不支持,如果用户环境较旧,还是要谨慎。
如果你最近也在做表单校验、复杂导航、自适应布局,不妨试试用:has()把状态“硬编码”进去。写完你也会发现,以前随手写的class操作确实有点多余。
今天的分享就到这里了。这个特性的热度其实从推出至今就没降过,但真正把它用起来的项目真的不多。我总觉得:“CSS越写越好,JS越写越少”才是前端未来的样子。

