前端动画技术方案总结
2025-08-18 16:11:34 # 前端开发

动画类型

序列帧(逐帧)动画

是通过顺序播放一系列连续的静态图像,由人眼的视觉暂留效应来产生动画效果,如常见的GIF格式动画。
这种方式简单粗暴,便于精细控制,适合用来表现复杂的动作或细节,常用于2D动画、游戏角色动作等。但由于每一帧都需要单独绘制或生成,导致制作成本加大,最终生成的文件体积也较大。

补间(关键帧)动画

关键帧动画只需要定义动画中的重要帧(关键帧),中间帧由算法自动生成。

前端动画的技术实现

CSS transitionanimation 属性是最简单便捷的实现关键帧动画的方式。

CSS Transitions

css中的transition 属性可以为一个元素定义在不同状态之间切换时的过渡效果。比如在不同的伪类之间切换,像是 :hover:active 或者通过 JavaScript 实现的状态变化。

button {
  background-color: #3d3d3d;
  transition: background-color .5s ease-in;
}

button:hover {
  background-color: #252627;
}

CSS Animations

transition只能定义初始和终止状态之间的过渡效果,而animation 可以通过@keyframes 规则定义多个状态帧,从而实现更为精细、复杂的过渡动画效果。

 @keyframes move {
   from {
     transform: translate(0, 0);
   }
   50% {
     transform: translate(150px, 100px);
   }
   to {
     transform: translate(10px, 10px);
   }
 }
div {
  animation: move 2s ease-in-out infinite;
  animation-play-state: running; /* paused / running */
}

还可同时定义多个不同的动画效果:

@keyframes fade {
   from {
     opacity: 1;
   }
   50% {
     opacity: .25;
  }
   to {
     opacity: 1;
   }
 }
div {
  animation: 
    move 2s ease-in-out infinite, 
    fade 1s ease-out infinite;
}

JS + DOM

使用js控制DOM样式是一种比较传统的动画实现方式,这种方式灵活性高、兼容性好,而且既可以实现逐帧动画,也可以实现关键帧动画。

  1. js+雪碧图实现逐帧动画
    雪碧图(sprite, 也叫精灵图)技术是将多个小图标合成到一张大图里,然后通过css中的background-position 属性来控制背景剪切的区域。因此可以利用这种方式,将动画帧制作成雪碧图,再用js动态修改background-position 属性值来实现逐帧动画。
    举例说明,如雪碧图中的帧按照从左到右、从上到下的顺序排列,每一帧的尺寸为W x H
/**
 * 使用 background-position 和 requestAnimationFrame 实现逐帧动画
 * @param {HTMLElement} element - 需要动画的元素
 * @param {string} imageUrl - 背景图片的 URL
 * @param {number} frameWidth - 每一帧的宽度
 * @param {number} frameHeight - 每一帧的高度
 * @param {number} totalFrames - 总帧数
 * @param {number} loopCount - 循环次数(Infinity 表示无限循环)
 */
function createFrameAnimation(element, imageUrl, frameWidth, frameHeight, totalFrames, loopCount = Infinity) {
  let currentFrame = 0; // 当前帧索引
  let currentLoop = 0; // 当前循环次数
  let isPlaying = true; // 是否正在播放
  let animationId = null; // requestAnimationFrame 的 ID

  // 设置背景图片和初始样式
  element.style.backgroundImage = `url(${imageUrl})`;
  element.style.backgroundRepeat = 'no-repeat';
  element.style.width = `${frameWidth}px`;
  element.style.height = `${frameHeight}px`;

  // 更新 background-position 的函数
  function updateFrame() {
    if (!isPlaying) return;

    // 计算当前帧的 background-position
    const xOffset = -currentFrame * frameWidth;
    element.style.backgroundPosition = `${xOffset}px 0`;

    // 更新帧索引
    currentFrame = (currentFrame + 1) % totalFrames;

    // 如果当前帧回到 0,表示完成一次循环
    if (currentFrame === 0) {
      currentLoop++;
      if (currentLoop >= loopCount) {
        stopAnimation();
        return;
      }
    }

    // 继续下一帧
    animationId = requestAnimationFrame(updateFrame);
  }

  // 开始动画
  function startAnimation() {
    if (isPlaying) return;
    isPlaying = true;
    animationId = requestAnimationFrame(updateFrame);
  }

  // 停止动画
  function stopAnimation() {
    isPlaying = false;
    if (animationId) {
      cancelAnimationFrame(animationId);
      animationId = null;
    }
  }

  // 重置动画
  function resetAnimation() {
    currentFrame = 0;
    currentLoop = 0;
    element.style.backgroundPosition = '0 0';
  }

  // 初始化
  startAnimation();

  // 返回控制方法
  return {
    start: startAnimation,
    stop: stopAnimation,
    reset: resetAnimation,
  };
}

