多次触发的动画结束事件

2026-03-22 前端

背景

用 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 动画规范的正常行为。理解它的触发机制才能写出可靠的动画回调:

  1. 现代浏览器已优化:同一 @keyframes 多个属性只触发一次
  2. 始终检查 animationName:过滤你关心的动画
  3. 考虑伪元素:::before/::after 的动画也会冒泡到宿主元素
  4. 需要精确控制时用 Promise 封装:更可预测、可组合