← 回總覽

【第 3725 期】CSS 状态与 JavaScript 事件之间的界限变化

📅 2026-07-02 09:00 前端早读课 软件编程 7 分鐘 8369 字 評分: 86
CSS 前端开发 JavaScript 伪类 Web开发
📌 一句话摘要 本文系统盘点了类似事件监听器的 CSS 伪类(如:hover、:focus、:valid 等),并前瞻了即将到来的 CSS event-trigger 特性,主张优先使用 CSS 状态伪类替代 JavaScript 事件处理以优化性能。 📝 详细摘要 文章首先指出 CSS 正在积累越来越多的伪类来响应 JavaScript 事件,从而减少对 JS 的依赖。随后逐一介绍了:hover/:active、:focus/:focus-visible、:focus-within/:has()、:checked、:valid/:invalid/:user-valid/:user-inv

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 日本

!Image 1

盘点了这些伪类,并前瞻即将到来的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匹配的是当前正被鼠标、手指或触控笔按下的目标元素(如链接或按钮),因此它类似于pointerdownpointerup/pointercancel

顺便一提,CSS 声明pointer-events: none可以阻止指针事件在所选元素上触发!

##### :focus:focus-visible :focus伪类类似于 JavaScript 中的focusblur(失焦)事件,但: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 事件之间的那段时间(例如pointerenterpointerleave之间),但当它们不做这件事时,就像上面这样在处理逻辑判断。

让我们再看几个隐含逻辑处理的例子。

##### :valid/:invalid/:user-valid/:user-invalid/:autofill

在这里我们不需要:not()伪类函数,因为有效性可以通过:valid:invalid两个伪类来分别检测。但在 JavaScript 这边,并没有valid事件(只有invalid事件)。话虽如此,如果使用 JavaScript,你通常会在inputchangeblur(失焦时检查有效性)或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-triggeranimation的一个仅重置子属性。下面是一个大致的示例: 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/

这期前端早读课

对你有帮助,帮”赞“一下,

期待下一期,帮”在看” 一下。 阅读原文

查看原文 → 發佈: 2026-07-02 09:00:00 收錄: 2026-07-02 20:00:19

🤖 問 AI

針對這篇文章提問,AI 會根據文章內容回答。按 Ctrl+Enter 送出。