写过三年前端,每次子元素状态一变化就要写一个 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() 一样。这在大型项目里可能打乱你原有的层叠顺序。
三步下一步
- 搜:在你的项目里全局搜索
new MutationObserver,看有哪些是用来监听子元素状态变化的 - 改:把能用 :has() 替代的场景先改掉,优先处理「选中状态」「输入验证」「显隐切换」这三类
- 测:用 Chrome DevTools Performance 面板录一段动态交互,对比改前改后的 FPS
浏览器支持已经是 100% 了——Chrome 105、Firefox 121、Safari 15.4 全在,Baseline 2023。不需要 polyfill,不需要 @supports 降级,2026 年了,这就是标准写法。
评论区
登录后可评论。