Leo小李头像
关注

Web Animations API 深度实践:从关键帧到时序控制的浏览器原生动画引擎

Web Animations API 深度实践:从关键帧到时序控制的浏览器原生动画引擎

一、CSS 动画与 JS 动画的断层:为什么需要 Web Animations API

CSS @keyframes 和 transition 适合声明式的简单动画,但缺乏动态控制能力——无法在运行时修改关键帧、无法精确控制播放进度、无法基于滚动位置驱动动画。JavaScript 动画(requestAnimationFrame)提供了完全控制力,但需要手动计算插值、管理帧率和处理性能优化,代码复杂度高。

Web Animations API(WAAPI)是浏览器的原生动画引擎,将 CSS 动画的声明式语法与 JavaScript 的命令式控制力统一。它允许开发者用 JavaScript 创建和管理动画,同时享受浏览器硬件加速和合成器线程的优势。

二、WAAPI 核心机制:从关键帧定义到时序控制

flowchart TD
    A[Animation 构造] --> B[Keyframe 定义<br/>属性值 + 偏移量]
    A --> C[Timing 参数<br/>时长/缓动/延迟/迭代]
    B --> D[Animation 对象]
    C --> D
    D --> E[播放控制<br/>play/pause/reverse/cancel]
    D --> F[进度控制<br/>currentTime/playbackRate]
    D --> G[事件监听<br/>finish/cancel/remove]
    E --> H[合成器线程渲染<br/>硬件加速]

WAAPI 的核心优势在于动画运行在合成器线程——不阻塞主线程的 JavaScript 执行。这意味着即使主线程繁忙(如处理大量数据),动画仍然流畅。CSS 动画也运行在合成器线程,但 WAAPI 提供了运行时修改的能力。

三、工程实现:关键帧动画、时序控制与滚动驱动

3.1 关键帧动画

// 基础关键帧动画
const fadeInUp: Keyframe[] = [
  {
    opacity: 0,
    transform: 'translateY(20px)',
    offset: 0,  // 0%
  },
  {
    opacity: 1,
    transform: 'translateY(0)',
    offset: 1,  // 100%
  },
];

const timing: KeyframeAnimationOptions = {
  duration: 600,
  easing: 'cubic-bezier(0.16, 1, 0.3, 1)', // easeOutExpo
  fill: 'forwards',
};

const animation = element.animate(fadeInUp, timing);

// 交错动画:列表项依次出现
function staggerAnimation(
  elements: Element[],
  keyframes: Keyframe[],
  options: KeyframeAnimationOptions,
  staggerDelay: number = 100
): Animation[] {
  return elements.map((el, index) => {
    return el.animate(keyframes, {
      ...options,
      delay: (options.delay || 0) + index * staggerDelay,
    });
  });
}

// 使用
const items = document.querySelectorAll('.list-item');
staggerAnimation(Array.from(items), fadeInUp, timing, 80);

3.2 时序控制与组合

// 动画组合:多个动画协同播放
class AnimationTimeline {
  private animations: Map<string, Animation> = new Map();

  add(name: string, element: Element,
      keyframes: Keyframe[],
      options: KeyframeAnimationOptions): void {
    const animation = element.animate(keyframes, {
      ...options,
      // 关键:使用 fill: 'both' 保持动画首尾状态
      fill: 'both',
    });
    this.animations.set(name, animation);
  }

  // 顺序播放:A 完成后播放 B
  async sequence(names: string[]): Promise<void> {
    for (const name of names) {
      const animation = this.animations.get(name);
      if (animation) {
        animation.play();
        await animation.finished;
      }
    }
  }

  // 并行播放:A 和 B 同时播放
  parallel(names: string[]): void {
    for (const name of names) {
      const animation = this.animations.get(name);
      if (animation) {
        animation.play();
      }
    }
  }

  // 交错组:组内交错,组间顺序
  async staggeredGroups(
    groups: string[][],
    groupDelay: number = 200
  ): Promise<void> {
    for (const group of groups) {
      const promises = group.map((name, index) => {
        const animation = this.animations.get(name);
        if (animation) {
          animation.currentTime = 0;
          animation.playbackRate = 1;
          // 组内交错延迟
          return new Promise<void>(resolve => {
            setTimeout(() => {
              animation.play();
              animation.finished.then(() => resolve());
            }, index * 80);
          });
        }
        return Promise.resolve();
      });
      await Promise.all(promises);
      // 组间延迟
      await new Promise(r => setTimeout(r, groupDelay));
    }
  }
}

3.3 滚动驱动动画

// 基于 ScrollTimeline 的滚动驱动动画
// 注意:ScrollTimeline 是较新的 API,需要特性检测

