【译】前端requestAnimationFrame概述

2,405 阅读8分钟

对JavaScript中requestAnimationFrame函数的简单介绍,用以实现流畅的动画。原文链接:medium.com/p/456f8a0d0…

requestAnimationFrame是一个让我感到兴奋的玩意,不是因为他有多复杂,而是这名字听起来就足够让人浮想联翩以致于很想去尝试。requestAnimationFrame函数与动画无关,你可以用它做很多事。

我们来谈谈什么是动画。动画其实是一种假象,是一种不连续的运动以帧的形式呈现给我们的东西。在二十世纪,通常人们观看的电影其实就是通过胶片记录和投影的。它是以每秒至少24帧的速度形成的视觉上的运动起来的假象。NTSC广播的标准的帧速率为23.975FPS,而PAL制式的为25FPS

因此,至少要以24FPS的速率才能形成动画,但这样的动画并不是平滑的,流畅的。平滑的动画要以无线帧速率才能实现,但是对于人类大脑而言是侦测不到那种情况下的帧速率,可以说60FPS就已经很不错了。常见的电脑、智能手机等大部分现代化设备通常是以60FPS的速率刷新屏幕的,少部分游戏系统则支持120FPS。

那么,什么又是帧呢?这个没有绝对的定义,它主要是依赖于使用的具体环境。例如,电影胶片的每一帧都是由所记录的FPS决定的。在录制视频时,把摄像机的帧率调为30FPS,那么就必须以30FPS的速率在1s内播放生成的30个单独图像。然而,在讨论web时,帧的定义又发生了变化。

对于web动画,我们可以在设备屏幕中移动1px或者更多。移动一个元素(DOM元素)的像素越少,那么动画就越流畅,越平滑。帧其实就是DOM元素在屏幕上的实时位置的一个快照。在1s内,如果一个元素以 1px/次 的速度移动60px,那么FPS值就是60。也就是说,上面等价于以 2px/次 的速度移动120px。虽然移动速度变大了,但是动画并不会更加流畅平滑,因为相应的元素的移动距离也变大了。

那么,如何使用JavaScript让DOM元素产生动画效果呢?可以使用JavaScript中的setInterval函数。setInterval可以以n毫秒的间隔时间调用回调函数,而且必须在回调函数的第二个参数传入n值。为了实现60FPS,我们需要以60次/s 的速度移动一个元素,那意味着元素必须移动大约16.7ms(100ms/60frames)。接下来,我们在回调执行中移动DOM元素 1px,而且每16.7ms需要调用一次回调。

<div id="box"></div>
#box {
  width: 50px;
  height: 50px;
  background-color: #000;
}
var element = document.getElementById('box');
var left = 0;

var animateCallback = function() {
	element.style.marginLeft = (++left) + 'px';

  // clear interval after 60 frame is moved
  if (left == 60) {
    clearInterval(interval);
  }
}

var interval = setInterval(animateCallback, (1000 / 60));

通过上面的代码,我们成功实现了一个平滑的动画效果,但是在PC或者手机上面显示时会存在一个很大的问题,并且很难被发现。那就是setInterval函数在n毫秒后,并不能保证被调用(想了解更多请阅读Mozilla文档)。总的说来,setInterval被看成是延迟执行回调函数的web API。然而,回调函数总是会被阻塞,这意味着如果网页正忙于处理其它事务,回调就不得不等待,直到栈中的异步任务被清空为止(了解更多,请阅读how JavaScript works,该链接可以让你了解到栈是什么,以及web api工作原理)。不仅如此,回调函数的执行可能会消耗掉比16.7ms更长的时间,这就意味着,动画将运行超过1s(此时,回调执行60次),60FPS也就无法实现。

接下来,我们了解一下什么是失帧?首先,浏览器会以最大m次/秒刷新屏幕。数字m取决于电脑的屏幕刷新率,浏览器的刷新率,以及CPU、GPU的处理能力。如果你的浏览器只能以30帧/s的速度刷新屏幕(由于上面的一个或者多个原因造成),那么以60帧/秒的速度运行动画是没有什么意义的,多余的帧数将会消失。与此同时,对DOM结构所做的更改要比浏览器渲染的要多,这也被称为布局抖动,因为这些操作是同步的,会影响网站的性能以及绘制操作,从而导致动画效果不佳。

浏览器只在屏幕有样式更改,布局改动,以及回流时才刷新屏幕

此时,需要来自浏览器的某种回调函数,他会告诉我们下一次屏幕刷新的时间,或者更准确的说,是下一次绘制操作将在何时执行。这个回调函数就是requestAnimationFrame Web API。

