CSS :has() 实战:把筛选面板、表单校验、主题切换里的 JS 一行行删掉

2026-09-18 0 793

去年年底给一个后台管理系统做重构,产品经理提了一堆交互细节需求,全是同一类:「用户选了东西之后,那块区域要变个样子」。

按老习惯就是加事件监听、toggle class、再写个 MutationObserver 兜住动态渲染的列表。一个筛选项配一段 JS,五个筛选条件就是五段。我盯着那个文件看了两分钟,决定先拿 :has() 试一把。

结果是这段逻辑从 87 行 JS 变成了 19 行 CSS,而且后面新增筛选条件不用再动 JS。这篇文章就把当时用的几个写法,连同踩到的坑,一起讲清楚。

先弄清楚 :has() 到底在选什么

:has() 被叫「父选择器」其实是叫窄了。它本质上是一个「相对选择器」:只要元素内部(或者它的兄弟节点里)存在匹配的东西,这个元素就被选中。

三个方向都能用:

/* 1. 后代方向:卡片里有图片 */
.card:has(img) { }

/* 2. 子元素方向:表单域里有错误提示 */
.field:has(> .error[data-show]) { }

/* 3. 兄弟方向:它后面紧跟着一个提示条 */
.tooltip-trigger:has(+ .tooltip[data-open]) { }

第三个写法特别值得单独记一下。CSS 一直没有「选择前一个兄弟」的能力,:has(+ x) 是目前唯一的正路子。以前想在鼠标悬停时高亮前面的图标,只能把 DOM 顺序调过来然后用 flex 反向排,现在不用了。

浏览器支持方面:Safari 15.4、Chrome 105、Firefox 121 开始都支持了。到今天(2025 年)主流浏览器早就覆盖完了,只有一些内嵌 WebView 和政企单位还在跑的老版本 Chromium 需要照顾一下。后面会讲怎么给它们兜底。

场景一:筛选面板的「已选」状态

需求很朴素:用户勾了任意一个筛选条件后,右上角出现「清空筛选」按钮,同时面板底色变一下。以前这活是给每个 checkbox 绑 change 事件。

:has() 配合表单的 reset 原生命令,一行 JS 都不用:

<form class="filter-bar">
  <fieldset>
    <legend>分类</legend>
    <label><input type="checkbox" name="cat" value="A"> 服装</label>
    <label><input type="checkbox" name="cat" value="B"> 数码</label>
    <label><input type="checkbox" name="cat" value="C"> 家居</label>
  </fieldset>

  <fieldset>
    <legend>状态</legend>
    <label><input type="radio" name="st" value="1"> 在售</label>
    <label><input type="radio" name="st" value="2"> 下架</label>
  </fieldset>

  <button type="reset" class="clear-btn">清空筛选</button>
</form>
.filter-bar {
  border: 1px solid #d8dce3;
  border-radius: 6px;
  transition: border-color .18s, background-color .18s;
}

.filter-bar .clear-btn {
  opacity: 0;
  pointer-events: none;
  transition: opacity .18s;
}

.filter-bar:has(input:checked) {
  border-color: #3b82f6;
  background-color: #f5f9ff;
}

.filter-bar:has(input:checked) .clear-btn {
  opacity: 1;
  pointer-events: auto;
}

关键在 button type="reset"。它会把整个 form 里的复选框、单选框、文本框全部还原成初始状态,浏览器原生就干这事,比你手写一个遍历 dom 的循环靠谱。

注意按钮必须写在 <form> 内部,而且不能有 type="button",否则 reset 行为不会触发。

场景二:表单校验的父级反馈

第二个场景是登录页。以前的做法是监听 blur 事件,自己维护一个 touched 状态,然后给外层容器加 class 控制红框。

现在用 :user-invalid 搭配 :has() 就能覆盖绝大部分情况:

<div class="field">
  <label for="email">邮箱</label>
  <input id="email" type="email" required>
  <span class="hint">请输入有效的邮箱地址</span>
</div>
.field {
  position: relative;
  padding-bottom: 22px;
}

.field .hint {
  position: absolute;
  left: 0;
  bottom: 0;
  font-size: 12px;
  color: #dc2626;
  opacity: 0;
  transform: translateY(-2px);
  transition: opacity .15s, transform .15s;
}

.field:has(input:user-invalid) input {
  border-color: #dc2626;
  outline-color: #dc2626;
}

.field:has(input:user-invalid) .hint {
  opacity: 1;
  transform: translateY(0);
}

:user-invalid:invalid 的区别得说清楚,不然做出来的效果会很别扭。

:invalid 是「值不合法」,页面一加载、用户还没碰过输入框,只要是空的且 required,它就已经是 :invalid 了。结果就是用户一进来整个表单全是红的,体验极差。

