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: 'paused', // 暂停
FINISHED: 'finished' // 已完成
};
状态转移的规则很简单:
start()触发时,如果当前是idle或finished,进入running。pause()必须在running下才有效,转为paused。resume()必须在paused下才有效,转回running。stop()可以从任何状态直接到idle。- 收到原生
animationend时,如果状态不是paused,直接到finished。
这里有个容易踩的坑:animationend 可能在你已经手动 stop() 之后延迟到达,如果状态机在 stop() 后已经是 IDLE,此时收到 animationend 应当直接忽略,否则会把状态错误地改回 FINISHED,导致后面再次 start() 时逻辑异常。很多事件监听组件生命周期混乱,根源就在这条状态转移约束上没写好。
时间层是最有附加值的地方。要拿到当前进度百分比,最简单粗暴的方式是:
js复制const duration = parseFloat(getComputedStyle(el).animationDuration) * 1000;
const currentTime = performance.now() - startTimestamp;
const progress = Math.min(currentTime / duration, 1);
但animationDuration在多个动画叠加时会返回类似1s, 2s的字符串,直接parseFloat只会拿到第一个值。而且Chrome和Firefox在返回animationDuration时,偶尔会带着多余的空格,需要做一次trim。这些细节都要处理。
更关键的是,performance.now()拿到的时间戳,和浏览器渲染动画的timeline并不完全一致。动画在页面后台标签页里会被降频甚至冻结,此时performance.now()仍然在走,但动画实际没在播。于是你计算出来的progress和实际渲染进度就对不上。
我采用的方案是"基于核心帧的追帧机制":不只依赖animationend,而是用requestAnimationFrame持续拉取当前动画状态,帧回调里取当前时间偏移,再对照预解析出来的关键帧时间点,算出"视觉上应该在哪一帧、实际播放到哪一帧",两者的差值如果超过一定阈值(默认100ms),就判定为动画被系统降频影响,此时主动触发一次强制跳帧回调,通知外部"动画进度可能失真"。
这样一来,即便浏览器在后台冻结动画,组件也能在恢复前台后第一时间把状态和进度校准回来。
4. 手写核心实现:从解析动画名到事件绑定
下面开始落地。先贴一段核心类骨架,我在项目里用的版本是TypeScript,但这里我改成JavaScript方便阅读。
js复制class CssAnimationWatcher {
constructor(el, options = {}) {
if (!el) throw new Error('需要提供一个有效的DOM元素');
this.el = el;
this.options = Object.assign({
checkInterval: 250, // 兜底巡检间隔
staleThreshold: 100, // 判定失真的毫秒阈值
autoStart: true, // 是否在构造时自动监听
}, options);
this.status = 'idle';
this.animationNames = [];
this.durations = [];
this.onceFlag = false;
this._startTimestamp = 0;
this._rafId = null;
this._timer = null;
this._parseAnimationInfo();
this._bindNativeEvents();
if (this.options.autoStart) {
this.start();
}
}
}
构造函数里第一件事是_parseAnimationInfo(),这一步很关键,它要把元素样式表里所有动画名和时长拆出来:
js复制_parseAnimationInfo() {
const style = getComputedStyle(this.el);
const names = style.animationName || 'none';
const durations = style.animationDuration || '0s';
this.animationNames = names.split(',').map(s => s.trim()).filter(n => n && n !== 'none');
this.durations = durations.split(',').map(s => {
const trimmed = s.trim();
if (trimmed.endsWith('ms')) return parseFloat(trimmed);
if (trimmed.endsWith('s')) return parseFloat(trimmed) * 1000;
return 0;
});
}
注意,animationName默认值是none,没有动画的元素解析出来是空数组,这样后面事件绑定逻辑可以直接跳过。多动画时,名称和时长是一一对应的,按逗号拆完后,第i个名称对应第i个时长。我遇到过一种极端情况:动画名称本身含逗号,比如animation-name: "foo,bar",这种情况下拆分会出错,但CSS动画名的命名规范里其实不建议这样做,处理时可以加个简单判断——如果数组长度不一致,就退回用animationDuration的第一项作为统一起时。
事件绑定的代码相对简单:
js复制_bindNativeEvents() {
this._onStart = (e) => {
if (!this._isValidEvent(e)) return;
this.status = 'running';
this._startTimestamp = performance.now();
this._emit('start', e);
this._startRafLoop();
};
this._onIteration = (e) => {
if (!this._isValidEvent(e)) return;
this._emit('iteration', e);
};
this._onEnd = (e) => {
if (!this._isValidEvent(e)) return;
if (this.status === 'paused') return;
this.status = 'finished';
this._emit('end', e);
this._stopRafLoop();
};
this.el.addEventListener('animationstart', this._onStart);
this.el.addEventListener('animationiteration', this._onIteration);
this.el.addEventListener('animationend', this._onEnd);
}
里面_isValidEvent很重要:
js复制_isValidEvent(e) {
if (e.target !== this.el) return false;
if (this.animationNames.length > 0 && !this.animationNames.includes(e.animationName)) {
return false;
}
return true;
}
e.animationName在标准浏览器里就是字符串,但在某些WebView里可能带引号,比如"fadeIn"。为了兼容,我做了一层容错:
js复制const name = e.animationName.replace(/['"]/g, '');
if (this.animationNames.length > 0 && !this.animationNames.includes(name)) return false;
这个坑我在安卓的某款WebView上真实踩过,动画名是fadeIn,事件对象里给的却是"fadeIn"(带引号),直接判等永远false,事件监听形同虚设。
接下来是追帧循环。这一块是整个组件的"内功":
js复制_startRafLoop() {
this._stopRafLoop();
const loop = () => {
if (this.status !== 'running') return;
const now = performance.now();
const elapsed = now - this._startTimestamp;
const totalDuration = this.durations.reduce((a, b) => Math.max(a, b), 0);
// 计算当前进度
let progress = totalDuration > 0 ? elapsed / totalDuration : 0;
progress = Math.min(Math.max(progress, 0), 1);
// 用最后一次样式计算去比对是否落后
const currentStyleTime = this._readCurrentStyleTime(progress);
const drift = Math.abs(elapsed - currentStyleTime);
if (drift > this.options.staleThreshold) {
this._emit('stale', { progress, drift });
// 视觉偏离过大,强制校准
this._forceJumpToProgress(progress);
}
this._emit('progress', {
progress,
remaining: Math.max(totalDuration - elapsed, 0),
elapsed
});
this._rafId = requestAnimationFrame(loop);
};
this._rafId = requestAnimationFrame(loop);
}
_readCurrentStyleTime这个函数用来估算样式层面动画实际走到的位置,最朴素的做法是读取getComputedStyle(this.el)里某个自定义属性——但自定义属性数值不太好拿。更通用的做法是读取一个我们主动注入的CSS变量,在动画关键帧里每帧更新:
css复制@keyframes fadeIn {
0% { --progress: 0; opacity: 0; }
100% { --progress: 1; opacity: 1; }
}
然后组件内部这样读:
js复制_readCurrentStyleTime(expectedProgress) {
const val = getComputedStyle(this.el).getPropertyValue('--progress');
const num = parseFloat(val);
if (!isNaN(num)) return num * totalDuration;
// 读不到自定义属性时,退回用期望值
return expectedProgress * totalDuration;
}
这种自定义属性的方式有两个优点:一是直观,二是即便浏览器渲染降频,getComputedStyle拿到的值依然能反映"最近一次渲染提交"的真实状态。不过需要项目里适配动画关键帧,不是所有动画都会加--progress。因此我在组件里做了一个"可用性探测":如果发现元素样式中存在--progress变量,就走追帧逻辑;如果不存在,就退化为纯事件驱动——也就是只依赖animationend事件。
提示:优先推荐在自己可控的动画里加上
--progress这个自定义属性,它带来的进度感知能力远大于那一点额外CSS体积。
5. 把组件封装成易用API:回调、Promise与实例方法
到这里核心机制已经完整了。但作为一个"组件",直接让使用者操作类方法很不友好,我会在此基础上再封装一层对外API,支持两种使用姿势:事件订阅 和 Promise链式调用。
先看事件订阅:
js复制const watcher = new CssAnimationWatcher(el);
watcher.on('start', () => {
console.log('动画开始');
});
watcher.on('progress', ({ progress, remaining }) => {
progressBar.style.width = `${progress * 100}%`;
});
watcher.on('end', () => {
console.log('动画已经播放完成');
});
这层封装本质就是事件总线,实现很简单:
js复制on(event, callback) {
if (!this._listeners) this._listeners = {};
if (!this._listeners[event]) this._listeners[event] = [];
this._listeners[event].push(callback);
return this; // 支持链式调用
}
_emit(event, payload) {
if (!this._listeners || !this._listeners[event]) return;
this._listeners[event].forEach(cb => {
try {
cb(payload);
} catch (err) {
console.error('[CssAnimationWatcher] 事件回调执行出错:', err);
}
});
}
Promise链式调用适用于"先播动画再执行后续逻辑"的场景:
js复制await watcher.play();
doSomethingAfterAnimation();
play 方法会返回一个Promise,内部逻辑是这样:
js复制play() {
return new Promise((resolve, reject) => {
if (this.status === 'running') {
reject(new Error('动画正在播放中'));
return;
}
// 重新触发动画:先移除再强制回流
this.el.classList.remove('animate-active');
void this.el.offsetWidth; // 强制回流,确保动画重置
this.el.classList.add('animate-active');
const onEnd = (e) => {
this.off('end', onEnd);
if (e.animationName !== this.animationNames[0]) return;
resolve();
};
this.on('end', onEnd);
// 兜底超时,防止事件丢失永远pending
const timeout = Math.max(...this.durations) + 500;
setTimeout(() => {
resolve(); // 超时也视为完成,但可以标记一下
}, timeout);
});
}
注意里面有个关键点:void this.el.offsetWidth强制回流。这在很多场景下是必须的,否则你移除了animate-active又立刻加回去,浏览器可能认为这是同一个样式状态没变化,动画不会重新播。强制回流能确保样式状态被重新计算。代价是影响了这一帧的渲染性能,但换来的是动画正确重置,值得。
off 方法也要实现,方便移除监听:
js复制off(event, callback) {
if (!this._listeners || !this._listeners[event]) return this;
const idx = this._listeners[event].indexOf(callback);
if (idx !== -1) this._listeners[event].splice(idx, 1);
return this;
}
组合起来用的时候,既能拿到细粒度的事件,又能享受Promise的简洁。
注意:
play()里的超时兜底时间设置成最长动画时长 + 500ms。这个500ms不是拍脑袋定的,是考虑到animationend事件派发时机和渲染提交之间最多差一帧,一帧按16.7ms算,加上事件队列积压的容错,500ms已经非常保守。太短会误判,太长会让用户等待,实测取500ms较为平衡。
6. 几个高频场景的实战用法
前面原理讲了不少,实际用的时候能不能直接"抄作业"是关键。我整理了四个高频场景。
场景一:一次性入场动画结束后,显示后续UI
这是最基础但也是写错最多人最多的场景。很多人的写法是:
js复制el.addEventListener('animationend', handler);
问题在于如果动画因为某些原因没触发(比如用户切后台再切回来,动画被跳过),handler就永远不会执行,页面卡在中间状态。用组件就安全得多:
js复制await watcher.play();
uiBox.classList.add('visible');
play()内部有超时兜底,即使animationend丢了,最多延迟500ms也会继续。
场景二:两个动画依次播放,第二段要在第一段完全结束后才开始
这种链条用Promise解决最优雅:
js复制await watcherA.play();
watcherB.el.classList.add('animate-active');
await watcherB.play();
唯一的注意点是,如果A和B是同一个元素,需要确保第二次play()不会因为样式状态相同而不触发动画。可以对元素做一次classList.remove加void offsetWidth强制回流,组件内部已经做了。
场景三:动画进度条实时显示
进度条的需求本质上是"动画播到哪,进度条跟到哪"。用progress回调非常直观:
js复制watcher.on('progress', ({ progress }) => {
bar.style.width = (progress * 100).toFixed(1) + '%';
});
这里有个细节,进度条不应该直接操作width属性,频繁改样式会触发大量重排。实操中我用的是transform: scaleX(progress),在支持合成器层的浏览器里性能好很多:
js复制bar.style.transform = `scaleX(${progress})`;
顺带一提,如果进度条沿用了CSS动画的缓动函数,那progress回调拿到的就是线性时间百分比,反映不到视觉上的"快慢"。此时不应该用它来驱动视觉条,而是用在业务逻辑判断上,比如"进度超过80%时预加载下一步资源"。
场景四:用户点击跳过动画
这是最开始驱动我做这个组件的需求。点击跳过后,需要把动画立即跳到终态:
js复制function skipAnimation() {
watcher.stop(); // 终止状态机
el.style.animation = 'none'; // 移除动画
void el.offsetWidth; // 强制回流
el.classList.remove('animate-active');
// 手动把终态样式写死
el.style.opacity = '1';
el.style.transform = 'none';
// 通知业务:动画已跳过
watcher._emit('skipped', { reason: 'user_click' });
}
不过我更推荐的做法是,在CSS里准备好一套终态类,跳过后直接加类,而不是逐条写样式。这样能保持样式的可维护性,组件的终态逻辑也不会散落在JS里。
7. 兼容性踩坑与性能注意事项
长时间使用这套方案,会遇到不少兼容性问题,我把最有价值的几条列出来。
WebView上的animationName引号问题
前面提到过,某些安卓WebView会给animationName加引号。处理方式是replace(/['"]/g, '')。不要依赖浏览器自己修正,因为不同WebView表现不一致。
低端机上的事件派发延迟
在性能较差的安卓机上,CSS动画本身如果掉帧严重,animationend的派发可能延后几十甚至上百毫秒。这意味着事件回调的"动画已完成"语义和屏幕实际画面不一致。此时追帧机制就派上用场了——它会在后台持续比对样式时间和真实时间,一旦发现偏差超过阈值就主动兜底。
后台标签页的动画冻结
切到后台,浏览器会暂停CSS动画并节流requestAnimationFrame。重新切回来时,performance.now()的时间差非常大,如果直接用时间算进度,会得到"动画一瞬间结束了"的假象。实际上动画画面还停在离开时的那一帧。组件的追帧逻辑检测到drift过大时,会自动发一个stale事件,业务侧可以据此决定是恢复动画还是强制跳到终态。
大量元素同时监听时的性能
如果一个页面上几十个元素都用这个组件,每个元素都起一个requestAnimationFrame循环,性能会很差。我的经验是:在项目里维护一个全局Watcher管理器,把所有动画元素的监听统一到一个requestAnimationFrame循环里。这样既节省了不必要的重复渲染,也方便在页面不可见时统一暂停所有监听。具体做法不复杂,就是把上面的监听注册逻辑改成向一个全局数组push,然后由唯一的循环统一驱动。
样式读取频率
getComputedStyle是同步操作,在requestAnimationFrame里高频读取会强制浏览器做样式计算,反过来又影响动画性能。所以我会在追帧循环里设置一个readStyleEveryNthFrame参数,默认每3帧读一次样式,这正好对应16.7ms * 3 ≈ 50ms的采样频率,足够感知动画偏差,又不至于拖累渲染。
8. 组件迭代方向:从"监听器"到"动画编排中心"
最后聊聊这个组件未来可以扩展的方向,这也是我在实际项目中不断演进得出的经验。
方向一:支持transition事件
CSS里除了animation,transition也大量使用。但transitionend事件的语义和animationend有很大不同:它有propertyName属性,一次transition可能触发多个transitionend事件,比如width和height同时变化就会触发两次。监听器要做的是按属性名去聚合,并在所有属性都到位后才派发统一的"完成"事件。这个逻辑并不复杂,但很少有人做细致。
方向二:提供"撤销播放"能力
业务中经常遇到"动画播错了要退回重播"的需求。配合状态机和时间戳,组件可以记录每一次play()的起始状态快照,在revert()时把样式和状态都恢复回去。这个能力在组件库里特别有用,可以极大降低交互开发的心智负担。
方向三:和Web Animations API做桥接
浏览器原生的el.animate()其实已经是更现代的动画方案,它的finished Promise天然解决了事件丢失问题。但很多老项目仍然依赖CSS类名驱动动画,不可能一次性重构。所以我的思路是在监听器内部做一层"双驱动":如果检测到元素上已经应用了Web Animations API的动画,就直接走finished Promise;否则退化为CSS事件+追帧。这种兼容策略让组件从"临时代码"逐步过渡到"长期基建"。
方向四:可视化调试面板
组件在开发模式下可以开启一个调试面板,实时展示当前状态机、动画名、时长、进度,以及事件派发日志。这个面板我后来沉淀成了一个小工具函数,团队里视觉同学也能自己看明白动画到底在哪一步卡住了。
我在实际维护这套代码时最大的体会是:动画监控这种事,看似简单,真要做得稳,必须同时跟浏览器的渲染时序、事件的异步派发、业务的状态流转三方博弈。状态机没设计清楚,后面就是无穷无尽的偶发bug;追帧没做,就是进度永远不准;兼容层不加,换个WebView又是一堆问题。所以这一版组件写下来,与其说是写了一堆代码,不如说是把浏览器动画底层的脾气彻底摸了一遍。
如果你也在被动画时序问题折磨,建议直接把这套思路抄走,先解决"事件不触发"和"进度不准确"两个核心痛点,再根据业务扩展其它能力。