作为一个web api,rAF将被异步调用。和setInterval不一样,requestAnimationFrame不接收delay参数(这里的delay指的就是setInterval的第二个参数),它只在浏览器准备执行下一次绘制操作时调用回调函数,因此我们要在回调函数中移动DOM元素。

让我们看一下前面的requestAnimationFrame函数例子

<div id="box"></div>
#box{
  width: 50px;
  height: 50px;
  background-color: #000;
}
var element = document.getElementById('box');
var left = 0;
var rAF_ID;

var rAFCallback = function(){
	element.style.marginLeft = (++left) + 'px';
  // cancel animation frame after 60px
  if( left == 60 ) {
    cancelAnimationFrame(rAF_ID);
  }else {
  	rAF_ID = requestAnimationFrame( rAFCallback );
  }
}

rAF_ID = requestAnimationFrame( rAFCallback );

对比前后js代码,我们可能已经注意到了setInterval和requestAnimationFrame之间的一些差异。首先rAF不会在每次每次绘制时自动调用。每次更改元素时都需要发出请求。当浏览器计划进行下一次绘制操作时,这些调用将被一一压栈,并被执行。栈中队列可以在for循环,while循环中或者在更加准确的递归函数中进行。

requestAnimationFrame返回请求的id(整数),我们可以使用这个id来取消请求,使用cancelAnimationFrame(id)方法会取消回调的执行,从而停止动画的执行(这里使用的递归)。rAF并不能保证提供60FPS的动画效果,这只是一种避免丢帧以及提高效率的方法,从而帮助获取更多的FPS值。这就意味着,如果我们通过使用移动多少像素来取消动画,例如上面例子的60px,那么根据系统中浏览器刷新率,动画可以持续1s(60FPS)、2s(120FPS)或者更长时间。

那么,我们究竟应该如何保证在FPS为任意值时,我们的动画必须在1s内完成,且元素在1s内移动60px呢?这时候就出现了回调函数参数。

当我们把回调函数参数传递给rAF时,rAF将传递时间戳参数(timestamp),该参数以毫秒(ms)为单位,表示web页面加载以来消耗的时间。该时间戳函数给出了调用回调的准确时间。

因此,在我们想出解决这个难题的逻辑之前,我们已经知道了动画的持续时间(1s)和距离(60px),我们需要计算在对应的时间里,我们的progress(根据上下文理解)有多少,再用这个progress乘以60px,这里的progress代表在第一次回调执行以来已经用掉了多少时间。公式如下:

progress = ( starttime - timestamp ) / duration

用progress值乘以距离,我们得到了在下一次绘制时DOM元素的像素值。

position = distance * progress

一旦progress达到了100% ,我们就需要停止调用requestAnimationFrame函数。此时可能会出现progress超过100%的情况, 出现开始的时间间隔为980ms (第一次回调操作的时间戳和当前的时间戳之间的差异),下次则可能为1050ms。

当为980ms时,我们不能停止动画,因为如果按照上面的公式,我们还没有完全移动元素,这就是为什么我们需要最小的progress值(100)。

公式如下:

safeProgress = Math.min(progress, 1) // 1 == 100%

上面的公式也可能出现因为progress的浮点数而造成位置也有浮点数。在这里我们计算的是css像素,它与设备像素十分不同(深入阅读请点击链接。css像素实际上是像素密度值,即在实际设备上渲染一个像素对象(CSS中提到的像素)需要占用多少像素。因此,我们可以使用css像素浮点值,幸运的话我们仍可以在实际设备上通过一些像素来使DOM元素移动。代码如下

<div id="box"></div>
#box{
  width: 50px;
  height: 50px;
  background-color: #000;
}
var element = document.getElementById('box');
var startTime;
var duration = 1000; // 1 second or 1000ms
var distance = 60; // 60FPS

var rAFCallback = function( timestamp ){
	startTime = startTime || timestamp; // set startTime is null

  var timeElapsedSinceStart = timestamp - startTime;
  var progress = timeElapsedSinceStart / 1000;

  var safeProgress = Math.min( progress.toFixed(2), 1 ); // 2 decimal points

  var newPosition = safeProgress * distance;

  element.style.transform = 'translateX('+ newPosition + 'px)';

  // we need to progress to reach 100%
  if( safeProgress != 1 ){
  	requestAnimationFrame( rAFCallback );
  }
}

// request animation frame on render
requestAnimationFrame( rAFCallback );

注意:这里使用transform而不是marginLeft

以上就是requestAnimationFrame的全部运行机制,requestAnimationFrame相较于setInterval或者setTimeout还有其他优势,就是当浏览器tab页面未使用时,requestAnimationFrame会通过组织requestAnimationFrame回调的方式暂停动画,这样既能节省电量又能保留动画的状态。而唯一的不足可能就是它天生的不确定性,我们不知道它何时被调用,但这就是我们必须要面对的。