背景
用 CSS 动画做交互时,经常需要知道动画什么时候结束。JS 提供了 animationend 事件来监听动画结束,但很多开发者会遇到一个诡异的现象:
一个动画结束,animationend 事件却触发了多次。
element.addEventListener('animationend', () => {
console.log('动画结束了');
});明明只做了一次动画,控制台却打印了 2 遍、3 遍甚至更多。
原因
1. 每个动画属性各触发一次
这是最常见的原因。早期 CSS 动画用 animation 简写属性时,如果你同时改变了多个属性,animationend 会对每个被动画的属性单独触发一次。
.box {
animation: fadeSlide 1s;
}
@keyframes fadeSlide {
from { opacity: 0; transform: translateX(-100px); }
to { opacity: 1; transform: translateX(0); }
}上面的动画同时改变了 opacity 和 transform,animationend 会触发 2 次。
这是旧版浏览器的行为。现代浏览器(Chrome 104+、Firefox、Safari 15+)已经对此做了优化——如果多个属性在同一个
@keyframes块中,只触发一次animationend。但如果你还在支持旧版浏览器,或者用了分离的动画名称,这个问题仍然存在。
2. animationend 事件冒泡
animationend 事件会冒泡。如果父元素和子元素都有动画,子元素的动画结束时,事件会冒泡到父元素,导致父元素上的监听器也被触发。
<div id="parent">
<div id="child" style="animation: slide 1s"></div>
</div>parent.addEventListener('animationend', () => {
console.log('谁结束了?'); // 子元素动画结束也会触发这里
});3. 伪元素(::before / ::after)也触发事件
如果你给元素加了 ::before 或 ::after 并设置了动画,这些伪元素的动画结束后,animationend 事件也会在宿主元素上触发。
.box::after {
content: '';
animation: pulse 0.5s;
}box.addEventListener('animationend', (e) => {
console.log(e.target === box); // true(宿主元素)
console.log(e.animationName); // 'pulse'
});4. 重新触发动画导致事件堆积
有些实现会在 animationend 回调里重新触发动画(比如移除并重新添加动画类),如果处理不当会导致事件监听堆积、触发次数指数级增长。
如何准确判断动画结束
方案一:检查 animationName
在事件回调中检查 animationName,排除无关动画的事件。
element.addEventListener('animationend', (e) => {
if (e.animationName !== 'fadeSlide') return;
console.log('指定的动画结束了');
});方案二:防抖处理
如果不在乎精确次数,可以用防抖只处理最后一次。
let timer;
element.addEventListener('animationend', () => {
clearTimeout(timer);
timer = setTimeout(() => {
console.log('动画真正结束了');
}, 50);
});方案三:检查 elapsedTime
animationend 事件对象有一个 elapsedTime 属性,表示动画已经执行了多少秒。根据动画时长判断是否接近完整播放。
element.addEventListener('animationend', (e) => {
if (e.elapsedTime < 0.9) return; // 动画时长 1s,过滤掉短的
console.log('完整动画结束');
});方案四:阻止冒泡 + 过滤伪元素
element.addEventListener('animationend', (e) => {
e.stopPropagation(); // 阻止冒泡到父元素
if (e.pseudoElement) return; // 忽略伪元素触发的事件
if (e.animationName !== 'myAnim') return;
console.log('准确的动画结束');
});方案五:使用 Promise 包装
最精确的方式:放弃 animationend 事件,用 Promise + requestAnimationFrame 轮询。
function waitForAnimation(el, duration) {
return new Promise(resolve => {
const start = performance.now();
function check() {
if (performance.now() - start >= duration) {
resolve();
} else {
requestAnimationFrame(check);
}
}
check();
});
}
await waitForAnimation(element, 1000);
console.log('动画结束(精确)');或者用 animationend 但包装成 Promise:
function onAnimationEnd(el, name) {
return new Promise(resolve => {
const handler = (e) => {
if (e.animationName === name && !e.pseudoElement) {
el.removeEventListener('animationend', handler);
resolve(e);
}
};
el.addEventListener('animationend', handler);
});
}
await onAnimationEnd(element, 'fadeSlide');
console.log('动画结束');animationend 与其他事件对比
| 事件 | 触发时机 | 备注 |
|---|---|---|
animationstart |
动画开始时 | 同样可能触发多次 |
animationend |
动画结束时 | 本文主题 |
animationiteration |
每次循环结束 | 无限循环动画不会触发 animationend |
transitionend |
CSS 过渡结束时 | 每个属性各触发一次(类似旧版 animationend) |
实战:完整的弹窗动画控制
<div class="modal" id="modal">
<div class="modal-body">
<p>内容...</p>
<button id="closeBtn">关闭</button>
</div>
</div>.modal {
animation: fadeIn 0.3s;
}
.modal.closing {
animation: fadeOut 0.3s;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}const modal = document.getElementById('modal');
const closeBtn = document.getElementById('closeBtn');
// 打开 - 动画结束后不做额外操作(已足够)
modal.style.display = 'block';
// 关闭 - 等待关闭动画结束再隐藏 DOM
closeBtn.addEventListener('click', () => {
modal.classList.add('closing');
// 只用一次,精确过滤
modal.addEventListener('animationend', function handler(e) {
if (e.animationName === 'fadeOut' && !e.pseudoElement) {
modal.removeEventListener('animationend', handler);
modal.style.display = 'none';
modal.classList.remove('closing');
}
});
});总结
animationend 多次触发不是 Bug,而是 CSS 动画规范的正常行为。理解它的触发机制才能写出可靠的动画回调:
- 现代浏览器已优化:同一
@keyframes多个属性只触发一次 - 始终检查
animationName:过滤你关心的动画 - 考虑伪元素:
::before/::after的动画也会冒泡到宿主元素 - 需要精确控制时用 Promise 封装:更可预测、可组合