86
This article systematically reviews CSS pseudo-classes that act like event listeners (e.g., :hover, :focus, :valid) and previews the upcoming CSS event-trigger feature, advocating for prioritizing CSS state pseudo-classes over JavaScript event handling for performance optimization. 前 前端早读课
Today 3877 words (about 16 min) View Source →
Daniel Schwarz 2026-07-02 09:00 日本
盘点了这些伪类,并前瞻即将到来的event-trigger特性。
前言
CSS 伪类越来越像事件监听器,从交互状态到表单验证再到媒体播放,许多曾经必须用 JavaScript 处理的场景如今 CSS 便能胜任。本文盘点了这些伪类,并前瞻即将到来的 event-trigger 特性。
今日前端早读课文章由 @Daniel Schwarz 分享,@飘飘编译。
译文从这开始~~
> 如果用于解决实际工作中的 前端页面交互性能优化与重构 问题,最值得借鉴的一点是:优先用 CSS 状态伪类(如 :user-valid、:popover-open)平替常规的 JS 事件监听,以此减少 DOM 事件绑定、降低 JS 包体积并提升渲染性能。
CSS 正在 "倾听" 我们。别误会,不是那种监听。而是 CSS 正在积累越来越多的伪类,帮助我们响应 JavaScript 事件 —— 这样我们就不必再用 JavaScript 本身来处理了。尽管伪类追踪的是 "状态" 而非 "事件",但有时候它们的表现确实很像事件监听器(当然,在 CSS 的语境下,这种区别也没那么重要)。
话说回来,如今的 CSS 到底算什么呢?举个例子,Animation Triggers 规范中有一个关于event-trigger的提案,它本质上就是监听事件并触发动画。不过在我看来,这套语法的能力远不止于此(想想:类似 invoker commands,但用 CSS 实现)。
但为了立足当下,我会先带你了解那些 "类似事件监听器" 的 CSS 伪类,然后再介绍event-trigger,向你展示这个目前尚未被支持的特性(在我的理解中)将如何工作。
#### "事件监听" 式伪类
##### :hover与:active
:hover状态捕捉的是从pointerenter事件触发到pointerleave事件触发之间的那段时间 —— 这恰好完美诠释了为什么伪类是 "状态" 而非 "事件"。
:active匹配的是当前正被鼠标、手指或触控笔按下的目标元素(如链接或按钮),因此它类似于pointerdown和pointerup/pointercancel。
顺便一提,CSS 声明pointer-events: none可以阻止指针事件在所选元素上触发!
##### :focus与:focus-visible
:focus伪类类似于 JavaScript 中的focus和blur(失焦)事件,但:focus-visible则更为复杂。:focus-visible在:focus触发时触发,但除此之外,浏览器还会运用一系列启发式规则来判断是否应当显示焦点指示器。用户是否在使用键盘操作?该元素是否是表单控件?这让我由衷感叹 CSS 所提供的能力。事实上,用 JavaScript 处理这种情况的最佳方式,反而是去查询 CSS 伪类:
element.addEventListener("focus", (event) => { if (event.target.matches(":focus-visible")) { / 执行相关操作 / } });
##### :focus-within以及:has()
JavaScript 擅长处理 "如果 A 是 Y,就对 B 执行 Z" 这类逻辑。我们可以遍历 DOM、利用事件冒泡,还有更多手段。在这方面,CSS 有时会显得力有不逮。然而,CSS 正在飞速进化。它已经拥有许多 "如果这样就那样" 的新特性,比如滚动驱动动画,未来还会更多。HTML 也在做同样的事情,通过专用组件(如<details>)来实现,这些组件都有与之配套的 CSS 特性。
从更宏观的角度来看,我们现有的是:focus-within—— 当子元素获得焦点时匹配父元素;以及:has()—— 它接受任何有效选择器,只要两个选择器之间存在对应关系就能匹配。
举个例子,以下两个选择器的效果完全一样:
form:focus-within { / 当表单内有元素获得焦点时,为表单添加样式 / } form:has(:focus) { / 当表单内有元素获得焦点时,为表单添加样式 / }
##### :checked
:checked的作用不言自明。与之最为对应的 JavaScript 事件是change,它在<input>、<select>或<textarea>的值发生变化时触发(不过在这个语境下,input事件也颇为相似)。
要监听选中状态,我们通常会这样写:
checkbox.addEventListener("change", (event) => { if (event.target.checked) { / 已选中 / } else { / 未选中 / } });
CSS 伪类常常捕捉的是两个 JavaScript 事件之间的那段时间(例如pointerenter和pointerleave之间),但当它们不做这件事时,就像上面这样在处理逻辑判断。
让我们再看几个隐含逻辑处理的例子。
##### :valid/:invalid/:user-valid/:user-invalid/:autofill
在这里我们不需要:not()伪类函数,因为有效性可以通过:valid和:invalid两个伪类来分别检测。但在 JavaScript 这边,并没有valid事件(只有invalid事件)。话虽如此,如果使用 JavaScript,你通常会在input、change、blur(失焦时检查有效性)或submit(提交时检查整个表单的有效性,如下所示)的事件监听回调中调用checkValidity()方法(该方法在返回false时实际上会触发invalid事件)。
form.addEventListener("submit", () => { if (form.checkValidity()) { / 所有表单控件均有效 / } else { / 某个表单控件无效(触发invalid事件) / } });
我们也可以使用ValidityState对象来实现 —— 它不会触发invalid事件,但能像 HTML 表单验证一样告诉我们表单控件为何有效或无效:
input.addEventListener("input", () => { if (input.validity.valid) { / 输入有效 / } else { / 输入无效(不会触发invalid事件) / } });
HTML 表单验证的妙处在于它一手包办了整个前端验证逻辑,但如果你需要某些非默认行为,那就得求助于checkValidity()或ValidityState了。
伪类在任何情况下都能正常工作。事实上,甚至有点 "过于" 灵敏了!一个容易被忽略的细节是:表单控件会立即触发:valid或:invalid。而:user-valid和:user-invalid则会等待用户输入值并失焦之后才触发。这其实正是change事件的行为方式(除非元素是复选框、单选按钮、下拉列表、颜色选择器或范围滑块),也是它区别于input事件的关键所在。
对于自动填充,JavaScript 中既没有对应的事件,也没有什么优雅的检测方式,但 CSS 却有一个:autofill伪类可供使用。
#### 媒体元素伪类
媒体元素伪类仍然很新。Chrome 尚未支持,Firefox 也是最近才实现的,但它们已被纳入 Interop 2026 计划,不久之后我们就能根据<audio>和<video>元素的状态来设置样式,而无需监听 JavaScript 事件。相信你现在已经理解其中的原理了,下面是一个简要概览:
| 伪类 | 对应的 JavaScript 事件 | | --- | --- | | :buffering | waiting | | :muted | volumechange(但请看下文) | | :paused | pause | | :playing | playing(不是 play) | | :seeking | seeking | | :stalled | stalled | | :volume-locked | 无直接对应,见下文 |
使用volumechange事件来检测静音状态:
audio.addEventListener("volumechange", () => { if (audio.muted) { // 已静音 } else { // 未静音 } });
检测音量是否被锁定,意味着需要尝试更改音量并验证是否成功。最佳做法是创建一个全新的元素,以免在真实元素上触发volumechange事件:
// 创建video元素 const video = document.createElement("video"); // 更改音量 video.volume = 0.5; if (video.volume !== 0.5) { // 音量已锁定 } else { // 音量未锁定 }
或者,如果是写 CSS 的话,直接使用:volume-locked伪类就好了。
##### :popover-open/:open/:modal
正如我们所预料的那样,当 popover、<dialog>或<details>打开或关闭时,并没有对应的 JavaScript 事件,但我们可以监听toggle事件然后检查状态:
element.addEventListener("toggle", () => { if (element.open) { / Popover/dialog/details 已打开 / } else { / Popover/dialog/details 未打开 / } });
然而,CSS 直接提供了现成的伪类:
* :popover-open
(用于 popover)
* :open
(用于<dialog>和<details>元素)
* :modal
(用于模态<dialog>和全屏元素)
说到全屏元素……
##### :fullscreen
:fullscreen伪类等同于 JavaScript 的fullscreenchange事件加上一个内置的条件判断:
document.addEventListener("fullscreenchange", () => { if (document.fullscreenElement) { / fullscreenElement 处于全屏状态 / } else { / 没有元素处于全屏状态(fullscreenElement 为 null) / } });
##### :target
当 URL 中的哈希值(例如#contact)与某个元素的 ID 匹配时(例如<div id="contact">),该元素就会匹配:target伪类。而使用 JavaScript 时,我们必须监听hashchange事件,然后查找是否存在匹配的元素:
window.addEventListener("hashchange", () => { const target = document.getElementById(window.location.hash.substring(1)); if (target) { / 找到了匹配的元素 / } else { / 未找到匹配的元素 / } });
#### 结语
这篇文章并非要抨击 JavaScript,而是对 CSS 所简化的一切表达赞赏,同时也不忘 JavaScript 所提供的精细控制能力。拥有更多实现方式,从来都不是坏事。
说到这里,我想快速聊聊event-trigger。
##### 真正的事件监听器(event-trigger)
我是在 Chrome 实现滚动触发动画时接触到事件触发器的,因为它们在同一个模块中。但目前还没有任何浏览器支持这个特性,所以如果我有什么说错的地方,还请见谅。让我们深入了解一下。
event-trigger-name接受一个简单的带连字符的标识符:
button { event-trigger-name: --event; }
event-trigger-source本质上就是事件监听器。
它将接受以下关键词:
* activate
* interest
* click
* touch
* dblclick
* keypress(<string>)
button { event-trigger-source: click; }
我认为interest关键词指的是即将推出的 Interest Invoker API,而activate关键词的含义可能取决于具体元素。例如对于<details>,激活可能意味着展开,但我不太确定。规范的后续草案应该会告诉我们更多信息,并揭示更多事件类型。
总之,这些事件将用于触发动画。首先我们创建一个@keyframes动画,然后将其附加到要执行动画的元素上,但动画不会立即运行,而是等到事件触发后才播放(正常情况下动画会立即运行)。
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } div { animation: fade-in 300ms both; }
接下来,我们要确保当事件触发时,动画随之启动。方法是在animation旁边设置animation-trigger,引用那个带连字符的标识符(--event)。这还有一个额外的好处 —— 允许一个元素的事件触发另一个元素的动画。下面是一个简单示例,这次使用event-trigger简写形式:
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } button { / 点击时,触发 --event 动画 / event-trigger: --event click; } div { / 当 --event 触发时,正向播放动画 / animation-trigger: --event play-forwards; / 动画 / animation: fade-in 300ms both; }
这就是所谓的无状态事件触发器。想想看 —— 你没法 "取消点击" 一次点击,对吧?但我们可以 "失去兴趣",所以下面展示的是一个有状态的事件触发动画(注意语法:两个事件用/分隔,对应两个动画动作,分别对应两种状态):
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } button { / interest(进入)/ interest(离开) / event-trigger: --event interest / interest; } div { / 获得关注时正向播放,失去关注时反向播放 / animation-trigger: --event play-forwards play-backwards; / 动画 / animation: fade-in 300ms both; }
可接受的动画动作包括:
* none
* play
* play-once
* play-forwards
* play-backwards
* pause
* reset
* replay
许多事件和动画动作的组合在实际中是行不通的,但这些组合很容易规避,因为使用它们本身就不合逻辑。不过,我们确实可以触发多个不同的动画,因为animation-trigger是animation的一个仅重置子属性。下面是一个大致的示例:
animation-name: animationA, animationB; animation-trigger: --eventA play, --eventB replay;
这一特性的可能性是无穷的,取决于 W3C 如何推进(规范中还提到了允许事件冒泡!)。但说实话,我有点希望我们能像 HTML 通过 Invoker Commands API 那样,用事件触发器来调用 JavaScript 方法。
关于本文
译者:@飘飘
作者:@Daniel Schwarz
原文:https://css-tricks.com/css-states-and-javascript-events/
这期前端早读课
对你有帮助,帮”赞“一下,
期待下一期,帮”在看” 一下。 阅读原文