核心概念:时间线、补间动画、缓动函数、关键帧的底层原理

好,咱们今天来聊聊动画库里的四个核心概念。说实话,这四个东西搞明白了,市面上百分之九十的动画库你都能一眼看穿。我当年刚入行时,被这些术语绕得晕头转向,后来自己动手撸了一个简易动画引擎,才算真正吃透。

咱们一个一个来拆解。

时间线(Timeline)——动画的总指挥

时间线是什么?说白了,它就是一个时间轴管理器。你想想看,一个页面上可能有几十个动画同时在跑:A元素在移动,B元素在旋转,C元素在淡出。谁先谁后?谁快谁慢?谁暂停谁继续?这些都得有个统一调度。

时间线的核心职责就三条:

  • 管理时间进度——记录当前时间、总时长、播放状态
  • 调度子动画——控制多个补间动画的添加、移除、排序
  • 驱动更新循环——通过 requestAnimationFrame 或 setInterval 不断推进时间

我在项目中遇到过一个问题:多个动画同时触发时,如果各自独立跑自己的计时器,很容易出现不同步。比如一个元素移动了100px,另一个旋转了360度,按理说应该同时结束,结果因为计时器精度问题,一个先停一个后停。嗯,这就是没有统一时间线的后果。

一个典型的时间线实现长这样:

class Timeline {
  constructor() {
    this.tweens = [];
    this.currentTime = 0;
    this.isPlaying = false;
  }

  add(tween) {
    this.tweens.push(tween);
  }

  update(deltaTime) {
    this.currentTime += deltaTime;
    this.tweens.forEach(tween => {
      tween.update(this.currentTime);
    });
  }

  play() {
    this.isPlaying = true;
    const loop = () => {
      if (!this.isPlaying) return;
      this.update(16.67); // 约60fps
      requestAnimationFrame(loop);
    };
    loop();
  }
}

注意看,所有补间动画都共享同一个 currentTime。这就是同步的秘诀。

补间动画(Tween)——动画的最小单元

补间动画,英文叫 Tween,全称是 in-betweening。意思就是「在中间插值」。你给它一个起始值和一个结束值,它帮你算出中间每一帧的值。

举个例子:

const tween = new Tween({
  target: element,
  from: { x: 0, y: 0 },
  to: { x: 100, y: 200 },
  duration: 1000
});

这个补间动画会在1秒内,把 element 从 (0,0) 平滑移动到 (100,200)。

补间动画的内部逻辑其实很简单:

  1. 记录起始值和结束值
  2. 根据当前时间计算进度百分比(0~1)
  3. 用进度值在起始值和结束值之间做线性插值
  4. 把计算结果应用到目标对象上

核心代码就这几行:

class Tween {
  constructor(config) {
    this.target = config.target;
    this.from = config.from;
    this.to = config.to;
    this.duration = config.duration;
    this.startTime = null;
  }

  update(currentTime) {
    if (!this.startTime) this.startTime = currentTime;
    const elapsed = currentTime - this.startTime;
    let progress = Math.min(elapsed / this.duration, 1);

    // 遍历所有属性,做线性插值
    for (let key in this.from) {
      const value = this.from[key] + (this.to[key] - this.from[key]) * progress;
      this.target[key] = value;
    }
  }
}

这里有个坑:如果直接做线性插值,动画看起来会很生硬。为什么?因为现实世界中的运动很少是匀速的。这就引出了下一个概念。

缓动函数(Easing)——让动画活起来

缓动函数,说白了就是「变速器」。它把线性的时间进度,映射成非线性的动画进度。

我建议你把缓动函数理解成一个黑盒子:

  • 输入:0 到 1 的线性进度
  • 输出:0 到 1 的变速进度

常见的缓动函数有这些:

名称 效果 适用场景
ease-in 先慢后快 物体离开画面
ease-out 先快后慢 物体进入画面
ease-in-out 两头慢中间快 弹窗出现/消失
linear 匀速 机械运动、进度条
bounce 弹跳效果 拟物化交互

实现一个缓动函数其实不复杂,比如 ease-out 的数学公式是:

function easeOut(t) {
  return 1 - Math.pow(1 - t, 3);
}

你想想看,如果 t=0.5,线性进度是50%,但经过 easeOut 映射后,实际进度变成了 1 - (0.5)^3 = 0.875,也就是87.5%。这就是「先快后慢」的数学原理。

我曾经踩过一个坑:在实现弹跳效果时,直接用物理公式模拟,结果计算量太大导致掉帧。后来改用预计算好的贝塞尔曲线,性能直接翻倍。嗯,这里要注意,缓动函数不是越复杂越好,够用就行。

关键帧(Keyframe)——动画的里程碑

关键帧这个概念,最早来自传统动画行业。动画师只画几个关键姿势,中间的动作由助手补全。在代码里,关键帧就是「在特定时间点,属性应该达到的特定值」。

一个关键帧动画通常包含:

  • 多个时间点(比如 0%、50%、100%)
  • 每个时间点对应的属性值
  • 相邻关键帧之间的插值方式

举个例子:

const keyframes = [
  { time: 0,    x: 0,   y: 0   },
  { time: 0.5,  x: 100, y: 50  },
  { time: 1,    x: 200, y: 0   }
];

这个动画会先向右下移动,再向右上移动,形成一个弧线轨迹。

关键帧的底层实现,本质上就是「分段补间动画」。每一对相邻关键帧之间,就是一个独立的补间动画。引擎需要做的是:

  1. 根据当前时间,找到它落在哪两个关键帧之间
  2. 计算在这两个关键帧之间的局部进度
  3. 对这两个关键帧的属性值做插值

代码实现大致是这样:

class KeyframeAnimation {
  constructor(keyframes) {
    this.keyframes = keyframes;
  }

  getValueAtTime(t) {
    // 找到 t 落在哪两个关键帧之间
    for (let i = 0; i < this.keyframes.length - 1; i++) {
      const kf1 = this.keyframes[i];
      const kf2 = this.keyframes[i + 1];
      if (t >= kf1.time && t <= kf2.time) {
        const localT = (t - kf1.time) / (kf2.time - kf1.time);
        // 对每个属性做插值
        const result = {};
        for (let key in kf1) {
          if (key === 'time') continue;
          result[key] = kf1[key] + (kf2[key] - kf1[key]) * localT;
        }
        return result;
      }
    }
    return this.keyframes[this.keyframes.length - 1];
  }
}

核心要点总结:

  • 时间线是总指挥,负责统一调度和同步
  • 补间动画是最小单元,负责单个属性的插值
  • 缓动函数是变速器,让动画更自然
  • 关键帧是里程碑,实现复杂的分段动画

我的个人建议:

刚开始学动画库时,别急着用高级API。先手动实现一个最简单的补间动画,把时间线、缓动函数、插值逻辑都自己写一遍。这个过程会让你对底层原理有刻骨铭心的理解。

避坑指南:

我曾经在项目里直接用了第三方动画库的「高级关键帧功能」,结果在低端手机上卡成PPT。后来一查,那个库在关键帧之间用了复杂的贝塞尔插值,计算量太大。解决方案很简单:把关键帧数量减少,或者改用线性插值加缓动函数。记住,动画效果和性能之间永远需要权衡。

好了,这四个核心概念就讲到这里。下一章咱们会深入源码,看看主流动画库(比如 GSAP、Anime.js)是怎么实现这些机制的。到时候你会发现,万变不离其宗,底层逻辑就是今天讲的这些东西。