CSS 子元素满足某个条件如何改变父元素样式?
input:checked 改成父元素边框高亮、img 存在时父容器切两栏布局、表单校验状态动态联动——以前这些都需要 JS 去监听子元素状态再回头改父元素。:has() 的出现,把"子元素状态驱动父元素样式"这件事直接搬进了 CSS。
- 核心能力:
:has()是名副其实的父选择器,根据子元素/后代/兄弟的状态反向匹配父元素或前面的兄弟。 - 浏览器覆盖:Chrome 105+、Safari 15.4+、Firefox 121+ 已全部支持,2024 年底起可以在生产环境放心用。
- 性能真相:锚定到具体类(
.card:has(img))的开销是微秒级;只有div:has(...)这种宽泛选择器 + 频繁 DOM 变更时才是问题。 - 能用 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;
}
}