为什么requestAnimationFrame()在帧末运行我的代码而不是帧初?

4
var y = 0
canvas.height *= 5
ctx.fillStyle = 'green'
function update () {
  requestAnimationFrame(update)
   ctx.clearRect(0, 0, canvas.width, canvas.height)
  ctx.fillRect(0, y, 300, 300)
  y++
}
update()

对于这个简单的JSBin(https://jsbin.com/yecowob/edit?html,js,output),其中一个正方形沿着屏幕移动,这是Chrome开发工具时间轴的样子:

https://imgur.com/Ai0BxY0

据我理解,垂直虚线是当前帧的结束和下一帧的开始。在屏幕截图中,我们有一个持续19.3毫秒的帧,浏览器几乎没有做任何事情(很多空闲时间)。如果浏览器在帧开始时立即运行所有代码,它不会遇到这种情况吗?
然而,如果我将正方形重复绘制500次,在6倍CPU减速(https://jsbin.com/yecowob/4/edit?js,output)时,有时浏览器会按照我希望的方式执行代码(在帧开始时开始运行),但之后又会失去同步:

https://imgur.com/Y04XCrz

当它开始在虚线上运行时,帧率更加流畅,但我只能在浏览器需要大量处理时才能使其起作用。那么为什么requestAnimationFrame()不会每次在帧的开头立即触发,我该如何做到这一点呢?非常感谢您对此提供的任何帮助。
2个回答

4
因为这就是requestAnimationFrame的作用:它将一个回调函数安排在下一帧“绘制”之前触发,正好在实际绘制到屏幕之前。
这里的“帧”指的是事件循环迭代,而非视觉上的帧,此后我将继续使用“事件循环迭代”来做出区分。
因此,如果我们查看HTML规范中描述的事件循环迭代的结构,我们可以看到“运行动画帧回调”算法是从“更新渲染”算法内部调用的。
这个算法在步骤2中负责确定当前事件循环迭代是否为绘图迭代,方法是检查每个活动文档的“渲染机会”。 如果不是,则所有以下步骤都将被丢弃,包括我们的“运行动画帧回调”。
这意味着我们requestAnimationFrame预定的回调函数将只在非常特殊的事件循环迭代中执行:下一个带有渲染机会的迭代。
规范没有精确地描述这些“绘画帧”应该以多大的频率发生,但基本上大多数当前供应商都试图维护60Hz,而Chrome会让它限制在活动显示器的刷新率上。
因此,如果您想要一个简化版本的解释,可以将requestAnimationFrame(fn)视为setTimeout(fn,time_needed_until_the_next_painting_frame)(这里略有不同,即超时回调在事件循环迭代的开头执行,而动画帧回调则在末尾执行)。
为什么会被设计成这样?因为大多数情况下,我们希望在屏幕上绘制最新的信息。因此,让这些回调在绘图之前触发可以确保每个需要绘制的元素都处于其最新位置。
但这也意味着,确实不能在其中进行太重的操作,否则可能会失去绘图机会。
现在,我必须指出有一个正在进行的提案requestPostAnimationFrame,它会安排回调函数在下一次“绘制帧”之后立即触发,也就是在实际绘制到屏幕之后。
使用这种方法,您将获得所期望的行为。
不幸的是,这仍然只是一个提案,尚未被纳入规范,并且不确定是否会被采纳。
虽然它已经在Chrome中实现,但需要通过"Experimental Web Platform features"标志才能启用。我们可以做的最好的事情是在下一个事件循环迭代的开头安排一个回调函数,以逼近其行为。
以下是我为另一个问题/答案制作的示例实现:

if (typeof requestPostAnimationFrame !== 'function') {
  monkeyPatchRequestPostAnimationFrame();
}

requestAnimationFrame( animationFrameCallback );
requestPostAnimationFrame( postAnimationFrameCallback );

// monkey-patches requestPostAnimationFrame
//!\ Can not be called from inside a requestAnimationFrame callback
function monkeyPatchRequestPostAnimationFrame() {
  console.warn('using a MessageEvent workaround');
  const channel = new MessageChannel();
  const callbacks = [];
  let timestamp = 0;
  let called = false;
  channel.port2.onmessage = e => {
    called = false;
    const toCall = callbacks.slice();
    callbacks.length = 0;
    toCall.forEach(fn => {
      try {
        fn(timestamp);
      } catch (e) {}
    });
  }
  window.requestPostAnimationFrame = function(callback) {
    if (typeof callback !== 'function') {
      throw new TypeError('Argument 1 is not callable');
    }
    callbacks.push(callback);
    if (!called) {
      requestAnimationFrame((time) => {
        timestamp = time;
        channel.port1.postMessage('');
      });
      called = true;
    }
  };
}


// void loops, look at your dev-tools' timeline to see where each fires
function animationFrameCallback() {
  requestAnimationFrame( animationFrameCallback );
}
function postAnimationFrameCallback() {
  requestPostAnimationFrame( postAnimationFrameCallback )
}


嗨@Kaiido。非常感谢您提供的详细解释。虽然它没有解决我的问题,但它帮助我更好地理解了它,尽管仍有一个方面我无法理解:使用requestPostAnimationFrame()可以让我的代码在绘制帧后立即运行,但合成器执行的渲染和绘画仍会在帧末调用。在我的第二个截图中(https://imgur.com/Y04XCrz),渲染和绘画在我的代码运行后立即被调用(这就是我想要的),但我不知道是什么原因导致了这种情况,也不知道是否有可能以任何方式影响它。 - Dan Birsan
我注意到的是,在这里:https://imgur.com/a/Kwi55aY,Compositor中的Frame Start(第二个)会在Draw Frame之前触发一点点。对于所有我想要的帧,Frame Start会在当前帧的绘制帧(垂直虚线)之前触发。 - Dan Birsan
这是因为事件循环的设计方式。如果您查看我的答案中的链接,您将了解到所有合成操作也必须在这些“绘画帧”上进行,而且对于此,您无法改变它们发生的时间。 - Kaiido

0

我在最新版的Chrome中测试了您的第二段代码片段,发现结果与第一段相同:

enter image description here

所以只需参考@kaiido的答案。我认为那是正确的答案。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接