面试题:500条数据渲染加载卡顿(分页),不能使用懒加载、虚拟滚动,如何解决?

2025年08月23日 Tags: Js


这阵子面试,面试官问了一个问题:500条数据渲染卡顿如何解决?当时没答上来,说的用 canvas 画虚拟列表...下来研究了一下。没想到是用 requestAnimationFrame ,很久没用了,也没想起来,回顾一下,加深印象。

RequestAnimationFrame()

定义

告诉浏览器在下一次重绘之前,调用用户提供的回调函数。通常用于告诉浏览器你希望执行一个动画。

语法

requestAnimationFrame(callback)

参数

返回值

一个整数,表示回调的唯一标识符。可以将该值传递给 window.cancelAnimationFrame() 用来取消回调。注意,如果递归调用,则应更新标识符,传递给 window.cancelAnimationFrame() 的标识符应是最新值。

对回调函数的调用频率通常与显示器的刷新率相匹配,最常见的刷新率是60HZ(每秒60个周期或帧)。但是,requestAnimationFrame()一次性的,如果想要在浏览器重绘之前继续更新下一帧动画,回调函数自身必须再次调用 requestAnimationFrame()

⚠️请确保总是使用第一个参数(callback的第一个参数)或其他一些获取当前时间的方法)来计算动画在一帧中的进度,否则动画在高刷新率的屏幕中运行得更快。

上面这句话意思是说,递归调用 requestAnimationFrame() 时,屏幕每刷新一次就会执行一次 requestAnimationFrame() 的回调。对于 60HZ 的屏幕,每秒会执行回调 60 次;对于 120HZ 的屏幕,每秒会执行回调 120 次。假设每次回调执行时,让元素向右移动 2px,那么对于这两种刷新频率,物体每秒实际上会分别移动 120px240px。但实际上,我们期望在这两种屏幕上,元素移动的效果应是一致的。因此就需要通过计算时间差来计算动画进度。

有两种方式可以用来计算时间差,一种是使用回调参数,另一种是使用 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 和浏览器事件循环

注意事项

  1. requestAnimationFrame() 是一次性的,如果要实现动画,需要在回调函数中调用 requestAnimationFrame()

  2. 使用 cancelAnimationFrame() 来取消回调时,传入的回调标识符应是最新的。

  3. 应该通过计算时间差的方式来计算动画进度,保持在不同刷新率的屏幕中动画的效果是一致的。

  4. 对于某些浏览器,在首次调用 requestAnimationFrame() 和首次调用回调函数之间会有多帧延迟,可以通过设置时间零点来解决。

  5. 页面首次只有在首次加载完成后才会执行 requestAnimationFrame() 的回调。也就是如果在 <head> 中使用了 requestAnimationFrame() ,其回调也只会在 FCP 后执行。这与浏览器的渲染调度优化有关,避免进行不必要的渲染。

补充

时间源(time origin)(实验性🧪):当前文档生命周期的开始节点的标准时间。

确定方式:

参考链接

MDN Window:requestAnimationFrame() 方法

← 上一篇: Cookie,sessionStorage,localStorage,IndexedDB 介绍

下一篇: → Bug —— 首次加载页面,向后端发起请求时返回502,再次发起请求时成功