:user-invalid 是「用户已经交互过,且值不合法」。它会在用户输入后又删空、或者失焦时值不合法的情况下触发,之后再重新变合法就会自动摘掉。这才是我们想要的时机。

支持情况:Firefox 从 88 就有了,Chrome 119、Safari 16.5 开始跟进。如果你要兼容更老的 Chrome,可以先按 :invalid 写一版,再用 @supports selector(:user-invalid) 覆盖。不过我的建议是别折腾,直接在提交时做一次 JS 校验兜底,视觉上让老浏览器就用 :invalid 也无所谓。

场景三:卡片的结构变体

商品列表里的卡片长得不一样:有图的顶部没有内边距,只有文字的要多留白;评论区有图的卡片用两栏,没图的用单栏。

以前的写法是在渲染模板里判断,给不同的卡片加 .card--with-image。问题在于这个判断散在各个模板文件里,一个新来的同事根本猜不到有哪些修饰类。

交给 CSS 自己判断:

.card {
  padding: 20px;
  display: grid;
  gap: 12px;
}

/* 有封面图:图片顶到边,内容区缩进 */
.card:has(> .card__cover) {
  padding-top: 0;
  grid-template-columns: 200px 1fr;
}

/* 没有封面图:单栏,上下留白更大 */
.card:not(:has(> .card__cover)) {
  padding-block: 28px;
}

/* 标题超过两行时,副标题收窄 */
.card:has(> .card__title:first-child) .card__sub {
  max-width: 34ch;
}

这里有个容易翻车的点::not(:has(...)):has() 是嵌套在 :not() 中的,这是合法的。但反过来,:has(:has(...)) —— 也就是 :has() 里面再套 :has() —— 是不合法的,整条规则会被浏览器直接丢弃。

规范明确禁止了 :has() 的嵌套。我为了写「卡片里有个包含了图片的容器」这种条件,卡了快半小时,最后只能改用别的写法:

/* 想要的:.card:has(.media:has(img)) —— 无效 */
/* 等价写法:直接定位到最终条件 */
.card:has(.media img) { }

大部分情况下,你去掉中间那层 :has(),直接用后代选择器表达,语义是一样的。

场景四:不用 JS 的主题切换

这个写法我第一次见的时候愣了一下。

常规做法是把 checkbox 放在 body 最前面,然后用 input:checked ~ .content 那种兄弟选择器去影响后面所有内容。缺点非常明显:checkbox 必须在 DOM 里排第一,稍微想把它挪到页面底部或者塞进某个工具栏,整个选择器就断了。

:has() 就没有这个约束,因为它是从 html 元素往下找:

<body>
  <!-- 想放哪放哪 -->
  <header>
    <label class="theme-switch">
      <input type="checkbox" id="dark-mode">
      <span>深色模式</span>
    </label>
  </header>

  <main>...</main>
</body>
:root {
  --bg: #ffffff;
  --fg: #1f2328;
  --line: #e4e7ec;
}

html:has(#dark-mode:checked) {
  --bg: #16181d;
  --fg: #e6e8eb;
  --line: #2c3038;
}

body {
  background: var(--bg);
  color: var(--fg);
  transition: background-color .25s, color .25s;
}

只改变量,不写具体样式,切换的时候不会有闪烁。而且因为变量定义在 :root 上,所有用了这些变量的地方会自动跟着变,不用挨个去找。

再加点细节,让勾选框靠近的时候有反馈:

.theme-switch:has(input:hover) {
  background-color: color-mix(in srgb, var(--fg) 6%, transparent);
}

color-mix() 现在支持度也够了,Chrome 111、Safari 16.2、Firefox 113。用它来做这种「当前前景色混合一点底色」的悬浮态,比硬编码一个 rgba 值更抗主题切换 —— 深色模式下它自动就是对的。

场景五:跟容器查询配合

单个场景说完了,说一下组合起来用的效果。

后台的订单卡片在不同的位置用:主列表里宽度大概 720px,侧边栏的「最近订单」里只有 280px。以前要写两套卡片样式,或者用媒体查询硬猜。

.card-wrap {
  container-type: inline-size;
  container-name: card;
}

@container card (max-width: 320px) {
  .card:has(> .card__avatar) {
    grid-template-columns: 1fr;
    text-align: center;
  }

  .card:has(> .card__avatar) .card__avatar {
    margin: 0 auto;
  }
}

容器查询负责「这块区域有多宽」,:has() 负责「这张卡片里有什么」。两个条件叠加,基本能覆盖我遇到过的绝大多数自适应需求。

注意 container-type: inline-size 要加在外层包裹元素上,不是卡片自己身上。写在卡片自身上,卡片就永远测不到自己相对于别人的宽度了。这个我一开始也写错了,表现是断点完全不生效,排查了好一会儿。

性能:什么时候 :has() 会拖慢页面

