核心概念:时间线、补间动画、缓动函数、关键帧的底层原理
好,咱们今天来聊聊动画库里的四个核心概念。说实话,这四个东西搞明白了,市面上百分之九十的动画库你都能一眼看穿。我当年刚入行时,被这些术语绕得晕头转向,后来自己动手撸了一个简易动画引擎,才算真正吃透。
咱们一个一个来拆解。
时间线(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)。
补间动画的内部逻辑其实很简单:
- 记录起始值和结束值
- 根据当前时间计算进度百分比(0~1)
- 用进度值在起始值和结束值之间做线性插值
- 把计算结果应用到目标对象上
核心代码就这几行:
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 }
];
这个动画会先向右下移动,再向右上移动,形成一个弧线轨迹。
关键帧的底层实现,本质上就是「分段补间动画」。每一对相邻关键帧之间,就是一个独立的补间动画。引擎需要做的是:
- 根据当前时间,找到它落在哪两个关键帧之间
- 计算在这两个关键帧之间的局部进度
- 对这两个关键帧的属性值做插值
代码实现大致是这样:
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)是怎么实现这些机制的。到时候你会发现,万变不离其宗,底层逻辑就是今天讲的这些东西。