1. 先说清楚这个组件到底能干什么
别被标题骗了,它的核心功能特别朴素:监听一条CSS动画从开始到结束的完整生命周期,并且在动画过程中的任意时刻,能拿到当前已经执行到百分之几、还剩多少毫秒。就这么点事,但要做得又稳又通用,里面坑比想象中多。
我最初做这个组件,是因为一个数据可视化的项目里有大量图表入场动画。需求是:图表动画播到一半时,用户点击了"跳过动画"按钮,我得立刻让动画跳到终点,并且触发终态回调。第二周又来了新需求:动画播完后要把页面上的某个提示气泡弹出来——按常规做法就是在setTimeout里写死动画时长,比如sleep(800)再执行下一条逻辑。但这种写法的隐患很快暴露了:设计师中途把动画时长从800毫秒改成了1200毫秒,你那个setTimeout(800)如果忘了同步改,提示气泡就会在动画播到一半的时候弹出来,非常诡异。
更麻烦的是,CSS动画的关键帧如果用了steps()、cubic-bezier()这种复杂缓动,你在中间任意时刻去读getComputedStyle拿到的值,和动画实际渲染到的位置之间,几乎总是差一两帧。这个误差肉眼看不出来,但如果你要拿这个值去同步另一个元素的位移,画面就会明显"发飘"。
所以这个组件的价值不是"被动等动画结束,然后通知我",而是"主动掌握动画的每一个时间点,并且让回调的触发时机和动画渲染的画面严格对齐"。适合谁用呢?数据可视化团队、做复杂交互动效的H5页面开发者、维护组件库的工程师,以及被设计稿上 transition: all 0.3s 反复折磨的前端同学。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 前提知识:CSS动画的事件机制与浏览器行为差异
在写监听组件前,必须把三个原生事件摸透:animationstart、animationiteration、animationend。
这三个事件本身不复杂,各自语义也清楚。但它们在真实浏览器里的表现有很多反直觉的细节:
第一,animationend 不一定会触发。 这句话我重复强调一百遍都不嫌多。以下情况里它可能永远不冒泡:
- 动画元素被设置成
display: none,再切换回来时动画是重新开始的,但如果你在隐藏期间期望"动画结束了",这个事件就丢了。 - 动画的播放状态被显式改为
paused,然后卡在那里不继续,animationend永远不会来。 - 元素直接被
remove()从DOM里拿掉了,事件和元素一起没了。 - 关键帧里面
animation-iteration-count: infinite的无限循环动画,理论上永远不会有animationend(除非中途被中断)。
所以,一个只能依赖animationend的监听组件,可靠性天然不足。需要额外的超时、状态机、甚至requestAnimationFrame兜底轮询。
第二,事件触发时读取值的不确定性。 很多新手在animationend回调里第一时间去读el.offsetWidth或者getBoundingClientRect(),结果发现值不是终值,而是动画中间某个值。原因在于animationend是异步派发的事件,虽然它语义上代表"动画播放完毕",但浏览器的渲染流水线可能还没把元素的最终样式提交到当前帧。这个时序问题,在Chrome和Safari上表现不一样,Chrome偶尔会给你终值,Safari大多数时候给你的是倒数第二帧的值。
第三,多动画名冲突问题。 一个元素可以同时挂多个动画:animation: fadeIn 1s, slideUp 2s。这种情况下animationend会触发两次,每次e.animationName对应不同的动画名。如果你对所有动画统一做监听,回调里不判animationName,逻辑直接乱掉。
我在实际开发中经历过一次很典型的bug:弹窗组件同时有透明度动画和位移动画,一个800ms一个1000ms,我只监听了animationend,结果每次弹窗关闭后,组件状态已经重置了,但透明的动画在1000ms后才发出结束事件,状态又变了回去,控制台报了一堆警告,页面表现时好时坏。后来加了e.animationName判断,才把问题解决。
所以,做这种组件之前,先把这个表记住:
| 场景 | animationstart | animationiteration | animationend |
|---|---|---|---|
| 正常单次播放完毕 | 触发一次 | 不触发 | 触发一次 |
| infinite无限循环 | 触发一次 | 每隔一个周期触发 | 不触发 |
display:none 瞬间隐藏 |
不触发 | — | 不触发 |
| 动画中途被移除/替换 | 后续不触发 | 后续不触发 | 后续不触发 |
| 多个并行动画 | 每个动画各触发一次 | 每个循环各触发一次 | 每个动画各触发一次 |
搞清楚这张表,后续组件的设计才有依据。
3. 监听组件的架构设计:事件、状态机与追帧
真正动手写组件的时候,先不要急着堆代码。组件要解决的其实是三个层次的问题:
- 事件层:接收原生事件,翻译成业务语义。
- 状态层:维护当前动画处于"未开始 / 播放中 / 已结束 / 被中断"哪个阶段。
- 时间层:提供当前进度百分比、剩余毫秒数、总时长的查询能力。
很多开源组件只做了第一层,甚至有些只做了"事件回调"这一小部分,你需要在它之上再封装状态管理,很不方便。我设计的这个组件,把三个层次全部内聚了,对外只暴露几个方法:start()、pause()、resume()、stop()、on(),以及一个实时获取状态的getState()。
先说事件层。原生事件在组件内部可以原样透传,但要注意:原生animationend事件并不是挂在元素本身,而是挂在元素祖先链上的某个节点上。如果你的动画元素在某个带overflow: hidden的容器里,事件沿冒泡路径很可能被容器拦截或触发顺序错乱。稳妥的做法是,在组件挂载阶段给元素加了pointer-events: none后,仍然要在addEventListener时显式指定{ once: false },同时用e.target和e.currentTarget做双重校验,避免误判来自子元素的动画事件。
然后是状态机。状态机的定义要避免"魔法数字",建议用Symbol或者字符串常量:
code复制const STATUS = {
IDLE: 'idle', // 初始状态
RUNNING: 'running', // 播放中
PAUSED: 'pause