:has() 不是免费的。浏览器的样式引擎在遇到它的时候,失效范围会扩大 —— 一个节点变了,可能要让一大片祖先重新评估匹配。

几种明显要避免的写法:

一是往结构不稳定的容器上加。

/* 别这么写 */
.list:has(li) { }

/* 也别这么写 */
body:has(.modal) { }

.list 里的 li 只要增删一条,整个 list 都要重新匹配。如果这是个带虚拟滚动的长列表,滚动时不断增删节点,这块开销就上来了。

我实测过一版:一个 500 条目的列表,每行都写了 .row:has(.tag),滚动时的样式计算耗时从 1.2ms 涨到 6ms 左右。单看不多,但叠加上重排重绘,低端机上能感觉到卡顿。改成给 .row 加一个 class 之后回到 1.5ms。

结论不是「:has() 慢」,而是「别把它用在会被高频改动的节点上」。静态结构上用,随意用都没事。

二是条件本身太宽泛。

/* 匹配范围太大 */
:has(input:checked) { }

/* 缩到具体容器上 */
.filter-bar:has(input:checked) { }

前者会让浏览器从根节点开始找,后者有明确的范围。

Chrome DevTools 的 Performance 面板里有个 Selector Stats 功能,打开后录制一次交互,能看到每个选择器的匹配耗时和匹配次数。我的一般做法是:改完之后录一遍,把耗时超过 1ms 的选择器挑出来看,基本都是 :has() 写宽了。

三个我实际踩过的坑

坑一:动态插入的内容不触发匹配。

有次用 :has() 做了一个「弹窗打开时页面禁止滚动」的效果,但弹窗是 JS 动态插入的,效果死活不出现。后来发现是我把选择器写成了 body:has(> .modal[data-open]),而弹窗实际是挂在 #app 里的,多了一层。把 > 去掉就好了。

这类问题的排查思路是:先用 document.querySelectorAll('body:has(.modal)') 在控制台跑一下,看能不能拿到元素。能拿到说明是样式问题,拿不到说明是选择器路径写错了。

坑二:伪元素不能当条件。

/* 无效,整条规则会被丢弃 */
.item:has(::before) { }

::before 是伪元素,不是真实节点,永远不行。想表达「这个元素有前置装饰」只能靠类名或者属性。

坑三:以为它是万能的父选择器。

写过一段时间之后确实容易上瘾,什么需求都想先试试 :has()。但有一类场景是真的不适合:需要在状态变化时执行副作用的时候。

比如「按钮被选中后发送埋点」,CSS 能改变外观,但它发不了请求。再比如「选中超过三个时禁用其余选项」,虽然用 :has() 加上计数兄弟选择器理论上能凑出来,但可读性会烂到没人敢改。

我的判断标准很简单:只影响视觉的就用 CSS,需要触发行为或者数据变更的就老老实实回 JS。混着来最后两边都难维护。

给老浏览器的兜底

@supports 做特性检测,语法长这样:

/* 默认降级:按钮一直显示 */
.clear-btn {
  opacity: 1;
  pointer-events: auto;
}

@supports selector(:has(*)) {
  .clear-btn {
    opacity: 0;
    pointer-events: none;
    transition: opacity .18s;
  }

  .filter-bar:has(input:checked) .clear-btn {
    opacity: 1;
    pointer-events: auto;
  }
}

思路是反过来的:先写「功能可用但不智能」的基础样式,再用 @supports 在里面加增强效果。这样即使 :has() 完全不被支持,页面依然是可用的,只是按钮不会自动隐藏而已。

顺序很重要,别写反了。如果先写 :has() 的规则再写基础规则,支持 :has() 的浏览器也会被后面的基础规则覆盖掉,功能直接失效。CSS 层叠规则在这儿没有任何例外。

最后

回到开头那个项目。改造完之后筛选面板的 JS 从 87 行变成 19 行 CSS,后面产品又陆续加了三个筛选维度,我一行 JS 都没动。

但我想说的不是「:has() 能替代 JS」。它在很多地方替不了,也不该替。真正有价值的是它让「结构决定外观」这件事回到了 CSS 该在的地方 —— 用什么标签、放什么子节点,这件事本身就是样式的一部分,没必要非得用 JS 再翻译一遍成类名。

顺手做的一件事是:把项目里那些 .card--with-image.form--has-error.panel--active 之类的修饰类清了一遍,能改成 :has() 的都改了。改完之后模板干净不少,新来的同事看 HTML 结构就能猜到样式,不用再去翻 JS 找 class 是在哪儿加的。

CSS :has() 实战:把筛选面板、表单校验、主题切换里的 JS 一行行删掉
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 css CSS :has() 实战:把筛选面板、表单校验、主题切换里的 JS 一行行删掉 https://www.taomawang.com/web/css/2770.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务