以前写CSS,总有种被“祖先选择器”压着抬不起头的感觉。你想根据子元素的状态去控制父元素的样式,只能给父元素加class,或者用JS在事件里来回加。2023年之前,我基本默认“样式只能向下追溯”。但自从各大浏览器都支持了:has()之后,我发现CSS终于开始向上看了。
很多人认识:has()是从“父选择器”这个外号开始的,但我觉得它的能力远远不止“选父元素”。如果你只拿它来高亮一个有图片的卡片,那实在浪费。今天我用几个实战片段,带你看看怎么用:has()解决一些以前必须写JS的交互问题。保证每一个案例你都能用到实际项目里。
一、记一次和:has()的初遇
第一次在同事代码里看到.card:has(img)这种写法,我愣了一下。因为.card后面不是跟空格,不是跟>,而是冒号加has括号,里面又是一个选择器。我下意识觉得“这莫非是jQuery的has?” 试一下发现,它真的就是“如果这个元素内部有img,就选中这个元素本身”。
于是那天下午我重构了一个产品列表页。原来的列表有统一高度的图片占位,有些卡片没有图片,CSS里我写了一大堆.card.no-image这种类名。重构完以后,我把类名全删了,直接:
.product-card:has(.thumbnail) { padding-bottom: 0; }
没有了图片的卡片自动就去掉了多余的内边距。整个代码清爽到同事以为我把逻辑搬到后端去了。
二、最实用的案例:表单输入反馈
HTML5有:required、:valid、:invalid,但以前你很难把这些状态作用到整个表单项或者父容器上。比如用户在一个输入框里填了错误的内容,你希望整个输入框的外边框变红,同时旁边显示一个错误图标。以前你要写JS监听input事件,或者用兄弟选择器,但错误提示和输入框往往是相隔不定的。
现在用:has(),你可以这样:
.field:has(input:invalid) {
border: 1px solid #e74c3c;
}
.field:has(input:valid) {
border: 1px solid #27ae60;
}
配合HTML结构:
<div class="field">
<label for="email">邮箱</label>
<input type="email" id="email" required>
<span class="error-msg">邮箱格式不对</span>
</div>
当输入的内容合法,边框变绿;非法则变红。你可能会问:那错误信息怎么让它显示出来?用:has()也可以控制兄弟元素。这里需要一点小技巧,我们可以把错误信息藏在某处,利用:has()配合sibling?但:has()不能直接影响兄弟,只能影响容器。所以更常见的做法是让错误信息存在于容器内,但默认隐藏,容器有invalid状态时显示:
.field .error-msg {
display: none;
}
.field:has(input:invalid:not(:placeholder-shown)) .error-msg {
display: block;
}
注意这里用到了:not(:placeholder-shown),意思是用户输入了内容但非法,才显示错误提示,避免用户一进页面就满屏红。
如果你不想依赖父级,也可以把错误信息放后面,但CSS只能影响当前元素和后代,以及后来的一些伪类。用:has()还是最舒服的。
三、卡片列表的“有图”和“无图”布局
产品列表、文章列表经常会出现“有些卡片有大图,有些卡片只有文字”的情况。以前我们给卡片加个类名,或者用JS检测图片是否存在。现在可以直接用:has(img)来决定卡片是横向布局还是纵向布局。
.card {
display: flex;
flex-direction: column;
}
.card:has(> img) {
flex-direction: row;
align-items: center;
}
.card:has(> img) .card-body {
padding-left: 16px;
}
这样的话,有图片的卡片图片在左边,纯文字的卡片内容在下方。无论从后端返回的数据怎么变化,前端样式都能自动适配。我看过很多项目里为了这个效果特意在接口里返回了一个hasImage的布尔值,其实CSS自己就能判断。
如果你觉得这样误判可能性大(比如卡片里有装饰图但不算主图),你可以用更精确的子选择器,例如.card:has(.cover-image),这样就只认有封面的卡片。
四、根据子元素数量动态调整网格列数
以前要实现“如果列表里只有两个子元素,就让它们各占一半宽度;如果有三个,就各占三分之一”,我们需要用JS监听子元素数量,然后添加类名。现在很多新项目用容器查询解决了,但也不完全一样。
:has()可以和:nth-child等结合,实现简单的数量感知。比如一个父容器,里面直接子元素数量是2个还是3个,我们可以这样写:
.list {
display: grid;
grid-template-columns: 1fr;
}
.list:has(> :nth-child(2):last-child) {
grid-template-columns: 1fr 1fr;
}
.list:has(> :nth-child(3):last-child) {
grid-template-columns: 1fr 1fr 1fr;
}
这里面的妙处是选择器:last-child表示“当前节点是最后一个子元素”。当集合里恰好有2个元素时,第2个元素既是第2个也是最后一个,于是匹配。所以:has(> :nth-child(2):last-child)就表示“子元素数量正好为2”。同理,3个也一样。
这种写法在标签列表、商品sku选择等场景里很实用。你不用再给容器写data-count属性,CSS自己看得懂。
五、菜单状态:一个被忽略的好例子
很多导航菜单的展开状态还是靠JS,或者靠:focus-within。但:focus-within只能判断“内部是否有焦点”,想要根据内部某个特定子元素的展开状态来改变父级信息,用:has()更精准。
比如一个顶部的导航项,里面有一个下拉按钮,还有一个子菜单列表。当下拉按钮被激活(比如用:focus或者aria-expanded属性),我们希望整个导航项高亮。
.nav-item:has(button[aria-expanded="true"]) {
background: #f0f0f0;
font-weight: bold;
}
注意这里没用:focus-within,因为如果点击的是外部区域,焦点不在里面,但属性可能还是true。属性选择器靠的是状态,和焦点无关,很适合这种场景。
同样的,你可以写一个纯CSS的折叠面板,不用JS控制打开关闭:
.panel:has(input:checked) .panel-content {
display: block;
}
配合一个隐藏在标题里的复选框,就可以实现手风琴效果了。这种玩法很有怀旧风格,但确实能说明:has()能读取表单状态。
六、把:has()和容器查询放在一起,威力更大
容器查询(container query)允许你根据容器的宽度来改变样式,但它无法感知容器里面的内容结构。而:has()恰好可以弥补这一点。你可以根据容器内部是否有某个元件,来决定容器的显示逻辑,然后再次应用容器查询。
举个例子,一个新闻卡片组件,可以放在侧边栏(窄容器)也可以放在主内容区(宽容器)。当组件内部有图片时,我们希望它在宽容器下是横排,在窄容器下是竖排。这个需求用容器查询加:has()可以完美实现:
.news-card {
container-type: inline-size;
}
.news-card:has(img) .inner {
display: block;
}
@container (min-width: 400px) {
.news-card:has(img) .inner {
display: flex;
}
}
没有图片的卡片始终是竖排,有图片的卡片在容器宽度超过400px时变成横排。这种结合让组件真正做到了“内容感知”和“容器感知”,在项目里可以极大减少模板里的条件表达式。
七、注意性能,别把:has()当万能药
:has()理论上确实比以前的模拟写法慢一点点,尤其是嵌套使用或者在选择器非常复杂的时候。我在实际项目里测试过,现代浏览器对它做了非常多的优化,绝大多数场景下你根本感知不到性能问题。但如果你在一个页面里有几千个卡片,然后写了.card:has(.img):has(.body)这种花哨选择器,那还是会有一些计算成本的。
最好的做法是,尽量让:has()的选择器右边保持简单,比如直接用类名或者标签名,不要在里面再套一个:has()。同时避免在全局重置样式中使用它。
另外,兼容性方面,截至2025年,几乎所有的现代浏览器都支持了:has(),包括Chrome, Edge, Firefox, Safari。如果你还在维护IE时代的旧系统,那就只能老老实实加class了。但我觉得大家可以不必迁就那种老古董项目。
八、:has()还有一个常见误解
有些人认为:has()可以像选择器一样使用正则或者文本内容匹配,其实不能。它只能选择元素的结构关系,不能根据文本内容或数值来筛选。比如你还不能写li:has(text("99"))之类的。所以它只是一个结构上的选择利器,不能干更多魔法。
另外,:has()不能用在所有伪类里,比如你不能在::before里写:has(),它只能作为真正的选择器条件。
九、复盘:我第一次重构后代码变短了多少
我之前做过一个商品规格选择的页面。用户的交互是点击某个色块,然后对应的尺寸列表会变灰。原来代码里要维护一个选中的状态变量,然后根据它给列表容器加上一个class。用了:has()之后,我只需要让选中的radio和它后面的兄弟元素产生关联,然后通过.options:has(input:checked:disabled)来改变颜色。代码量直接砍了一半。
这种感觉就像是我本来需要用JS去“告诉”CSS发生了什么,现在CSS自己能看见了。写前端的时间大幅度转移到样式本身,而不是逻辑的搬运工。
十、写在最后
:has()不是第一个让CSS变得“有状态”的功能,但它是很关键的一个。它让我们可以在纯样式层描述出复杂的条件分支,把交互细节留在HTML和CSS中。以前我们常说HTML负责结构,CSS负责样式,JS负责行为。但现在这个界限被打破了,CSS也开始能决定“因为内部有什么,所以外面长什么样”。
当然,我不建议你用:has()去做所有事。复杂的业务状态依然需要JS和框架来管理。但是在那些“简单结构对应简单视觉”的场景里,能少写几行JS就少写几行,它会让你的代码更简洁,更少bug。毕竟,谁会觉得代码变少不是一件好事呢?

