树欲静而风不止
叙述前端的戎码生涯

一个:has()顶十段JS 16个能直接抄的CSS实战技巧

用了十几年 CSS,我们一直在抱怨同一件事:只能从父选 子,不能从子反选父。直到 :has() 出现,这条祖训被彻底打破。

一、那个困扰前端十年的难题

先来感受一下痛点。假设你要实现:当一个卡片里有图片时,给卡片加上边框

过去你只能这样写 JS:

document.querySelectorAll('.card').forEach(card => {
  if (card.querySelector('img')) {
    card.classList.add('has-image');
  }
});

绑定、判断、加类名……一套流程下来十几行。而现在,一行 CSS 搞定:

.card:has(img) {
  border: 2px solid #0052d9;
}

翻译成人话就是:「选中那些内部包含 img.card:has() 让选择器第一次拥有了「向上看」的能力。


二、:has() 到底怎么读?

记住一个万能句式:

A:has(B) = 「包含 B 的 A」

注意,最终被选中、被加样式的是 A(父级),而不是 B。这正是它颠覆性的地方——CSS 史上第一个真正的「父选择器」。

/* 包含被选中复选框的 label */
label:has(input:checked) { color: green; }

/* 后面紧跟着 p 的 h2 */
h2:has(+ p) { margin-bottom: 0; }

/* 不包含图片的文章 */
article:not(:has(img)) { padding: 20px; }

它还能和 +~> 等组合器自由搭配,表达「兄弟关系」「相邻关系」,灵活得超乎想象。


三、16 个让你直呼「真香」的实战场景

场景 1:表单校验提示

输入框为空且失焦后,让整个表单组变红:

.field:has(input:invalid) {
  border-color: #e34d59;
}
.field:has(input:invalid) .tip {
  display: block; /* 显示错误提示 */
}

以前要监听 blurinput 事件,现在 CSS 自己就能感知校验状态。

场景 2:图文混排自适应

文章里图片就用两栏布局,没有就用单栏:

.post:has(img) { display: grid; grid-template-columns: 1fr 1fr; }
.post:not(:has(img)) { display: block; }

场景 3:下拉菜单展开态

.menu:has(.submenu:hover) {
  background: #f5f7fa;
}

鼠标移到子菜单时,父菜单同步高亮,无需任何 JS。

场景 4:数量驱动的样式

配合 :nth-child 还能实现「当列表项超过 5 个时换个样式」这种过去想都不敢想的逻辑:

ul:has(li:nth-child(6)) {
  columns: 2; /* 6 项及以上自动分两列 */
}

场景 5:弹窗打开锁住背景滚动

过去要靠 JS 给 bodyoverflow:hidden,现在一行 CSS 感知 dialog 状态:

body:has(dialog[open]) {
  overflow: hidden;
}

场景 6:图片未加载时显示占位

figure:has(img:not([src])) {
  background: #f5f7fa url('placeholder.svg') center / 48px no-repeat;
}

src 还没填上时显示占位图,加载完自动消失。

场景 7:行聚焦,hover 一行上方各行变暗

表格里鼠标移到某一行时,让它上面的所有兄弟行变淡,突出当前行:

.row:has(~ .row:hover) {
  opacity: .5;
}

这种「兄弟联动」的效果,以前必须靠 JS 遍历计算,现在纯 CSS 就能优雅实现。

场景 8:步骤条自动标记已完成

后面存在 .active 步骤的,说明前面这些都已完成,自动标绿:

.step:has(~ .step.active) {
  color: #00a870;
}
.step:has(~ .step.active)::before {
  content: '✓';
}

场景 9:搜索框聚焦放大父容器

输入框获得焦点时,让整个搜索框容器「弹」起来:

.search-box:has(input:focus) {
  transform: scale(1.02);
  box-shadow: 0 4px 16px rgba(0,0,0,.1);
}

场景 10:空状态自动提示

列表里一个子项都没有时,自动显示「暂无数据」提示,再也不用 JS 判断 length === 0

.list:not(:has(.item)) .empty-tip {
  display: block;
}

场景 11:必填项自动标红星

表单组里含 required 输入框时,自动给 label 加上红色星号:

.field:has(input:required) label::after {
  content: ' *';
  color: #e34d59;
}

场景 12:一个开关切整页暗色

页面里的暗色开关被勾选时,整页切换为暗色主题,零 JS:

body:has(#dark-toggle:checked) {
  background: #1a1a1a;
  color: #eee;
}

场景 13:全选框联动

表格里所有行都勾选时,让表头高亮提示「已全选」:

.table:not(:has(tbody input:not(:checked))) thead {
  background: #f0f6ff;
}

读法:「不存在未勾选的行」→ 说明全部勾选了。

注意:这种写法在表格一行都没有时也会命中(「不存在未勾选行」同样成立)。如果担心空表格被误高亮,再加一个「至少有一行」的条件即可:

.table:has(tbody input):not(:has(tbody input:not(:checked))) thead {
  background: #f0f6ff;
}

场景 14:卡片选中态高亮

卡片内 radio 被选中时整张卡片高亮,套餐、支付方式选择常用:

.plan-card:has(input:checked) {
  border-color: #0052d9;
  background: #f0f6ff;
}

场景 15:图片加载失败兜底

图片加载出错时,在父容器层显示备用占位背景(配合 onerror 打上标记后一劳永逸):

figure:has(img.broken) {
  background: #f5f7fa url('fallback.svg') center / 64px no-repeat;
}

场景 16:相邻标题自动压缩间距

h2 后紧跟着 h3 时压缩上下间距,纯排版优化,免手写一堆 margin 调整:

h2:has(+ h3) {
  margin-bottom: 4px;
}

四、两条避坑提醒

1. 兼容性已经 OK 了。 截至 2026 年,Chrome、Safari、Firefox、Edge 全部支持 :has(),主流浏览器覆盖率超过 93%。生产环境放心用,老旧浏览器可用 @supports selector(:has(*)) 做降级。

@supports selector(:has(*)) {
  .card:has(img) { border: 2px solid #0052d9; }
}

2. 别滥用导致性能问题。:has() 的匹配开销比普通选择器大,尤其是写出 *:has(...) 这种无限定范围的选择器时。给它一个明确的父级标签或类名,浏览器就能高效命中。

/* ❌ 范围太大,浏览器要遍历所有元素 */
*:has(> img) { ... }

/* ✅ 限定清晰,性能友好 */
.gallery:has(> img) { ... }

五、写在最后

:has() 的意义远不止「少写几行代码」。它把过去必须用 JS 处理的「状态联动」搬回了 CSS——更声明式、更易维护、性能也更好。

一句话总结:凡是「子元素的状态要影响父元素或兄弟元素样式」的需求,先想想能不能用 :has() 解决。 你会发现,项目里的很多 JS 其实都可以删掉了。

下次写交互样式时,不妨先问自己一句:「这个,:has() 能搞定吗?」

赞(0)
未经允许不得转载:OveUI » 一个:has()顶十段JS 16个能直接抄的CSS实战技巧
分享到