深入解析Interpol:轻量级插值动画库的设计与实现

本文深入探讨了Interpol轻量级插值动画库的设计理念、核心API和实现细节,包括插值算法、时间线管理、性能优化策略,以及与GSAP等主流动画库的对比分析。

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%是因为这个库让我能够深入探讨机制、实现选择,并理解性能问题。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计