开篇
我们之前在这篇文章里面讲过浏览器的事件循环,还提到事件队列,调用栈等浏览器的一些实现机制。但还有一些细节我们没有提到,这篇文章我们就来把这些细节补充。
帧和动画
你一定知道动画片是怎样制作的,没错,只需要很多张画满动画的纸张,只要这些纸张的动画情景是按照时间的连续性排列,那么他们按照一定的速度在你的眼前切换,你就能看到一部完整的动画片了。我们的电脑播放的各种操作动画也是一样的道理。你端坐在电脑面前的时候,gpu就是不断地在屏幕上绘制图片才会让你觉得电脑真的是在动起来了。人的眼睛如果在1秒之内看到有超过60张图片在切换,那么我们就相当于看到了一部动画一样。在能看到动画最低的动画是60帧,就是1面内60次的连续动作,我们会认为这个动画是流畅的。按照计算我们可以得出1000/60 = 16.6ms为一帧(frame)。大多数电脑也是按照这个速率刷新我们的屏幕。
渲染流水线
和电脑一样,浏览器也是按照这个频率把网页上的元素的变化反馈给GPU的。如果屏幕的刷新率60fps,那么我们的js脚本执行一次我们就是重绘一次界面会不会太浪费了,因为js脚本的执行大多数时候非常短暂。为了避免这种不必要的浪费,我们的浏览器是按照电脑刷新频率执行绘制的界面的任务。也就是说假设我们改变dom的脚本时间只用了1ms,那么需要等待一段时间10~15ms才会执行我们的绘制界面任务工作。这期间消息队列中会是空的,不会有渲染相关的任务被推入消息队列被执行。我们操作dom的场景一般如下代码所示:
document.getElementById("box").style = "height: 100px";
这段代码修改了界面上一个id为box的样式,我们虽然只执行了一条简短的语句,但是浏览器却为我们做了很多事情:
- 执行javascript脚本
- 计算界面元素的css样式
- 重新计算界面元素的布局
- 开始开始绘制界面
- 合成层(如果有需要的话)
Javascript操作dom时产生的变化是需要浏览器执行界面的绘制任务才能被更新到屏幕上的,在事件循环中,我们依次将这些任务入队,然后执行。但是我们之前提到过,浏览器的刷新率是60fps,也就是说,后面三步并不是总是接着javascript脚本的执行而执行的,而是需要等待屏幕刷新之前执行这三个任务。一般来说这个时间是大概是16.6ms,也就是屏幕刷新率60fps。所以,我们的脚本执行如果非常快的话,那么操作结果就会“等待”屏幕刷新才会被用户看到,这个等待时间非常短,对于人的眼睛来说完全不会有延迟,但是对于高速运转的计算机,可以节约很多绘制界面的任务和资源。
定时器
使用settimeout
或者setinterval
来渲染动画存在一些问题,首先就是他们最小间隔时间是4.7ms,而并非是你指定的0,所以当你用settimeout
0 来执行你的动画,你会发现实际上移动的速度是要比预期的执行速度快的。我们之前说过,界面绘制的速率是16.6ms左右,而定时器最小执行时间是4.7ms,所以,在执行了3-4次定时器函数之后,我们才能看到一个片段被绘制在屏幕上,正确的应该是给定时器设置16.6ms的时间,才正好与屏幕刷新率同步。
16.6ms这个时间只是我们推算出来的一个事件,定时器对这个时间只需并不准确,而却随着执行次数的推一,误差会越来越远离实际值
使用定时器运行动画函数的另外一个缺点就是settimeout在每一帧的执行时间会受到其他任务和自身的影响,这种影响如果叠加,会影响到定时器在下一帧出现的位置。为了形象地说明,我们来看下面这张图。
从上面的图来看,我们虽然可以确保定时器执行的间隔的时间,但是无法确定定时器执行时所在一帧的位置,而且在某些情况下,过长的执行时间会导致后面的绘制任务被推后,从而影响动画执行的流程度。
总得来说使用定时器来执行动画有以下几种缺点:
- 定时器的计算过大会影响动画的流畅度,而过小则影响动画的速率。
- 定时器的延迟时间其实是并不精确,并且随着时间推移精度会逐渐下降。
- 定时器的执行时机会被其他长任务影响,所以并不好准确控制每一帧的开始位置。
requestAnimationFrame
raf之所以会适合做动画效果,其中一个很大的原因就是raf的执行速率与屏幕刷新的速率相同。浏览器会在下一次界面绘制之前执行raf函数。这个时间并不需要我们自己去指定,浏览器已经自己定义好了。我们可以看下面的图:
可以看到,左边是js脚本的循环赛道,右边是raf、渲染流水线的赛道。左边任务执行的频率是实时的,也就是说一旦有任务被推入了消息队列,立马执行,而右边则是有规律的执行,这个规律则是屏幕刷新律,也就是大概16.6ms就执行一次。所以在rAF中的代码,执行速率是与定时器中的不一样的。我们以一下代码为例:
el.style.display = "block";
el.style.display = "none";
el.style.display = "block";
el.style.display = "none";
el.style.display = "block";
如果你解了刷新原理,就能很好的回答上面的问题,由于js执行的非常快,上面的语句几乎可以在1ms内执行完成,但是我们的绘制任务需要等待下一次屏幕刷新之前才能执行,因此我们只会看到最后一条js执行的结果,实际上界面不会有任何变化。
使用raf的另外一个好处就是因为raf处在每一帧的最前面,所以有足够多的剩余时间去执行自身或者其他计算绘制的任务,保证所有的任务都在一帧内被执行,如图所示:
与其他大多数浏览器不一样,苹果系统在处理raf函数时讲这个函数执行顺序放到了刷新界面之后,导致的问题就是有一帧无法被观察到。
总结
这次我们介绍了定时器以及rAF函数,他们的工作原理以及实现动画的优先选项。其实如果你深入了解过浏览器的一些渲染机制,就能很好地理解定时器和rAF函数的区别,以及为什么我们会优先选择后者作为动画的执行的函数。不仅仅在dom上,我们在处理2d或者3d动画的时候,都是采用的rAF函数让计算机去计算每个物体的位置变化。在处理大数据视觉上我们更需要关心的性能,定时器显然跟不上我们的需求。
参考文档
What the heck is the event loop anyway? -Philip Roberts
In The Loop -Jake Archibald