Skip to main content

CSS 子元素满足某个条件如何改变父元素样式?

· 6 min read

input:checked 改成父元素边框高亮、img 存在时父容器切两栏布局、表单校验状态动态联动——以前这些都需要 JS 去监听子元素状态再回头改父元素。:has() 的出现,把"子元素状态驱动父元素样式"这件事直接搬进了 CSS。

  1. 核心能力:has() 是名副其实的父选择器,根据子元素/后代/兄弟的状态反向匹配父元素或前面的兄弟。
  2. 浏览器覆盖:Chrome 105+、Safari 15.4+、Firefox 121+ 已全部支持,2024 年底起可以在生产环境放心用。
  3. 性能真相:锚定到具体类(.card:has(img))的开销是微秒级;只有 div:has(...) 这种宽泛选择器 + 频繁 DOM 变更时才是问题。
  4. 能用 CSS 就别用 JS:表单校验态切换、空状态检测、数量查询这些场景,:has() 替代 JS 后代码量减半、不会漏同步。

性能提醒:has() 选择器锚定越具体越好,避免 body:has()*:has() 这种全局监听。


十几年来,CSS 一直有个明显的短板:你能从父元素选中子元素,但没法反过来——子元素满足某个条件时,影响父元素的样式。这个需求太常见了,比如表单输入校验时改边框颜色、卡片里有图片时切两栏布局、选中某个选项时高亮整个区块。以前只能靠 JS 去监听子元素状态变化,再用 classList 给父元素加 class。

:has() 的选择彻底改变了这件事。

它能做什么

最基本的用法:父元素包含某个子元素时改变自身样式。

/* 卡片里有图片时切两栏 */
.card:has(img) {
display: grid;
grid-template-columns: 200px 1fr;
}

/* 表单组有校验错误时边框变红 */
.form-group:has(input:invalid:not(:placeholder-shown)) {
border-color: #e74c3c;
}

/* 菜单里有 active 子项时高亮 */
.menu-item:has(.active) {
background: var(--highlight-bg);
}

不只是子元素,+~ 相邻兄弟选择器也能用:

/* label 后面紧跟 required input 时加星号 */
label:has(+ input:required)::after {
content: " *";
color: red;
}

/* h1 后面有 p 时减小下边距 */
h1:has(+ p) {
margin-bottom: 0;
}

说白了,selector:has(relativeSelector) 的意思是:选中 selector,前提是它包含/相邻满足 relativeSelector 的元素。匹配结果永远是 selector 本身,括号里的条件只是筛选逻辑。

不用 JS 做表单校验态切换

以前写表单校验,大概是这样的:

// 每个 input 都要绑 blur/input 事件
input.addEventListener('input', () => {
if (input.checkValidity()) {
formGroup.classList.add('valid');
formGroup.classList.remove('invalid');
} else {
formGroup.classList.add('invalid');
formGroup.classList.remove('valid');
}
});

一个表单十个字段就写十遍。用 :has()

.form-group:has(input:valid:not(:placeholder-shown)) {
--border-color: #27ae60;
}

.form-group:has(input:invalid:not(:placeholder-shown)) {
--border-color: #e74c3c;
--hint-display: block;
}

一行 JS 都不需要。关键是它不会漏同步——JS 方案里你加一个字段忘绑事件,那个字段的校验态就永远是初始状态;:has() 是浏览器原生支持的匹配,只要 DOM 状态变了,样式一定跟上。

数量查询(Quantity Queries)

根据子元素数量决定容器样式,以前是做不到的。

/* 子项 ≥5 个时改为三列 */
.grid:has(> :nth-child(5)) {
grid-template-columns: repeat(3, 1fr);
}

/* 子项 ≥10 个时进一步调整 */
.grid:has(> :nth-child(10)) {
grid-template-columns: repeat(4, 1fr);
}

列表页、商品网格、标签云这些场景特别实用——列表内容多时自动调整布局密度,不用写 JS 去数 .length

空状态检测

/* 购物车为空 */
.cart:not(:has(.item))::after {
content: '购物车是空的';
display: flex;
justify-content: center;
color: #999;
}

/* 搜索结果为空时隐藏筛选栏 */
.filters:not(:has(+ .results .result-item)) {
display: none;
}

性能:到底能不能放心用?

一句话结论:锚定到具体类的 :has() 开销是微秒级,完全不用担心。

实测数据——Chrome Profiler 中 :has(> .sidebar) 耗时约 0.005ms,has(> .sidebar) > :not(.sidebar) 约 0.002ms。你的页面里几百个这样的选择器加起来也不到一个 requestAnimationFrame 的时间。

真正会出问题的情况:

  • 选择器太宽泛——div:has(img).card:has(img) 慢几个数量级,因为浏览器需要在每个 div 上评估匹配条件
  • 锚定到全局容器——body:has(...)*:has(...) 会导致页面所有 DOM 变更都触发重新匹配
  • 深层嵌套的条件——.page:has(.a .b .c .d) 里的后代选择器本身就贵
  • 频繁 DOM 变更——比如在滚动容器里放 :has(),每次插入新元素都触发大量匹配

最佳实践很简单::has() 锚定到有意义的 class 上.card:has(img) 是好的,div:has(img) 是坏的。这和 CSS 最佳实践本身是一致的——本来就不该用标签选择器去写业务逻辑。

如果还是不放心,用 @supports 做渐进增强:

/* 不支持 :has() 的浏览器降级到单列布局 */
.card {
display: block;
}

@supports selector(:has(*)) {
.card:has(img) {
display: grid;
grid-template-columns: 200px 1fr;
}
}

什么时候用 :has() 而不是 JS

场景:has()JS
纯视觉联动(校验态、高亮)过度工程
需要修改数据/DOM 结构
复杂业务逻辑判断
响应子元素数量的自适应布局维护成本高

视觉状态联动用 CSS,数据和结构变更用 JS。 这个分界线很清晰,跨过去就是给自己找麻烦。

:has() 不是银弹,但它把"子元素驱动父元素样式"——这个过去十几年只能用 JS hack 的需求——变成了 CSS 原生能力。2024 年底之后,新项目里可以默认用它。

References

  1. MDN: :has() CSS pseudo-class —— Mozilla
  2. Case studies of :has() in e-commerce —— Chrome for Developers
  3. A revisit of the Every Layout sidebar with :has() and selector performance —— Andy Bell, Piccalilli
  4. Quantity Queries are Very Easy with CSS :has() —— Frontend Masters