也可以通过transform: translate3d() 直接移动动画元素并开启GPU加速,不过动画元素需要再套一层wrapper元素,并为其设置overflow: hidden; ,原理相同,不再赘述。

  1. 通过js动态修改DOM元素的css属性实现逐帧动画
    通过js动态修改DOM元素的css属性值能实现更加精细、复杂的动效控制,如改变元素的位置、颜色和透明度等。

  2. 通过js实现DOM关键帧动画
    js也可以实现关键帧动画:预先定义好关键帧的状态,然后在关键帧之间通过js计算中间状态并更新DOM样式。
    步骤如下:

    1. 定义关键帧
    2. 计算插值
    3. 更新DOM
      代码实现
/**
 * 关键帧动画类
 * @param {HTMLElement} element - 需要动画的元素
 * @param {Array} keyframes - 关键帧数组,格式为 [{ time: 0, styles: { ... } }, ...]
 * @param {number} duration - 动画总时长(毫秒)
 */
class KeyframeAnimation {
  constructor(element, keyframes, duration) {
    this.element = element;
    this.keyframes = keyframes.sort((a, b) => a.time - b.time); // 按时间排序
    this.duration = duration;
    this.startTime = null;
    this.isPlaying = false;
    this.animationId = null;
  }

  // 开始动画
  start() {
    if (this.isPlaying) return;
    this.isPlaying = true;
    this.startTime = performance.now();
    this.animate();
  }

  // 停止动画
  stop() {
    this.isPlaying = false;
    if (this.animationId) {
      cancelAnimationFrame(this.animationId);
      this.animationId = null;
    }
  }

  // 重置动画
  reset() {
    this.stop();
    this.applyStyles(this.keyframes[0].styles); // 重置到第一帧
  }

  // 动画循环
  animate() {
    if (!this.isPlaying) return;

    const currentTime = performance.now() - this.startTime;
    const progress = Math.min(currentTime / this.duration, 1); // 计算进度(0 到 1)

    // 应用当前帧的样式
    this.applyFrame(progress);

    // 如果动画未结束,继续下一帧
    if (progress < 1) {
      this.animationId = requestAnimationFrame(() => this.animate());
    } else {
      this.stop(); // 动画结束
    }
  }

  // 应用当前帧的样式
  applyFrame(progress) {
    const frame = this.getCurrentFrame(progress);
    this.applyStyles(frame.styles);
  }

  // 获取当前帧的样式
  getCurrentFrame(progress) {
    const time = progress * this.duration; // 当前时间
    let startFrame, endFrame;

    // 找到当前时间所在的关键帧区间
    for (let i = 0; i < this.keyframes.length - 1; i++) {
      if (time >= this.keyframes[i].time && time <= this.keyframes[i + 1].time) {
        startFrame = this.keyframes[i];
        endFrame = this.keyframes[i + 1];
        break;
      }
    }

    // 如果没有找到区间,返回最后一帧
    if (!startFrame || !endFrame) {
      return this.keyframes[this.keyframes.length - 1];
    }

    // 计算插值
    const frameProgress = (time - startFrame.time) / (endFrame.time - startFrame.time);
    const interpolatedStyles = this.interpolateStyles(startFrame.styles, endFrame.styles, frameProgress);

    return { styles: interpolatedStyles };
  }

