2025年08月23日 Tags: Js
这阵子面试,面试官问了一个问题:500条数据渲染卡顿如何解决?当时没答上来,说的用 canvas
画虚拟列表...下来研究了一下。没想到是用 requestAnimationFrame
,很久没用了,也没想起来,回顾一下,加深印象。
告诉浏览器在下一次重绘之前,调用用户提供的回调函数。通常用于告诉浏览器你希望执行一个动画。
requestAnimationFrame(callback)
DOMHigeResTimeStamp
(一个 double
类型的时间戳),表示上一帧渲染结束的时间(基于 time origin 的毫秒数)。 此时间戳在同源的所有窗口之间共享。一个整数,表示回调的唯一标识符。可以将该值传递给 window.cancelAnimationFrame()
用来取消回调。注意,如果递归调用,则应更新标识符,传递给 window.cancelAnimationFrame()
的标识符应是最新值。
对回调函数的调用频率通常与显示器的刷新率相匹配,最常见的刷新率是60HZ(每秒60个周期或帧)。但是,requestAnimationFrame()
是一次性的,如果想要在浏览器重绘之前继续更新下一帧动画,回调函数自身必须再次调用 requestAnimationFrame()
。
⚠️请确保总是使用第一个参数(callback的第一个参数)或其他一些获取当前时间的方法)来计算动画在一帧中的进度,否则动画在高刷新率的屏幕中运行得更快。
上面这句话意思是说,递归调用 requestAnimationFrame()
时,屏幕每刷新一次就会执行一次 requestAnimationFrame()
的回调。对于 60HZ 的屏幕,每秒会执行回调 60 次;对于 120HZ 的屏幕,每秒会执行回调 120 次。假设每次回调执行时,让元素向右移动 2px
,那么对于这两种刷新频率,物体每秒实际上会分别移动 120px
和 240px
。但实际上,我们期望在这两种屏幕上,元素移动的效果应是一致的。因此就需要通过计算时间差来计算动画进度。
有两种方式可以用来计算时间差,一种是使用回调参数,另一种是使用 performance.now()
。
假设界面上有一个 100 * 100 px 的 div
,让其以 100px 每秒的速度向右移动,移动 200px 后停止动画。
const oDiv = document.querySelector("div");
const oButton = document.querySelector("button");
let speed = 100; // 100px/秒
let position = 0;
let lastTime = 0,
startTime = 0;
let rafID = null;
function animate(currentTime) {
if (lastTime === 0) { lastTime = currentTime };
// 计算时间差
const deltaTime = currentTime - lastTime; // 对于刷新率为 60HZ 的屏幕,约等于 16.66666...ms
lastTime = currentTime;
const distance = (speed * deltaTime) / 1000;
position += distance;
if (position >= 200) {
cancelAnimationFrame(rafID);
return;
};
oDiv.style.transform = `translateX(${position}px)`;
rafID = requestAnimationFrame(animate); // 每次调用需要更新id
}
oButton.addEventListener("click", () => {
requestAnimationFrame(animate);
});
使用 performance.now()
确定代码从某处开始执行经过了多少时间:
const startTime = performance.now();
// do something for a while ...
const elapsedTime = performance.now() - startTime;
对于某些浏览器,在首次调用 requestAnimationFrame() 和首次调用回调函数之间会有多帧延迟,可以通过设置时间零点来解决(第一个回调函数执行时设置 zero)。
以下代码展示了一个 div
元素在逐渐显示的过程:
const oDiv = document.querySelector('div');
const oButton = document.querySelector('button');
let zero;
function firstFrame(timeStamp) {
zero = timeStamp; // 设置时间零点
animate(timeStamp);
}
function animate(timeStamp) {
// 去掉延迟
const value = (timeStamp - zero) / 1000; // 随着时间延长,值逐渐变大
if (value < 1) {
oDiv.style.opacity = value;
requestAnimationFrame((t) => animate(t));
} else {
oDiv.style.opacity = 1;
}
}
oButton.addEventListener('click', () => {
requestAnimationFrame(firstFrame);
})
使用 requestAnimationFrame()
的优点是:当用于渲染大量数据时,不会影响页面交互效果(如用户输入、点击按钮等)。这是因为浏览器的事件循环会根据任务优先级进行调度,用户输入、点击、滚动等事件比 rAF
具有更高的优先级,因此会被优先处理。如果回调中涉及耗时过长大量计算,浏览器会中断 rAF
回调,建议计算放入 Worker 中进行处理。
🧐待研究:requestAnimationFrame 和浏览器事件循环
requestAnimationFrame()
是一次性的,如果要实现动画,需要在回调函数中调用 requestAnimationFrame()
。
使用 cancelAnimationFrame()
来取消回调时,传入的回调标识符应是最新的。
应该通过计算时间差的方式来计算动画进度,保持在不同刷新率的屏幕中动画的效果是一致的。
对于某些浏览器,在首次调用 requestAnimationFrame() 和首次调用回调函数之间会有多帧延迟,可以通过设置时间零点来解决。
页面首次只有在首次加载完成后才会执行 requestAnimationFrame()
的回调。也就是如果在 <head>
中使用了 requestAnimationFrame()
,其回调也只会在 FCP 后执行。这与浏览器的渲染调度优化有关,避免进行不必要的渲染。
时间源(time origin)(实验性🧪):当前文档生命周期的开始节点的标准时间。
确定方式:
- 全局对象为 Window:
- 首次加载 Document(浏览器上下文)的创建时间
- 跳转页面:用户确认导航到新页面的时间
- 除以上,则为页面导航发生的时间(罕见)
- 全局对象为 WorkerGlobalScope:
- 时间源为 worker 被创建的时刻
- 其他情况,时间源值为 undefined