目录
一、一个不需要 JS 的布局判断
先看一个很小的需求:卡片有封面图时横排,没有封面图时单列。
过去的写法通常有两种。第一种是在模板里判断,给卡片加一个 has-image 类:
<article class="card card--has-image">...</article>第二种是在客户端用 JavaScript 检查 querySelector("img"),再补上类名。两种写法都能用,但它们都引入了一个额外的状态:DOM 里有没有图是一份真相,卡片上有没有类是另一份真相。只要有人改动了其中一份而忘了另一份,样式就会和内容对不上。
:has() 把这层判断交给了样式引擎:
.card { display: grid; grid-template-columns: 1fr;}
.card:has(> .card__cover) { grid-template-columns: 160px 1fr;}<article class="card"> <img class="card__cover" src="/cover.avif" alt="" /> <div class="card__body"> <h2>标题</h2> <p>摘要</p> </div></article>封面图被 CMS 删掉,布局自动回到单列;封面图被加回来,布局自动横排。没有类名需要同步,也不需要等 hydration。
但这段代码真正值得讲的,不是「CSS 又多了一个功能」,而是它改变了三件事:谁在选择谁、选择器在什么时候失效、以及样式正确性由谁负责。 这三件事比语法本身更容易出错。
二、先把心智模型摆正:主语没有变
:has() 常被叫作「父选择器」。这个外号很形象,但不准确,而且会误导。
在 .card:has(> .card__cover) 里,被选中的元素仍然是 .card。:has() 只是给这个主语附加了一个条件:它的子树里存在一个直接子元素 .card__cover。主语没有跑到图片上,也没有「反向选中父元素」这回事。
规范对它的定义是::has() 接受一个 <relative-selector-list>,只要其中任意一个相对选择器以当前元素为锚点匹配到至少一个元素,当前元素就匹配。
这句话里有三个关键词:
- 相对选择器:参数里的选择器可以从一个组合符开始,省略组合符时默认是后代组合符。
- 任意一个:逗号分隔的列表是「或」,不是「且」。
- 以当前元素为锚点:条件始终相对于主语来解析。
所以下面这些写法都是合法的,而且方向各不相同:
/* 有后代 .badge */.card:has(.badge) {}
/* 有直接子元素 .badge */.card:has(> .badge) {}
/* 后面紧跟着 .note 兄弟 */.card:has(+ .note) {}
/* 后面有任意 .note 兄弟 */.card:has(~ .note) {}注意后两种::has() 不只看子树,还能看兄弟。说它是「父选择器」,等于把一半用法直接漏掉了。
和几个容易混淆的伪类对比一下:
| 写法 | 匹配谁 | 条件在哪里 |
|---|---|---|
.card:has(> img) | .card | 子元素 |
.card:is(.featured, .pinned) | .card | 自己身上的另一个类 |
.card:not(.featured) | .card | 自己身上没有某个类 |
.card:where(.featured) | .card | 自己,但特异性归零 |
:is()、:where()、:not() 都是在回答「这个元素自己是什么」;:has() 回答的是「这个元素和周围是什么关系」。它没有改变选择器的主语,只是把条件从元素自身扩展到了关系里。
这张图值得多看几眼:箭头从条件指回主语。理解了这一点,后面所有边界条件都会变得自然。
三、五个真实场景
1. 布局随内容翻转
回到开头的卡片。如果封面图和正文不是直接父子关系,而是包在 .card__media 里,只需要调整条件:
.card:has(> .card__media > img) { grid-template-columns: 160px 1fr;}这里用 > 而不是后代组合符,有两个好处:条件更精确,失效范围也更小。.card:has(img) 会把卡片里任何位置的图片都算进来,包括正文里的图标;> .card__media > img 只认封面槽位。
2. 表单状态:用 ARIA 属性做条件
表单里有一类状态很难只用 :invalid 表达:用户还没输入,字段为空,浏览器就已经把它标成 invalid 了。 如果直接写 .field:has(:invalid),页面一加载,所有必填项都会立刻变红——用户还没犯错,界面先报错。
一种做法是等应用把状态写到 ARIA 属性上,CSS 只负责消费:
<div class="field"> <label for="email">邮箱</label> <input id="email" type="email" aria-invalid="true" aria-describedby="email-error" /> <p id="email-error" class="field__error">请输入有效的邮箱地址</p></div>.field:has([aria-invalid='true']) { border-color: var(--color-danger);}
.field:has([aria-invalid='true']) > label { color: var(--color-danger);}这样做的额外好处是:同一个状态同时被样式和辅助技术消费。红色边框和 aria-invalid 来自同一个真相,不会出现「视觉上报错、读屏软件却不知道」的割裂。
如果你本来就在用原生约束校验,也可以考虑 :has(:user-invalid)。它只在用户交互之后才匹配,时序比 :invalid 合理。但不同浏览器对「什么时候算用户交互」的判定并不完全一致,涉及关键流程时,还是建议把状态显式写到 DOM 上。
这里有一个很容易踩的坑:display: none 的元素仍然参与选择器匹配。
<!-- 错误:错误提示始终在 DOM 里,只是被隐藏 --><p class="field__error" hidden>请输入有效的邮箱地址</p>如果你写 .field:has(> .field__error),这个条件会一直成立,因为 hidden 或 display: none 只影响渲染,不影响元素是否存在。想让 :has() 反映「有没有错误」,错误节点就必须按需渲染,而不是渲染后再藏起来。
3. 数量查询:根据子元素个数切换布局
标签列表只有两三个时适合横排,超过五个就该换行或收起。用 :nth-child() 可以表达「至少有几个」:
.tags { display: flex; flex-wrap: nowrap;}
/* 至少有 6 个子元素 */.tags:has(> :nth-child(6)) { flex-wrap: wrap;}> :nth-child(6) 的意思是「存在第 6 个子元素」,也就是「至少有 6 个」。它并不要求恰好 6 个。
如果要表达「恰好 3 个」,可以把「至少有 3 个」和「没有第 4 个」组合起来:
.tags:has(> :nth-child(3)):not(:has(> :nth-child(4))) { /* 恰好 3 个子元素 */}注意 :nth-child() 只数元素节点。文本节点、注释节点都不算。所以列表里夹着换行和空格不会影响判断,但如果模板里塞了一个占位的 <li>,它会被算进去。
4. 让容器感知内部焦点:多数时候不该用 :has()
下面这个写法很常见:
.panel:has(:focus-visible) { outline: 2px solid var(--color-focus);}它能工作,但这里不需要 :has()。:focus-within 表达的正是「自己或后代获得焦点」,是原生伪类,匹配成本更低:
.panel:focus-within { outline: 2px solid var(--color-focus);}这是一个判断标准:当 CSS 已经有原生伪类表达同一种关系时,优先用原生伪类。 :has() 应该留给那些没有现成伪类可用的结构性判断。
同理,.list:has(> li:hover) 通常也不如 .list:hover 或 :focus-within 合适。指针在列表项之间移动时,前者需要反复重新判断祖先是否匹配,后者只跟容器自身的状态有关。
5. 空状态:用兄弟选择器接住结果
列表为空时显示提示,是一个很适合 :has() 的场景:
.empty-state { display: none;}
.list:not(:has(> li)) + .empty-state { display: block;}:not(:has(> li)) 负责判断「列表没有子项」,后面的 + .empty-state 负责选中提示节点。这里的主语从 .list 切换到了 .empty-state,所以不需要让提示成为列表的子元素,也不需要用 JavaScript 去切 hidden。
同样要小心占位元素:如果空列表里始终保留一个 <li class="placeholder">,:has(> li) 永远为真。空状态判断依赖的是「真的没有元素」,不是「看起来是空的」。
四、三个会静默失效的语法边角
1. 逗号是「或」,需要「且」就链式调用
/* 有 .badge 或者有 .meta,满足一个即可 */.card:has(.badge, .meta) {}如果要求两个都存在,不能写成 :has(.badge, .meta),而要:
.card:has(.badge):has(.meta) {}这是个很典型的误读::has() 的参数是一个选择器列表,列表语义天然是「匹配任意一个」。想要交集,就把多个 :has() 串在同一个复合选择器上。
2. 不能嵌套,伪元素也不能放进去
规范明确禁止两件事:
/* 无效::has() 不能嵌套 */.card:has(:has(> img)) {}
/* 无效:伪元素不能作为 :has() 的参数 */.card:has(::before) {}原因不神秘。伪元素是否生成,本身可能取决于祖先的样式;如果允许 :has() 查询伪元素,就可能出现「祖先的样式依赖后代伪元素,后代伪元素又依赖祖先样式」的循环。规范因此把伪元素排除在外。
需要同时满足多个条件时,用链式 :has(),不要试图嵌套。
3. 非 forgiving:一个坏选择器会毁掉整条规则
这是最容易在生产环境里静默出事的一条。
:is() 和 :where() 的参数是 forgiving 的:列表里有一个选择器无法解析,浏览器会忽略那一个,保留其余部分。:has() 不是。规范在修订记录里写得很直接::has() 和 :nth-child() 的选择器参数已经改为 non-forgiving。
也就是说:
/* 只要 :unknown-pseudo 无法解析,整条规则都会被丢弃 */.card:has(> img, :unknown-pseudo) { border: 1px solid red;}不是「忽略未知伪类,保留 > img」,而是整条规则不生效。平时这不会出问题,但在两种情况下很危险:
- 你在
:has()里混用了某个浏览器尚未实现的伪类,想「有就用,没有就退化」; - 你从别处复制了一段选择器,其中包含拼写错误。
安全做法是保持 :has() 参数干净,并用 @supports 明确隔离增强逻辑:
@supports selector(.card:has(> img)) { .card:has(> .card__cover) { grid-template-columns: 160px 1fr; }}4. 特异性取参数里最高的那个
:has() 本身不贡献伪类的特异性,它的特异性会被替换成参数中最具体的复杂选择器的特异性。这和 :is()、:not() 的规则一致。
于是:
/* .card 是 (0,1,0),:has(#main img) 取 #main img 的 (1,0,1),合计 (1,1,1) */.card:has(#main img) {}一个看起来只是「判断有没有图」的选择器,特异性被参数里的 ID 抬到了 ID 级别。后面想覆盖它,就得用同样甚至更高的特异性,样式表会越来越难维护。
如果条件里必须出现高特异性选择器,可以用 :where() 把它归零:
/* :where(#main) 特异性为 0,整个 :has() 只剩 img 的 (0,0,1) */.card:has(:where(#main) img) {}五、失效模型::has() 的真正成本
语法只是第一层。:has() 和普通选择器最大的区别,在于它让「后代的变化」有可能影响「祖先的匹配结果」。
普通选择器大多可以沿着一个方向做局部失效:元素自己的类变了,重算它自己和后代;兄弟变了,重算后面的兄弟。而 :has() 引入了一个反向依赖:一个深层节点被插入、删除或改变状态,可能让某个祖先从「匹配」变成「不匹配」。
浏览器当然不会每次 DOM 变化都把整棵树重新匹配一遍。引擎会维护失效集合,用类似 Bloom filter 的结构快速排除不可能受影响的元素。但有一个事实不会变::has() 的候选范围越大、条件埋得越深,一次变更需要重新检查的祖先和前置兄弟就越多。
这带来几条很实际的取舍:
第一,把 :has() 锚在稳定的、较浅的元素上。 .card:has(> img)、.field:has([aria-invalid='true'])、.list:not(:has(> li)) 都是好例子:主语明确,条件靠近主语,候选集小。
第二,避免让 :has() 的主体变成整棵树的根。 body:has(.modal-open) 能工作,但任何位置的相关变化都可能让 body 成为候选。更稳妥的写法是把状态放在一个明确的容器上,或者直接用类名。
第三,不要写 *:has(...)。 这等于告诉浏览器「每个元素都可能是候选」,把失效范围扩到最大。几乎没有场景需要这样做。
第四,高频状态优先用原生伪类。 :hover、:focus、:focus-within 的状态变化非常频繁。用 :has() 去查询这些状态,等于在每次指针移动或焦点切换时重新评估祖先关系。如果能用 :focus-within 表达,就不要用 :has(:focus)。
第五,用工具验证,不要凭感觉。 Chromium DevTools 的 Performance 面板和 selector stats 可以看到样式重算的耗时与匹配次数。真实页面里,一两个卡片上的 :has() 通常完全感觉不到;真正需要警惕的是「大列表 + 深后代 + 高频状态」的组合。
一句话总结::has() 的成本不在语法,而在失效边界。 让条件贴近主语,让主语保持稳定,成本通常可以忽略;反过来,它会把一次微小的 DOM 变更放大成一次大范围的样式重算。
六、渐进增强:默认样式必须先能看
即使到 2026 年,:has() 也已经是可以放心使用的能力:Chrome 105、Safari 15.4、Firefox 121 起支持。但在组件库、嵌入式页面或需要兼容旧设备的场景里,仍然建议把 :has() 当作增强,而不是唯一路径。
最稳的结构是:默认样式不依赖 :has(),增强样式放在 @supports 里。
/* 基线:单列,所有浏览器都能正确显示 */.card { display: grid; grid-template-columns: 1fr;}
/* 增强:支持 :has() 时,有封面就横排 */@supports selector(.card:has(> img)) { .card:has(> .card__cover) { grid-template-columns: 160px 1fr; }}如果布局本身必须知道「有没有封面」,还有两条退路:
- 让模板输出一个显式属性,例如
data-has-cover,CSS 同时支持属性和:has()。这样旧浏览器走属性,新浏览器可以直接用:has(),两者可以共存。 - 用 JavaScript 补类名,但只在
CSS.supports('selector(:has(*))')为 false 时执行。把 polyfill 限制在真正需要的环境里。
不建议引入「用 JavaScript 模拟 :has()」的通用选择器 polyfill。它需要监听整棵 DOM、重新计算匹配,行为也很难和原生实现完全一致,最后往往比直接加一个类名更贵。
七、什么时候不该用 :has()
:has() 很强,但它不是「所有 JS 判断的替代品」。下面这些情况,显式类名或组件状态通常更合适:
- 关系是业务语义,不是 DOM 结构。 「这个订单已支付」应该是一个数据属性或 ARIA 状态,而不是「某个图标节点存在」。让 CSS 去猜业务含义,条件会越来越脆。
- 需要跨越组件边界。 选择器不会穿透 Shadow DOM,跨组件的祖先关系也很难稳定表达。组件内部的关系在组件内部解决,组件之间用 props 或插槽约定。
- 状态变化非常频繁。 滚动位置、指针移动、实时数据刷新,用
:has()做条件容易把样式重算带进热路径。此时显式切类名或写内联样式通常更可控。 - 选择器已经需要画图才能解释。 如果 code review 里没人能一眼说出「它到底在选谁」,那就不是
:has()的问题,而是这层关系不该由 CSS 表达。 - 你想查询伪元素是否存在。 规范不允许,也不该用别的技巧硬绕。
八、一张检查表
写下一个 :has() 之前,可以依次问:
- 主语是谁?条件在子树里,还是在兄弟里?
- 逗号分隔的是「或」吗?需要「且」就链式
:has()。 - 参数里有没有可能无法解析的选择器?
:has()不 forgiving。 - 特异性有没有被参数抬高?必要时用
:where()归零。 - 候选元素多不多?条件是否尽量靠近主语?
- 这是高频状态吗?能不能用
:focus-within、:hover或显式类名? - 去掉
:has()之后,默认样式还能不能看?
九、结语
「父选择器」是个好记的外号。它帮你记住 :has() 能解决过去必须用 JavaScript 解决的问题,但也仅此而已。
真正需要记住的是三句话:
- 主语不变:
:has()没有反向选中父元素,它只是给当前元素加了一个关系条件。 - 条件可以外扩:后代、直接子元素、后续兄弟,都是相对选择器的一部分。
- 代价在失效:条件埋得越深、候选集越大、状态变化越频繁,样式引擎要重新检查的范围就越大。
CSS 没有因此变成编程语言。它只是获得了一种能力:在匹配的那一刻,问一句「你和周围是什么关系」。 用在这类关系稳定、结构清晰的场景里,:has() 能让模板和样式少一份需要同步的状态;用在语义模糊或高频变化的地方,它只会把问题从 JavaScript 搬到样式重算里。