  // 插值计算
  interpolateStyles(startStyles, endStyles, progress) {
    const styles = {};
    for (const key in endStyles) {
      if (typeof startStyles[key] === 'number' && typeof endStyles[key] === 'number') {
        styles[key] = startStyles[key] + (endStyles[key] - startStyles[key]) * progress; // 线性插值
      } else {
        styles[key] = endStyles[key]; // 非数字属性直接使用结束值
      }
    }
    return styles;
  }

  // 应用样式到 DOM 元素
  applyStyles(styles) {
    for (const key in styles) {
      this.element.style[key] = styles[key];
    }
  }
}

使用方法

const box = document.getElementById('box');

const keyframes = [
  { time: 0, styles: { transform: 'translateX(0px)', opacity: 1 } },
  { time: 500, styles: { transform: 'translateX(200px)', opacity: 0.5 } },
  { time: 1000, styles: { transform: 'translateX(400px)', opacity: 1 } },
];

const animation = new KeyframeAnimation(box, keyframes, 1000); // 1000ms 动画
animation.start(); // 开始动画
// animation.stop(); // 停止动画
// animation.reset(); // 重置动画

此外,还有许多第三方库可以实现关键帧动画,如anime.js 等。

利用animation steps()实现逐帧动画

animation-timing-function 属性设置动画在每个周期的持续时间内如何进行。它的值除了常见的几个缓动函数的关键字(linear ease ease-in 等)和三次贝赛尔曲线cubic-bezier() 函数外,还可设置为steps(n) 阶跃函数,它表示动画在相邻两个关键帧之间按照n 个定格发生跳跃,具体可参考MDN文档
因此,我们可以利用这个特性,实现帧跳跃:

.sprite {
    width: 100px;
    height: 100px;
    background-repeat: no-repeat;
    background-image: url(frames.png);
    animation: move 100ms steps(1, end) both infinite;
}

@keyframes move {
    from {background-position: 0 0;}
    5% {background-position: -100px 0;}
    10% {background-position: -200px 0;}
  ...
    to {background-position: -1000px 0;}
}

如不需要精确控制中间状态,也可简化成

.sprite {
    ...
    animation: move 2s steps(20) both infinite;
}

@keyframes move {
  from {background-position: 0 0;}
    to {background-position: -1000px 0;}
}

表示将0%~100%这个区间分成20步来阶段性展示。

animation-delay和css变量实现动画

animation-delay 属性可以设置animation 动画的延迟时间,当值为负数时,它表示动画从初始状态之前的某个时刻开始运行。利用这种机制,我们将animation-delay 属性设为负值,同时,将animation-play-state 属性设置为paused ,此时,动画将保持静止在过去的某一时刻的状态。我们只需要利用css变量动态地修改animation-delay 的值,就能实现动画的效果。
这种方式的好处是它能有效利用animation 的自动插补算法实现复杂动画,同时方便地控制动画的运行状态,因此非常便于实现一些与用户交互动作有关的复杂动画。

div {
  width: 0;
  height: 10px;
  background-color: red;
  animation: grow 2s infinite;
  animation-play-state: paused;
  animation-delay: var(--delay);
}

@keyframes grow {
  to {
    width: 100%;
    background-color: yellow;
  }
}
const div = document.querySelectorAll('div')[0]

let t = 0
requestAnimationFrame(function grow() {
  t = (t + 0.01) % 2;
  div.style.setProperty('--delay', `-${t}s`);
  
  requestAnimationFrame(grow);
})

SVG

路径动画

Canvas

FLIP动画思想

新兴的前端动画技术

Animation API

Houdini API

ViewTransition API

https://caniuse.com/?search=View%20Transitions%20Api

Variable Fonts

Scroll-driven Animations