配了三年自定义播放器,每次想知道视频在不在播都要靠 JS 监听事件再加 class——今天 CSS 用七个伪类把这件事彻底原生化了

写了三年自定义播放器,每次想知道视频在不在播,都要靠 JS 监听 play/pause 事件再往 wrapper 加 class——Safari 15.4 / Firefox 150 / Chrome 158 现在把这件事用一行 CSS 原生化了。


每个做过视频播放器的前端都知道这个套路:页面上放个 <video>,外面包一层 .player 的 div,再写一堆 JS:

const video = document.querySelector("video");
const wrapper = document.querySelector(".player");

video.addEventListener("play", () => wrapper.classList.add("is-playing"));
video.addEventListener("pause", () => wrapper.classList.remove("is-playing"));

CSS 再读这个 class:

.player.is-playing .play-button { opacity: 0.4; }
.player:not(.is-playing) .play-button { opacity: 1; }

状态一多,这套机制就开始漏:缓冲时 class 没及时更新,进度条拖拽时 class 和实际状态对不上,页面开了多个播放器时还要处理竞态。JS 和 CSS 之间的这座桥,是硬着头皮搭上去的。

CSS Selectors Level 4 把这座桥拆了。 浏览器早就知道视频在不在播放,现在它把这件事直接暴露给 CSS——不用 JS 中转,不用 class 同步。


一行 CSS,替代一坨 JS

:playing:paused 是最基础的两个伪类,直接匹配 <video><audio> 元素的播放状态:

/* 播着的时候,按钮变淡 */
.player:has(video:playing) .play-button { opacity: 0.4; }

/* 暂停的时候,按钮高亮 */
.player:has(video:paused) .play-button { opacity: 1; }

:has() 在这里起了关键作用:外层 wrapper 能读取子元素的状态,不需要子元素自己改自己的 class。两个选择器组合起来,就是一个完整的播放状态 UI 响应——没有一行 JS。

整个伪类家族一共有七个,覆盖完整的播放状态:

/* 播放中 */
video:playing { border: 3px solid green; }

/* 暂停中 */
video:paused { border: 3px solid orange; }

/* 正在缓冲 */
video:buffering { --spinner-opacity: 1; }

/* 下载中断(卡住)*/
video:stalled { --spinner-opacity: 1; }

/* 用户在拖进度条 */
video:seeking { outline: 2px solid blue; }

/* 静音 */
video:muted { --mute-icon-opacity: 1; }

/* 音量被系统锁定(移动端常见)*/
video:volume-locked { --lock-icon-opacity: 1; }

状态和 :has() 配合起来,是最推荐的用法——外层容器读子元素状态,按钮、封面、进度条、字幕按钮都可以联动:

/* 播放时隐藏封面图 */
.player:has(video:playing) .cover { opacity: 0; pointer-events: none; }

/* 暂停且有封面时显示封面 */
.player:has(video:paused):has(.cover) .cover { opacity: 1; }

/* 缓冲时让转圈显示 */
.player:has(video:buffering, video:stalled) .spinner { opacity: 1; }

:buffering 和 :stalled 的区别

这两个状态特别容易混淆,实际行为完全不同:

  • :buffering:媒体在正常下载中——视频播到了还没下载的部分,播放器在等数据。此时 :playing 同样为 true。
  • :stalled:下载中断了——网络断了或者 CDN 抽了。此时 :playing 也为 true,但视觉上应该显示「网络不太好」的提示。
/* 正常缓冲,转圈提示但不报错 */
.player:has(video:buffering) .spinner { opacity: 1; }

/* 真正卡住,显示警告 */
.player:has(video:stalled) .warning { opacity: 1; }

渐进增强写法

Safari 和 Firefox 已经支持一年多,Chrome 158 刚加入,支持率在 2026 年已经相当高。但考虑到用户群体,建议用 @supports selector() 做渐进增强:

/* 默认:按钮可点击 */
.play-button { opacity: 1; cursor: pointer; }

/* 渐进增强:有支持的浏览器才改状态 */
@supports selector(video:playing) {
  .player:has(video:playing) .play-button { opacity: 0.4; cursor: default; }
  .player:has(video:paused) .play-button { opacity: 1; }
  .player:has(video:buffering, video:stalled) .spinner { opacity: 1; }
}

这个写法在没有支持的浏览器里完全回退到默认态——按钮始终可点击;支持的浏览器里,播放状态自动联动。


JS 还干什么

这套方案让 JS 只负责三件事:实际调用 video.play() / video.pause()、进度条拖拽的数学计算、以及 analytics 事件上报。状态同步归 CSS,JS 归交互,两边的职责终于分开了。

之前用 JS 维护的那坨 classList.add/remove 可以直接删掉——实测说明,这个分工能消除一整类「播放器状态变了但 UI 没跟上」的 bug。


三个坑

坑一:Chrome 刚支持要渐进增强。 Chrome 158(August 2026)是首批稳定版,用户 Agent 覆盖还不完整,@supports selector(video:playing) 必须加。

坑二::playing 和 :paused 不互斥于「未激活」状态。 视频加载完但用户还没点播放时,两者都不是——这是 :not(:playing):not(:paused) 的情况,别忘了处理封面显示逻辑。

坑三:只能读内嵌 <video> / <audio> 状态。 外部嵌入的 iframe 播放器(YouTube / Vimeo)不在这个范围内,还是要用各自的消息 API。


三步下一步

  1. 找代码:打开你项目里播放器的 JS,搜 addEventListener.*playclassList,这就是要替换的部分——删掉事件监听里的 class 操作
  2. 写 CSS:把 .is-playing / .is-paused / .is-buffering 的 class 规则,改成 :has(video:playing) / :has(video:buffering) 的 CSS 规则,外层加 @supports selector(video:playing) { }
  3. 验证:用 Chrome 158+ 打开播放器,分别触发播放、暂停、缓冲、拖拽四种状态,检查按钮、封面、转圈的响应是否符合预期

Chrome 158 把媒体状态伪类加入支持后,Safari / Firefox / Chrome 三大引擎全部就位,Interop 2026 的跨引擎一致性目标正式完成一个。播了三年视频播了十年 JS 的播放器状态,终于可以彻底交给 CSS 了。

评论区

0 条评论

登录后可评论。

阿柯·前端架构 11 阅读