class ScrollDrivenAnimation {
  private observer: IntersectionObserver | null = null;

  attach(element: Element, keyframes: Keyframe[],
         options: { threshold?: number } = {}): void {
    // 检测 ScrollTimeline 支持
    if ('ScrollTimeline' in window) {
      this.attachNative(element, keyframes);
    } else {
      // 降级:使用 IntersectionObserver
      this.attachFallback(element, keyframes, options.threshold || 0.2);
    }
  }

  private attachNative(element: Element, keyframes: Keyframe[]): void {
    const scrollTimeline = new ScrollTimeline({
      source: document.scrollingElement!,
      orientation: 'vertical',
    });

    element.animate(keyframes, {
      timeline: scrollTimeline,
      fill: 'both',
    });
  }

  private attachFallback(
    element: Element,
    keyframes: Keyframe[],
    threshold: number
  ): void {
    this.observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) {
            element.animate(keyframes, {
              duration: 600,
              easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
              fill: 'forwards',
            });
            this.observer?.unobserve(element);
          }
        }
      },
      { threshold }
    );
    this.observer.observe(element);
  }

  // 视差效果:基于滚动位置的位移
  attachParallax(
    element: Element,
    speed: number = 0.5
  ): void {
    let ticking = false;

    window.addEventListener('scroll', () => {
      if (!ticking) {
        requestAnimationFrame(() => {
          const scrollY = window.scrollY;
          const offset = scrollY * speed;
          element.animate(
            [{ transform: `translateY(${offset}px)` }],
            { duration: 0, fill: 'forwards' }
          );
          ticking = false;
        });
        ticking = true;
      }
    }, { passive: true });
  }
}

3.4 动画性能监控

class AnimationPerformanceMonitor {
  private frameTimes: number[] = [];

  startMonitoring(animation: Animation): void {
    const measureFrame = () => {
      const start = performance.now();

      requestAnimationFrame(() => {
        const frameTime = performance.now() - start;
        this.frameTimes.push(frameTime);

        if (animation.playState === 'running') {
          measureFrame();
        }
      });
    };

    measureFrame();
  }

  getReport(): {
    avgFrameTime: number;
    p95FrameTime: number;
    droppedFrames: number;
  } {
    const sorted = [...this.frameTimes].sort((a, b) => a - b);
    const avg = sorted.reduce((a, b) => a + b, 0) / sorted.length;
    const p95 = sorted[Math.floor(sorted.length * 0.95)];
    const dropped = sorted.filter(t => t > 16.67).length;

    return {
      avgFrameTime: avg,
      p95FrameTime: p95,
      droppedFrames: dropped,
    };
  }
}

四、WAAPI 的兼容性与性能边界

浏览器支持的渐进性:WAAPI 的核心 API(animate、play、pause)在所有现代浏览器中支持,但高级特性(ScrollTimeline、ViewTimeline、GroupEffect)仅在 Chrome 115+ 中支持。Safari 和 Firefox 的支持进度较慢。生产环境需要特性检测和降级方案。

合成器线程的限制:只有 transform 和 opacity 属性的动画可以在合成器线程运行。其他属性(如 width、height、box-shadow)的动画会触发布局重计算,阻塞主线程。使用 WAAPI 时仍需遵循"仅动画 transform 和 opacity"的性能原则。

动画取消的资源泄漏:Animation 对象在取消后仍持有对 DOM 元素的引用。如果频繁创建和取消动画(如列表滚动时),可能导致内存泄漏。建议使用动画池复用 Animation 对象,或在动画完成后调用 animation.cancel() 释放引用。

fill: 'forwards' 的层叠问题:fill: 'forwards' 使动画保持最终状态,但这个状态通过"动画层"覆盖在原始样式之上。如果后续通过 JavaScript 修改同一属性,修改可能被动画层覆盖。解决方案是动画完成后移除动画(animation.commitStyles() + animation.cancel()),将最终状态写入行内样式。

五、总结

Web Animations API 的核心价值在于"声明式定义 + 命令式控制"——用 CSS 风格的关键帧定义动画,用 JavaScript 精确控制播放、进度和时序。本文方案的核心模式为:关键帧定义 → 时序参数配置 → 播放控制与组合 → 滚动驱动 → 性能监控。落地时需重点关注三个原则:仅动画 transform 和 opacity(确保合成器线程运行)、使用 fill: 'both' 保持状态、动画完成后 commitStyles 并 cancel。建议从简单的入场动画开始使用 WAAPI,逐步替代 requestAnimationFrame 手写动画,并建立动画组件库统一管理关键帧和时序参数。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/leopold_man/article/details/161974224

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--