当网页上的元素优雅地移动、渐变或响应用户操作时,背后往往有一位"功臣"在默默工作------window.requestAnimationFrame
(RAF)。它是浏览器提供的高性能动画调度接口,旨在让我们的视觉更新与屏幕的刷新节奏同步,尽最大努力避免动画卡顿和画面撕裂,带来丝般顺滑的体验。
requestAnimationFrame
的基础运作
最基础的 RAF 用法,是构建一个自我驱动的循环:
javascript
function myAnimationLoop(timestamp) {
// 1. 根据当前时间戳 `timestamp` 更新动画状态
// ... 动画逻辑 ...
// 2. 请求浏览器在下一次重绘前再次调用此函数
requestAnimationFrame(myAnimationLoop);
}
// 启动动画循环
requestAnimationFrame(myAnimationLoop);
这个模式的核心在于:你定义一个函数来执行单帧的动画更新,然后通过 requestAnimationFrame
将这个函数注册给浏览器,浏览器会尽可能在下一次屏幕刷新前(通常是大约 1/60 秒后)调用它,并传入一个高精度的时间戳 timestamp
(类似于 performance.now()
的值)。为了让动画动起来,你需要在函数内部再次调用 requestAnimationFrame
。
深入探索:时间戳背后的时序世界
看到这里,你可能会问:既然浏览器会规律地调用回调,为什么还需要传入一个 timestamp
参数呢?
这正是 RAF 设计的精妙之处,也是我们深入理解动画时序控制的关键。想象一下,如果动画的更新逻辑是"每次调用就移动 1 像素",在性能强劲、稳定 60fps 的设备上,它看起来可能不错。但如果换到一台性能较弱或负载较高的设备上,帧率可能下降到 30fps,甚至更低。这时,同样是"每次调用移动 1 像素",动画的实际速度就会慢一半!这显然不是我们想要的。
timestamp
的存在,就是为了让我们能够实现基于时间的动画,而非基于帧率的动画。通过计算两帧之间实际经过的时间差(Delta Time),我们可以精确地控制动画元素在单位时间内应该发生的改变,从而在不同刷新率的设备上保持一致的视觉速度。
场景一:让物体匀速移动
假设我们想让一个元素以每秒 100 像素的速度向右移动。我们需要这样做:
javascript
const box = document.getElementById('myBox');
let lastTimestamp = 0;
const speed = 100; // 像素/秒
function moveRight(timestamp) {
if (!lastTimestamp) {
// 初始化上一帧的时间戳
lastTimestamp = timestamp;
}
// 计算自上一帧以来经过的时间(转换为秒)
const deltaTime = (timestamp - lastTimestamp) / 1000;
// 记录当前时间戳,供下一帧使用
lastTimestamp = timestamp;
// 根据流逝的时间计算应移动的距离
const distanceToMove = speed * deltaTime;
const currentLeft = parseFloat(box.style.left || 0);
box.style.left = `${currentLeft + distanceToMove}px`;
// 请求下一帧
requestAnimationFrame(moveRight);
}
requestAnimationFrame(moveRight);
在这个例子中,deltaTime
会根据实际帧间隔变化。如果某一帧延迟了,deltaTime
会变大,distanceToMove
相应增加,从而"追赶"上应有的进度,保证了平均速度 的恒定。但这也意味着,我们需要手动记录 lastTimestamp
并进行计算。
场景二:给动画设定"截止日期"
有时我们希望动画在特定时长后结束,比如一个 5 秒的淡出效果。这需要我们跟踪动画已运行的总时间:
javascript
const elementToFade = document.getElementById('fadeMe');
const duration = 5000; // 5 秒,单位毫秒
let animationStartTime = 0;
function fadeOut(timestamp) {
if (!animationStartTime) {
animationStartTime = timestamp;
}
const elapsedTime = timestamp - animationStartTime;
if (elapsedTime >= duration) {
// 时间到,确保最终状态并停止动画
elementToFade.style.opacity = 0;
console.log('Fade out complete.');
return; // 不再请求下一帧
}
// 根据已运行时间计算当前透明度
const progress = elapsedTime / duration;
elementToFade.style.opacity = 1 - progress;
requestAnimationFrame(fadeOut);
}
requestAnimationFrame(fadeOut);
这里,我们引入了 animationStartTime
和 elapsedTime
来控制动画进程和结束条件。看起来可行,但要注意,由于 RAF 的回调时机并非绝对精确,动画结束的那个"点"可能稍早或稍晚于恰好 5000ms。
场景三:挑战固定帧率
如果我们想制作一个老式胶片风格的 12fps 动画呢?原生 RAF 并不能直接设定帧率。我们需要手动"跳帧":
javascript
const targetFPS = 12;
const frameInterval = 1000 / targetFPS; // 每帧应间隔约 83.3ms
let lastFrameTimestamp = -frameInterval; // 确保第一帧能立即执行
let currentFrame = 0;
function lowFpsAnimation(timestamp) {
// 检查是否到达下一帧的执行时间
if (timestamp - lastFrameTimestamp >= frameInterval) {
// 更新时间戳记录
// (更精确的方式可能是 lastFrameTimestamp += frameInterval,但这会引入累计误差问题,处理起来更复杂)
lastFrameTimestamp = timestamp;
// 执行这一帧的动画逻辑
currentFrame++;
console.log(`Rendering frame ${currentFrame} at time ${timestamp.toFixed(0)}`);
// ... 更新画面到第 currentFrame 对应的状态 ...
}
requestAnimationFrame(lowFpsAnimation);
}
requestAnimationFrame(lowFpsAnimation);
实现固定帧率需要开发者自己管理时间间隔判断和帧计数,逻辑开始变得复杂,并且需要小心处理时间戳的更新方式以避免误差累积。
场景四:后台标签页的"搅局"
还记得吗?当页面不在前台时,RAF 会被"冻结"或大幅降频。想象一下,如果我们的匀速移动或限时动画依赖于前面手动计算的 deltaTime
或 elapsedTime
。当用户切换回页面时,timestamp
会突然跳跃一个很大的值,导致 deltaTime
异常巨大。如果我们直接用这个 deltaTime
去计算位移,物体可能会瞬间"飞"出屏幕!对于限时动画,则可能直接跳到结束状态。开发者需要额外添加逻辑来检测和处理这种时间跳跃,例如设置一个 deltaTime
的上限,或者在页面恢复可见时重置起始时间。
小结:原生 RAF 的"双刃剑"
通过这些场景,我们不难发现,requestAnimationFrame
就像一把锋利的双刃剑。它提供了底层的、高性能的动画调度能力,但也要求开发者亲力亲为地处理大量与时间、状态、帧率相关的细节。手动管理时间戳、计算 delta、跟踪已用/剩余时间、实现固定帧率、处理后台挂起......这些不仅增加了代码量,更容易引入难以察觉的 bug,消耗开发精力。
寻求更优解:借助工具库驯服 RAF
既然原生 RAF 的精细控制伴随着不小的复杂度,那么有没有更便捷的方式来驾驭它呢?幸运的是,答案是肯定的。社区中存在许多优秀的 JavaScript 库,它们封装了 RAF 的复杂性,提供了更高层、更易用的接口。@projectleo/tickerjs
就是其中之一,它专注于提供一个清晰、健壮的方式来管理基于 RAF 的动画循环和时间。
让我们看看 Tickerjs 如何简化上述场景:
Tickerjs 方案一:轻松实现限时动画(替代场景二)
想做一个 5 秒的淡出?用 Tickerjs 非常直观:
javascript
import { requestAnimationFrames, five } from '@projectleo/tickerjs';
const elementToFade = document.getElementById('fadeMe');
requestAnimationFrames({
totalTime: five.second, // 直接使用常量 five.second 表示 5000ms
actionOnFrame: ({ remainingTime }) => {
// 直接获取剩余时间 (ms)
elementToFade.style.opacity = remainingTime / five.second;
},
actionOnEnd: () => {
// 动画结束时自动调用
elementToFade.style.opacity = 0;
console.log('Tickerjs fade out complete.');
}
});
// Tickerjs 内部处理了时间跟踪和结束逻辑
Tickerjs 方案二:稳定运行固定帧率动画(替代场景三)
需要 12fps 的动画?同样简单:
javascript
import { requestAnimationFrames, twelve } from '@projectleo/tickerjs';
requestAnimationFrames({
frameRate: twelve.fps, // 设定目标帧率 12fps
actionOnFrame: ({ frameCount }) => {
// frameCount 是库计算好的逻辑帧号 (从 1 开始)
// 即使发生丢帧,frameCount 也会是正确的逻辑序号
console.log(`Tickerjs rendering frame ${frameCount}`);
// ... 更新画面到第 frameCount 对应的状态 ...
}
// 如果需要固定总帧数,可以在 actionOnFrame 中判断 frameCount 到达上限时返回 { continueHandleFrames: false }
});
// Tickerjs 负责处理时间间隔判断和逻辑帧计算
Tickerjs 方案三:获取精确的 Delta Time(辅助场景一)
在需要基于时间进行物理模拟或平滑移动时,delta
参数唾手可得:
javascript
import { requestAnimationFrames } from '@projectleo/tickerjs';
const box = document.getElementById('myBox');
const speed = 100; // 像素/秒
requestAnimationFrames({
actionOnFrame: ({ delta }) => {
// delta 是自上一帧的精确时间差 (秒)
const distanceToMove = speed * delta;
const currentLeft = parseFloat(box.style.left || 0);
box.style.left = `${currentLeft + distanceToMove}px`;
}
});
// Tickerjs 保证了 delta 计算的准确性
此外,Tickerjs 返回的 cancel
函数可以方便地中途停止动画,而内置的时间常量(如 one.second
, thirty.minute
)和工具函数(如 getStructuredTime
, second
)则进一步提升了代码的可读性和开发的便利性。这些辅助工具甚至可以在不使用 requestAnimationFrames
时,单独用于原生 RAF 或其他时间相关的计算中。Tickerjs 还预留了 specifyAnimationFrameManager
接口,用于在特殊环境(如需要 Polyfill 时)注入 RAF 实现同时不污染全局环境。
结语
requestAnimationFrame
是构建高性能 Web 动画不可或缺的底层机制。深刻理解它的工作方式、时间戳的意义以及潜在的挑战(如时序管理、帧率控制、后台行为)是每一位前端开发者进阶的必经之路。
然而,理解底层不代表我们需要在每个项目中都重复手动处理复杂逻辑。当动画逻辑变得复杂,或者仅仅是为了提高开发效率和代码健壮性时,选择一个设计良好、专注于解决这些痛点的库往往是明智之举。它们能将我们从繁琐的细节中解放出来,让我们更专注于实现富有创意的动画效果本身,最终交付更优秀的用户体验。