Interpol:低层级插值与动画实现
项目起源
三年前,我开始开发一个名为Interpol的轻量级插值库,这是一个用于处理Web上数值补间和平滑运动的低层级引擎。在本文中,我将解释该库的起源以及它如何成为我动画工作流程的关键部分。
Interpol旨在使用类似GSAP的API来插值多组数值。它的主要区别在于它不是"真正的"动画库,而是一个插值引擎。
核心需求
我对这个库的要求如下:
- 轻量级:打包大小约3.5kB
- 低层级:可维护且可预测,无魔法操作,无DOM API,仅进行数值插值
- 高性能:Interpol实例应在单个Ticker循环实例中批量更新
- 多重插值:每个实例需要插值多组数值,而不仅是一个
- 链式插值:需要创建具有实例偏移的时间线
- 接近GSAP和anime.js的API:看起来像我已经习惯的API
- 强类型:用TypeScript编写,具有强类型
- 可选RAF:提供不使用内部requestAnimationFrame的可能性
插值原理
线性插值(称为lerp)是一个数学函数,用于在两个值之间找到一个值:
1
2
3
|
function lerp(start: number, end: number, amount: number): number {
return start + (end - start) * amount
}
|
当我们在循环中使用它时,魔法就发生了:
1
2
3
4
5
6
|
const loop = (): void => {
const value = lerp(from, to, easing((now - start) / duration))
// 对值进行操作...
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
|
补间与阻尼对比
补间
使用补间,插值严格受时间限制。当我们在1000毫秒内对值进行补间时,该持续时间定义了一个绝对边界。
1
2
3
4
5
6
|
const from = 0
const to = 100
const duration = 1000
const easing = (t) => t * t
const progress = easing((now - start) / duration)
const value = lerp(from, to, progress)
|
阻尼
使用"阻尼lerp",不涉及时间概念。它是纯基于帧的技术。
1
2
3
4
|
let current = 0
let target = 100
let amount = 0.05
current = lerp(current, target, amount)
|
API深入解析
Interpol构造函数
1
2
3
4
5
6
7
8
9
10
|
import { Interpol } from "@wbe/interpol"
new Interpol({
x: [0, 100],
y: [300, 200],
duration: 1300,
onUpdate: ({ x, y }, time, progress, instance) => {
// 对值进行操作...
},
})
|
数值定义方式
1
2
3
4
5
6
7
8
9
10
11
|
// [起始值, 目标值] 数组
x: [0, 100],
// 使用对象代替数组(带有可选的特定缓动函数)
foo: { from: 50, to: 150, ease: "power3.in" }
// 隐式起始值为0,类似 [0, 200]
bar: 200
// 使用可重新评估的计算值
baz: [100, () => Math.random() * 500]
|
核心方法
1
2
3
4
5
6
7
|
itp.play()
itp.reverse()
itp.pause()
itp.resume()
itp.stop()
itp.refresh()
itp.progress()
|
时间线与算法
时间线允许我们对Interpol实例进行排序:
1
2
3
4
5
6
7
8
9
10
11
12
|
import { Timeline } from "@wbe/interpol"
const tl = new Timeline()
tl.add({
x: [-10, 100],
duration: 750,
ease: t => t * t
onUpdate: ({ x }, time, progress) => {},
})
tl.play(.3) // 从时间线进度的30%开始
|
时间线算法
时间线算法基于这样一个事实:进度低于0或高于1不会被动画化。在这种情况下,Interpol实例根本不播放,而是"前进"到低于0或高于1。
1
2
3
4
5
6
7
|
updateAdds(tlTime: number, tlProgress: number) {
this.adds.forEach((add) => {
// 为每个add计算0到1之间的进度
add.progress.current = (tlTime - add.time.start) / add.instance.duration
add.instance.progress(add.progress.current)
})
}
|
偏移量
偏移量涉及重新计算所有add的开始和结束位置:
1
2
3
|
tl.add({}, 110) // 在110毫秒绝对开始
tl.add({}, "-=110") // 在-110毫秒相对开始
tl.add({}, "+=110") // 在+110毫秒相对开始
|
性能优化
回调批处理
此类库的第一个主要优化是将所有回调(onUpdate)执行批处理到单个顺序队列中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// 全局Ticker实例(包含唯一的RAF)
// 被所有Interpol和Timeline实例使用
const ticker = new Ticker()
class Interpol {
play() {
ticker.add(this.update)
}
stop() {
this.ticker.remove(this.update)
}
update = () => {
// 在每一帧上执行所有操作
}
}
|
DOM更新批处理
另一个重要的优化是在同一帧中批处理所有DOM写入操作。通过共享的ticker同步所有更新,确保这些样式更改一起发生,最大限度地减少布局抖动并减少回流成本。
设计选择与解决方案
回到"轻量级库"的主题,这要求我做出几个选择:首先,不开发我当前不需要的任何功能;其次,过滤掉我可以用现有工具表达的API功能。
例如,Interpol中没有重复功能。但我们可以通过循环调用start来简单实现它:
1
2
3
|
const repeat = async (n: number): void => {
for (let i = 0; i < n; i++) await itp.play();
};
|
对于交错动画,我们可以使用Interpol的delay或Timeline的偏移量来满足这一需求:
1
2
3
4
5
6
7
8
|
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
const itp = new Interpol({
delay: i * 20,
x: [innerWidth / 2, () => random(0, innerWidth)],
// ... 其他属性
});
}
|
结论
Interpol是我的个人研究项目,我在我的项目中使用它,即使这只是我继续维护它的50%原因。另外50%是因为这个库让我能够深入探讨机制、实现选择,并理解性能问题。