写过三年前端,每次子元素状态一变化就要写一个 MutationObserver——今天 CSS :has() 把这件事彻底原生化了

写过三年前端,每次子元素状态一变化就要写一个 MutationObserver——今天 CSS :has() 把这件事彻底原生化了。

MutationObserver 存在了快十年。它的唯一作用就是盯着 DOM 改动,然后帮你 toggle 一个 class。但从 2023 年底开始,你再也不需要它了。

90 行 JS,一个选择器就能替代

这是最典型的场景:子复选框被勾中时,卡片要高亮。

// 旧写法:每个 input 挂一个监听,状态还要手动同步
const inputs = document.querySelectorAll('.card input[type="checkbox"]');
inputs.forEach(input => {
  input.addEventListener('change', () => {
    input.closest('.card').classList.toggle('is-selected', input.checked);
  });
});
/* 新写法:纯 CSS,状态变化自动响应 */
.card:has(input[type="checkbox"]:checked) {
  border-color: oklch(60% 0.2 250);
  background: oklch(97% 0.02 250);
}

这行 CSS 不需要 JS 初始化,不存在监听泄漏,组件卸载时不需要 disconnect(),子元素任何时候的状态变化都会自动触发样式重算。

四类场景,对应四种被淘汰的 JS 方案

场景一:表单验证状态驱动 UI

表单里有无效字段时,提交按钮要禁用。

// 旧方案:每个输入框挂 input 事件监听,验证后改按钮状态
document.querySelectorAll('input').forEach(input => {
  input.addEventListener('input', validateForm);
});
/* 新方案:CSS 自己读 */
form:has(:invalid) button[type="submit"] {
  opacity: 0.5;
  pointer-events: none;
}

场景二:数量感知布局

当列表超过 4 项时,切换成双列。

// 旧方案:每次增删节点要重新数一遍
function updateLayout() {
  const count = list.children.length;
  list.classList.toggle('two-col', count >= 4);
}
/* 新方案:浏览器自己数 */
.gallery:has(> :nth-child(4)) {
  grid-template-columns: 1fr 1fr;
}

场景三:前置兄弟节点样式

你想根据”后面那个 input 是否 disabled”来改变当前 label 的样式。传统 CSS 做不到,只能绕 JS。

/* 现在可以了:读后面的兄弟节点状态 */
label:has(+ input:disabled) {
  opacity: 0.5;
}

场景四:点击外部关闭浮层

这是 MutationObserver 最常见的用途之一——监听 body 的 click,然后在浮层外点击时关闭它。

// 旧方案:MutationObserver 盯着 body 节点,等子节点变化再处理
const observer = new MutationObserver(mutations => {
  mutations.forEach(mutation => {
    // 删除了哪个节点,是不是浮层
  });
});
observer.observe(document.body, { childList: true, subtree: true });
/* 新方案:用 :has() 读浮层打开状态 */
body:has(.popover.is-open) .backdrop {
  display: block;
}

不过这类场景更推荐用 :popover-open 伪类——它专门针对 popover/dialog 打开状态,比 MutationObserver 优雅得多。

性能注意:别把浏览器当傻子

:has() 很强大,但有个性能边界要注意:浏览器在你 DOM 子树变化时会重新评估 :has() 的匹配结果。

/* 昂贵:每个节点变化都要遍历整个文档找 div */
*:has(div) { ... }

/* 经济:限定在具体组件内 */
.card:has(.badge) { ... }

实际开发中,只要把 :has() 写在具体组件选择器后面而不是用通配符,浏览器引擎的优化就完全跟得上。Alex Cloudstar 做过实测,在一个有 500 个节点的复杂表单里,把 MutationObserver 换成 :has() 后,渲染性能反而提升了——因为 JS 不需要再排队执行了。

三个坑

第一,:has() 里不能嵌套 :has()。 规范没有定义这个行为,浏览器也不支持,写了等于白写。

第二,:has() 内部不能用伪元素。 .parent:has(::after) 是无效选择器,会导致整条规则被忽略。

第三,特异性取最高。 .card:has(#unique-id) 的特异性是 ID 级(0-1-0-0),不是类级(0-0-1-0),和 :is() 一样。这在大型项目里可能打乱你原有的层叠顺序。

三步下一步

  1. :在你的项目里全局搜索 new MutationObserver,看有哪些是用来监听子元素状态变化的
  2. :把能用 :has() 替代的场景先改掉,优先处理「选中状态」「输入验证」「显隐切换」这三类
  3. :用 Chrome DevTools Performance 面板录一段动态交互,对比改前改后的 FPS

浏览器支持已经是 100% 了——Chrome 105、Firefox 121、Safari 15.4 全在,Baseline 2023。不需要 polyfill,不需要 @supports 降级,2026 年了,这就是标准写法。

评论区

0 条评论

登录后可评论。

阿柯·前端架构 13 阅读