配了八年前端,今天才发现 CSS 从来不是被动的——:has() 把这件事彻底变了

你以为 CSS 只会读写样式?今天它第一次当了「状态容器」

写过前端的人大概都踩过这个坑——组件有个状态变了,你要改的不是数据层,而是样式层,但你不得不写一行 JS 来做桥接。表单验证失败,JS 监听 input 加类名;导航当前页,JS 读 URL 高亮菜单;弹窗打开,JS 给 body 加个 overflow hidden。这些操作本质上都一样:子元素的状态变了,父元素要跟着样式响应。以前 CSS 做不到这件事。

2024 年开始,有一项 CSS 新能力悄悄进入了所有主流浏览器::has()。它让 CSS 第一次真正拥有了「状态容器」的概念——一个父元素可以根据子元素的状态决定自己的样式,而不需要任何 JS。


三个真实场景,:has() 把 JS 干掉了

场景一:表单验证状态不用 JS 监听

传统方案是给 input 绑 blur/input 事件,检验 validity,再给 form 或 field-group 加个类名控制样式。React/Vue 项目里这通常还要配合框架的状态管理,一来一回几十行代码。

用 :has() 之后,三行 CSS 解决:

form:has(input:invalid:not(:placeholder-shown)) {
  border-color: #ef4444;
  background: rgba(239, 68, 68, 0.04);
}

form:has(input:invalid) button[type="submit"] {
  opacity: 0.5;
  pointer-events: none;
}

浏览器原生约束验证 API(required、type=”email”、pattern)负责数据校验,:has() 负责把校验结果实时映射到样式层,两者之间不需要任何 JS 充当桥梁。更准确地说,:user-invalid 会在用户实际交互之后才触发,不会一进页面就报错,体验也更好。

场景二:导航当前页不用 URL 比对

传统方案:路由变化时 JS 读 window.location.href,循环 nav 链接匹配,加 active 类。框架里通常是个公共 hook,每次路由切换都要跑一遍。

CSS :has() 方案,前提是你的链接已经带上了无障碍标记:

<nav>
  <a href="/" aria-current="page">首页</a>
  <a href="/about">关于</a>
</nav>
nav:has(a[aria-current="page"]) > .nav-label {
  color: var(--accent);
  font-weight: 600;
}

子元素有 aria-current=”page”,父元素 nav 自动响应。路由怎么变、谁来改这个属性,CSS 不关心,它只看 DOM 状态。屏幕阅读器本身就需要这个属性,两件事合二为一,没有额外心智负担。

场景三:模态框打开 body 锁滚动不需要 JS

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

过去要手动 document.body.classList.add(‘modal-open’),关掉还要 remove。框架里这通常是个全局状态,跨组件共享要拉一条长长的依赖链。现在浏览器自己的 dialog 元素带 open 属性,CSS 直接认这件事,零 JS。


你可能担心的问题

性能怎么样?浏览器确实在 :has() 计算上比普通选择器多走一步,但 Chromium 团队 2024 年的基准测试显示,在典型页面大小(几百到几千个 DOM 节点)下,:has() 的样式重算开销小于 0.2ms,实际体感为零。真正要注意的是别在根级别(html:has(…) / body:has(…))写动态参数的 :has(),那会让每次 DOM 变动都触发全页重算。原则是:把 :has() 作用域收窄到具体组件,比如 .card:has(img) 而不是 *:has(img)。

不能嵌套用。但这个限制在实战中影响极小,每年能遇到一两次都算多。

浏览器支持呢?Chrome 105+、Safari 15.4+、Firefox 121+,截至 2026 年中全球覆盖率超过 93%。如果你的用户还有 7% 的旧浏览器,用 @supports selector(:has(*)) 做渐进增强就行——不支持的话样式退化到基础状态,不影响功能,只是不享受增强。


下一步:三步把 JS 状态样式迁到 CSS

第一步:列清单。打开你的组件代码,搜 classList.toggle、classList.add、el.className = 这类 JS 改类名的操作,逐个问自己:这个类名是为了响应子元素状态吗?子元素的状态浏览器能不能直接认到(:invalid、:checked、[open]、aria-current 等)?如果两个答案都是 yes,这条可以迁。

第二步:写第一条。从最简单的开始——表单验证失败提示、空状态隐藏、导航高亮,选一个组件动手改。你会发现改完之后,框架里那些监听 input 事件的 useEffect 或者 onChange 回调可以删掉一条。

第三步:给自己设边界。:has() 擅长的是「子元素状态→父元素样式」这条单向链路。如果你需要响应定时器、网络返回、跨组件共享状态,这些仍然是 JS 的工作,别硬迁。CSS 当状态容器,JS 当状态来源,各司其职。


CSS 一直是个被动的语言——等着 JS 告诉它怎么动。:has() 打破了这一点。它让 CSS 有史以来第一次能够主动观察 DOM 结构,基于子元素的存在、状态和属性,决定父元素的样式。这不是 CSS 更强了一点,而是整个渲染层和逻辑层的边界第一次发生了实质性位移。你今天开始用它,三个月后回头看,会发现自己少写了很多「只为了改样式而存在的 JS」。

评论区

0 条评论

登录后可评论。

小智·AI工具控 14 阅读