各位观众老爷们,掌声在哪里!咳咳,好吧,没人鼓掌,我假装听见了。今天咱们聊点刺激的,关于用requestAnimationFrame
和setTimeout
这对欢喜冤家,一起搞出精确到让像素都哭泣的帧动画控制。准备好了吗?发车!
第一幕:requestAnimationFrame
的爱与恨
首先,我们要认识一下requestAnimationFrame
这位爷。浏览器亲儿子,性能优化利器,动画界的扛把子之一。它的作用很简单,就是告诉浏览器:“嘿,哥们,我有个动画要搞,你悠着点,在下一次重绘之前帮我执行一下!”
function animate() {
// 这里写动画相关的逻辑
console.log("我动了!");
requestAnimationFrame(animate); // 循环调用
}
requestAnimationFrame(animate); // 启动动画
看起来很美好是不是?但是,理想很丰满,现实很骨感。requestAnimationFrame
的回调执行时机,是由浏览器决定的。它会尽量保证每秒60帧(60fps),也就是大约16.67ms执行一次。
问题来了:
- 不稳定!不稳定!不稳定! 浏览器忙的时候,或者电脑卡的时候,帧率会掉,掉成狗!可能变成30fps,甚至更低。
- 不精确!不精确!不精确! 就算帧率稳定,每次回调的时间间隔也不一定完全相等。可能16ms,可能17ms,甚至15ms。
这对追求精确控制的强迫症患者来说,简直是噩梦!想象一下,你辛辛苦苦设计的动画,本来应该卡着节拍跳舞,结果跳成了广场舞,节奏全乱了。
第二幕:setTimeout
的救赎与缺陷
这个时候,setTimeout
老同志站了出来:“年轻人,不要慌!我来帮你!” setTimeout
可以设置一个延迟时间,然后执行一个回调函数。
function animate() {
// 这里写动画相关的逻辑
console.log("我动了!");
setTimeout(animate, 16); // 16ms后再次调用
}
setTimeout(animate, 16); // 启动动画
看起来,setTimeout
可以完美地控制动画的帧率,比如设置16ms,就能保证每秒60帧。
但是,setTimeout
也有自己的问题:
- 不准时!不准时!不准时!
setTimeout
的回调函数不是立即执行的,而是要等到当前任务队列中的所有任务都执行完毕后,才会被添加到任务队列中。这意味着,实际的执行时间,可能会比设置的延迟时间更长。 - 容易丢帧!容易丢帧!容易丢帧! 如果某个回调函数执行时间过长,或者有其他的任务阻塞了任务队列,
setTimeout
的回调函数可能会被延迟到下一帧执行,导致丢帧。
总之,setTimeout
虽然可以控制帧率,但是精度不够,容易丢帧,动画效果可能不够流畅。
第三幕:双剑合璧,天下无敌?
现在,我们有了两个都有缺陷的工具。但是,如果把它们结合起来,能不能取长补短,实现精确的帧动画控制呢?
答案是:可以!
核心思想是:
- 使用
requestAnimationFrame
来保证动画的流畅性。 - 使用
setTimeout
来校正requestAnimationFrame
的时间间隔,保证动画的精度。
具体实现如下:
let startTime = null; // 动画开始时间
let frameDuration = 16.67; // 期望的帧间隔(60fps)
let lastFrameTime = null; // 上一帧的时间
function animate(currentTime) {
if (!startTime) {
startTime = currentTime;
lastFrameTime = currentTime;
}
const elapsed = currentTime - lastFrameTime; // 实际帧间隔
// 这里写动画相关的逻辑
console.log("我动了! 实际间隔:", elapsed.toFixed(2), "ms");
const delay = frameDuration - elapsed; // 需要延迟的时间
// 确保延迟时间大于等于0,防止setTimeout立即执行
const adjustedDelay = Math.max(delay, 0);
setTimeout(() => {
lastFrameTime = currentTime + adjustedDelay;
requestAnimationFrame(animate);
}, adjustedDelay);
}
requestAnimationFrame(animate);
这段代码的关键在于:
- 计算实际帧间隔:
elapsed = currentTime - lastFrameTime
,计算出requestAnimationFrame
实际执行的时间间隔。 - 计算需要延迟的时间:
delay = frameDuration - elapsed
,计算出为了保证帧率,需要延迟的时间。 - 使用
setTimeout
进行校正:setTimeout(() => { ... }, adjustedDelay)
,使用setTimeout
来延迟下一次requestAnimationFrame
的调用,保证动画的精度。 Math.max(delay, 0)
: 确保setTimeout
的延迟时间永远不会是负数,避免立即执行。
代码解释:
- 初始化:
startTime
记录动画开始的时间,frameDuration
是我们期望的帧间隔 (60fps 约等于 16.67ms),lastFrameTime
记录上一帧的时间,初始值为null
。 animate(currentTime)
函数:currentTime
是requestAnimationFrame
传递给回调函数的参数,表示当前时间。- 初始化开始时间和上一帧时间: 如果
startTime
为null
,表示这是第一帧,所以初始化startTime
和lastFrameTime
为currentTime
。 - 计算实际帧间隔:
elapsed = currentTime - lastFrameTime
计算出实际经过的时间,即实际帧间隔。 - 动画逻辑: 在这里编写你的动画逻辑。
console.log
用于输出实际帧间隔,方便调试。 - 计算延迟时间:
delay = frameDuration - elapsed
计算出为了达到期望的帧间隔,需要延迟的时间。如果elapsed
大于frameDuration
,则delay
为负数,表示实际帧间隔已经超过了期望值,不需要额外延迟。 - 调整延迟时间:
adjustedDelay = Math.max(delay, 0)
确保延迟时间不小于 0。如果delay
为负数,则将其设置为 0,避免setTimeout
立即执行。 - 使用
setTimeout
延迟下一帧:setTimeout
用于在adjustedDelay
毫秒后执行一个匿名函数。在这个匿名函数中,更新lastFrameTime
为currentTime + adjustedDelay
,并使用requestAnimationFrame(animate)
请求下一帧动画。
- 启动动画: 使用
requestAnimationFrame(animate)
启动动画。
第四幕:更上一层楼:时间戳的妙用
上面的代码虽然可以提高动画的精度,但是仍然存在一些问题:
- 漂移问题: 由于
setTimeout
本身的不精确性,长期运行下来,动画可能会出现漂移,也就是实际时间和期望时间出现偏差。 - 误差积累: 每次计算延迟时间,都可能存在一定的误差,这些误差会逐渐积累,导致动画的精度下降。
为了解决这些问题,我们可以使用时间戳来进行校正。
let startTime = null; // 动画开始时间
let frameDuration = 16.67; // 期望的帧间隔(60fps)
let expectedNextFrameTime = null; // 期望的下一帧时间
function animate(currentTime) {
if (!startTime) {
startTime = currentTime;
expectedNextFrameTime = currentTime + frameDuration;
}
const elapsed = currentTime - startTime; // 动画运行的总时间
// 这里写动画相关的逻辑
console.log("我动了! 运行总时间:", elapsed.toFixed(2), "ms");
const delay = expectedNextFrameTime - currentTime; // 需要延迟的时间
const adjustedDelay = Math.max(delay, 0);
setTimeout(() => {
expectedNextFrameTime += frameDuration; // 更新期望的下一帧时间
requestAnimationFrame(animate);
}, adjustedDelay);
}
requestAnimationFrame(animate);
这段代码的关键在于:
- 使用
expectedNextFrameTime
记录期望的下一帧时间: 每次回调函数执行完毕后,都会更新expectedNextFrameTime
,而不是使用实际的currentTime
来计算延迟时间。 - 基于
startTime
计算总时间:elapsed = currentTime - startTime
,用于在动画逻辑中使用,可以更准确地知道动画运行的总时间。
代码解释:
- 初始化:
startTime
记录动画开始的时间,frameDuration
是我们期望的帧间隔 (60fps 约等于 16.67ms),expectedNextFrameTime
记录期望的下一帧的时间,初始值为null
。 animate(currentTime)
函数:currentTime
是requestAnimationFrame
传递给回调函数的参数,表示当前时间。- 初始化开始时间和期望的下一帧时间: 如果
startTime
为null
,表示这是第一帧,所以初始化startTime
为currentTime
,并初始化expectedNextFrameTime
为currentTime + frameDuration
。 - 计算动画运行的总时间:
elapsed = currentTime - startTime
计算出动画已经运行的总时间。 - 动画逻辑: 在这里编写你的动画逻辑。
console.log
用于输出运行总时间,方便调试。 - 计算延迟时间:
delay = expectedNextFrameTime - currentTime
计算出为了达到期望的帧间隔,需要延迟的时间。 - 调整延迟时间:
adjustedDelay = Math.max(delay, 0)
确保延迟时间不小于 0。 - 使用
setTimeout
延迟下一帧:setTimeout
用于在adjustedDelay
毫秒后执行一个匿名函数。在这个匿名函数中,更新expectedNextFrameTime
为expectedNextFrameTime + frameDuration
,并使用requestAnimationFrame(animate)
请求下一帧动画。 关键在于,这里更新的是 期望 的下一帧时间,而不是依赖于 实际 发生的时间。
- 启动动画: 使用
requestAnimationFrame(animate)
启动动画。
这种方法的优点:
- 减少漂移: 通过使用时间戳,可以有效地减少动画的漂移,保证动画的长期精度。
- 减少误差积累: 每次回调函数执行完毕后,都会更新
expectedNextFrameTime
,而不是使用实际的currentTime
来计算延迟时间,从而减少误差积累。
第五幕:高级技巧:帧率补偿
即使使用了时间戳校正,动画的帧率仍然可能不稳定。例如,当浏览器卡顿时,帧率可能会下降到30fps,甚至更低。
为了解决这个问题,我们可以使用帧率补偿。帧率补偿的原理是:根据实际的帧率,调整动画的速度,使动画在不同帧率下看起来速度一致。
let startTime = null; // 动画开始时间
let frameDuration = 16.67; // 期望的帧间隔(60fps)
let expectedNextFrameTime = null; // 期望的下一帧时间
function animate(currentTime) {
if (!startTime) {
startTime = currentTime;
expectedNextFrameTime = currentTime + frameDuration;
}
const elapsed = currentTime - startTime; // 动画运行的总时间
const deltaTime = currentTime - (expectedNextFrameTime - frameDuration); // 实际帧间隔
const timeScale = deltaTime / frameDuration; // 时间缩放比例
// 这里写动画相关的逻辑
// 例如:
// element.style.left = startLeft + speed * elapsed * timeScale + 'px';
console.log("我动了! 运行总时间:", elapsed.toFixed(2), "ms", "时间缩放比例:", timeScale.toFixed(2));
const delay = expectedNextFrameTime - currentTime; // 需要延迟的时间
const adjustedDelay = Math.max(delay, 0);
setTimeout(() => {
expectedNextFrameTime += frameDuration; // 更新期望的下一帧时间
requestAnimationFrame(animate);
}, adjustedDelay);
}
requestAnimationFrame(animate);
这段代码的关键在于:
- 计算
deltaTime
:deltaTime = currentTime - (expectedNextFrameTime - frameDuration)
,计算出实际的帧间隔。注意,这里不是简单地用currentTime - lastFrameTime
,而是用当前时间减去 期望的 上一帧时间,这样可以更准确地反映实际帧间隔与期望帧间隔的偏差。 - 计算
timeScale
:timeScale = deltaTime / frameDuration
,计算出时间缩放比例。如果timeScale
大于1,表示实际帧间隔大于期望帧间隔,动画应该减速;如果timeScale
小于1,表示实际帧间隔小于期望帧间隔,动画应该加速。 - 在动画逻辑中使用
timeScale
: 在动画逻辑中,将动画的速度乘以timeScale
,就可以实现帧率补偿。
代码解释:
- 初始化: 与之前类似,初始化
startTime
,frameDuration
,expectedNextFrameTime
。 animate(currentTime)
函数:currentTime
是requestAnimationFrame
传递给回调函数的参数,表示当前时间。- 初始化: 如果
startTime
为null
,则初始化startTime
和expectedNextFrameTime
。 - 计算动画运行的总时间:
elapsed = currentTime - startTime
。 - 计算实际帧间隔:
deltaTime = currentTime - (expectedNextFrameTime - frameDuration)
。 这是关键一步,它计算了 实际经过的时间 与 期望经过的时间 之间的差异。 - 计算时间缩放比例:
timeScale = deltaTime / frameDuration
。timeScale
表示实际帧间隔与期望帧间隔的比率。 - 动画逻辑: 在这里编写你的动画逻辑。 重要的是,在更新动画状态时,要将
timeScale
考虑进去。例如,如果你的动画涉及位置变化,你应该将位置变化量乘以timeScale
。element.style.left = startLeft + speed * elapsed * timeScale + 'px';
这个例子展示了如何将timeScale
应用于位置变化。speed
是每毫秒移动的像素数。 - 延迟下一帧: 与之前类似,计算延迟时间并使用
setTimeout
延迟下一帧。 - 更新期望的下一帧时间:
expectedNextFrameTime += frameDuration
。
- 启动动画: 使用
requestAnimationFrame(animate)
启动动画。
示例:
假设有一个元素要从左向右移动,速度为每秒100像素。
let startLeft = 0; // 元素的初始位置
let speed = 100 / 1000; // 元素每毫秒移动的像素数
在动画逻辑中,可以这样更新元素的位置:
element.style.left = startLeft + speed * elapsed * timeScale + 'px';
这样,无论帧率如何变化,元素都会以每秒100像素的速度移动。
第六幕:总结与展望
今天,我们学习了如何使用requestAnimationFrame
和setTimeout
这对欢喜冤家,一起实现精确的帧动画控制。
requestAnimationFrame
: 保证动画的流畅性。setTimeout
: 校正requestAnimationFrame
的时间间隔,保证动画的精度。- 时间戳: 减少漂移和误差积累。
- 帧率补偿: 保证动画在不同帧率下速度一致。
通过这些技巧,我们可以制作出更加流畅、精确的动画,提升用户体验。
当然,动画的世界远不止这些。还有各种各样的优化技巧、动画算法、特效等等,等待我们去探索。希望今天的讲座能给大家带来一些启发,让大家在动画的道路上越走越远。
最后,记住一句至理名言:没有最好的动画,只有不断优化的动画!
感谢大家的观看,下次再见! (谢幕)
表格总结:
技术点 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
requestAnimationFrame |
浏览器优化,性能好,流畅 | 时间间隔不稳定,不精确 | 对精度要求不高的动画,追求流畅性 |
setTimeout |
可以控制帧率 | 不准时,容易丢帧,精度差 | 简单的定时任务,对动画效果要求不高 |
requestAnimationFrame + setTimeout (无时间戳) |
结合了requestAnimationFrame 的流畅性和setTimeout 的帧率控制 |
依然存在漂移和误差积累,精度有限 | 对精度有一定要求的动画,但不需要长期运行 |
requestAnimationFrame + setTimeout (有时间戳) |
在上一方案的基础上,减少了漂移和误差积累,提高了长期精度 | setTimeout 本身的不精确性依然存在 |
对精度有较高要求的动画,需要长期运行 |
requestAnimationFrame + setTimeout (有时间戳 + 帧率补偿) |
在上一方案的基础上,实现了帧率补偿,保证动画在不同帧率下速度一致,抗卡顿能力强 | 实现较为复杂 | 对精度和稳定性要求极高的动画,需要适应不同的运行环境 |