我们都知道,要想利用JS实现高性能的动画,那就得使用requestAnimation这个API,简称rAF。
那为甚么用rAF,而不用setTimeout呢?
要清楚这个问题,就要从渲染进程的任务调度系统讲起,理解了渲染进程任务调度系统,自然就明白了rAF和setTimeout的区别。其次,如果了解任务调度系统,那么就能将渲染流水线和浏览器等的知识架构串起来,理解了这些概念,也有利于你理解Performance是如何工作的。
我们知道,渲染进程内部的大多数任务是在主线程上执行的,例如JS执行,DOM,CSS,计算布局,V8垃圾回收等任务。
要让这些任务在主线程上有条不紊的进行,就要进入消息队列。
主线程维护了一个普通的消息队列和一个延迟消息队列,调度模块会按照规则依次取出这两个消息队列中的任务,并在主线程上执行。为了下文讲述方便,在这里我把普通的消息队列和延迟队列都当成一个消息队列。
单消息队列的队头阻塞问题我们知道,渲染主线程会按照先进先出的顺序执行消息队列中的任务,具体地讲,当产生了新的任务,渲染进程会将其添加到消息队列尾部,在执行任务过程中,渲染进程会顺序地从消息队列头部取出任务并依次执行。在最初,采用这种方式没有太大的问题,因为页面中的任务还不算太多,渲染主线程也不是太繁忙。不过浏览器是向前不停进化的,其进化路线体现在架构的调整、功能的增加以及更加精细的优化策略等方面,这些变化让渲染进程所需要处理的任务变多了,对应的渲染进程的主线程也变得越拥挤。下图所展示的仅仅是部分运行在主线程上的任务,你可以参考下:
你可以试想一下,在基于这种单消息队列的架构下,如果用户发出一个点击事件或者缩放页面的事件,而在此时,该任务前面可能还有很多不太重要的任务在排队等待着被执行,诸如 V8 的垃圾回收、DOM 定时器等任务,如果执行这些任务需要花费的时间过久的话,那么就会让用户产生卡顿的感觉。你可以参看下图:
因此,在单消息队列架构下,存在着低优先级任务会阻塞高优先级任务的情况,比如在一些性能不高的手机上,有时候滚动页面需要等待一秒以上。这像极了我们在介绍 HTTP 协议时所谈论的队头阻塞问题,那么我们也把这个问题称为消息队列的队头阻塞问题吧。
Chromium 是如何解决队头阻塞问题的?为了解决由于单消息队列而造成的队头阻塞问题,Chromium 团队从 2013 年到现在,花了大量的精力在持续重构底层消息机制。
在接下来的篇幅里,我会按照 Chromium 团队的重构消息系统的思路,来带你分析下他们是如何解决掉队头阻塞问题的。
1. 第一次迭代:引入一个高优先级队列
首先在最理想的情况下,我们希望能够快速跟踪高优先级任务,比如在交互阶段,下面几种任务都应该视为高优先级的任务:
- 通过鼠标触发的点击任务、滚动页面任务;
- 通过手势触发的页面缩放任务;
- 通过 CSS、JavaScript 等操作触发的动画特效等任务。
这些任务被触发后,用户想立即得到页面的反馈,所以我们需要让这些任务能够优先与其他的任务执行。要实现这种效果,我们可以增加一个高优级的消息队列,将高优先级的任务都添加到这个队列里面,然后优先执行该消息队列中的任务。最终效果如下图所示:
观察上图,我们使用了一个优先级高的消息队列和一个优先级低消息队列,渲染进程会将它认为是紧急的任务添加到高优先级队列中,不紧急的任务就添加到低优先级的队列中。
然后我们再在渲染进程中引入一个任务调度器,负责从多个消息队列中选出合适的任务,通常实现的逻辑,先按照顺序从高优先级队列中取出任务,如果高优先级的队列为空,那么再按照顺序从低优级队列中取出任务。我们还可以更进一步,将任务划分为多个不同的优先级,来实现更加细粒度的任务调度,比如可以划分为高优先级,普通优先级和低优先级,最终效果如下图所示:
观察上图,我们实现了三个不同优先级的消息队列,然后可以使用任务调度器来统一调度这三个不同消息队列中的任务。
好了,现在我们引入了多个消息队列,结合任务调度器我们就可以灵活地调度任务了,这样我们就可以让高优先级的任务提前执行,采用这种方式似乎解决了消息队列的队头阻塞问题。
不过大多数任务需要保持其相对执行顺序,如果将用户输入的消息或者合成消息添加进多个不同优先级的队列中,那么这种任务的相对执行顺序就会被打乱,甚至有可能出现还未处理输入事件,就合成了该事件要显示的图片。
因此我们需要让一些相同类型的任务保持其相对执行顺序。
2. 第二次迭代:根据消息类型来实现消息队列
要解决上述问题,我们可以为不同类型的任务创建不同优先级的消息队列,比如:
- 可以创建输入事件的消息队列,用来存放输入事件。
- 可以创建合成任务的消息队列,用来存放合成事件。
- 可以创建默认消息队列,用来保存如资源加载的事件和定时器回调等事件。
- 还可以创建一个空闲消息队列,用来存放 V8 的垃圾自动垃圾回收这一类实时性不高的事件。
通过迭代,这种策略已经相当实用了,但是它依然存在着问题,那就是这几种消息队列的优先级都是固定的,任务调度器会按照这种固定好的静态的优先级来分别调度任务。那么静态优先级会带来什么问题呢?
虽然在交互阶段,采用上述这种静态优先级的策略没有什么太大问题的,但是在页面加载阶段,如果依然要优先执行用户输入事件和合成事件,那么页面的解析速度将会被拖慢。Chromium 团队曾测试过这种情况,使用静态优先级策略,网页的加载速度会被拖慢 14%。
3. 第三次迭代:动态调度策略
可以看出,我们所采用的优化策略像个跷跷板,虽然优化了高优先级任务,却拖慢低优先级任务,之所以会这样,是因为我们采取了静态的任务调度策略,对于各种不同的场景,这种静态策略就显得过于死板。
所以我们还得根据实际场景来继续平衡这个跷跷板,也就是说在不同的场景下,根据实际情况,动态调整消息队列的优先级。一图胜过千言,我们先看下图:
上图列出了三个不同的场景,分别是加载过程,合成过程以及正常状态。下面我们就结合这三种场景,来分析下 Chromium 为何做这种调整。
首先我们来看看页面加载阶段的场景,在这个阶段,用户的最高诉求是在尽可能短的时间内看到页面,至于交互和合成并不是这个阶段的核心诉求,因此我们需要调整策略,在加载阶段将页面解析,JavaScript 脚本执行等任务调整为优先级最高的队列,降低交互合成这些队列的优先级。
页面加载完成之后就进入了交互阶段,在介绍 Chromium 是如何调整交互阶段的任务调度策略之前,我们还需要岔开一下,来回顾下页面的渲染过程。
在显卡中有一块叫着前缓冲区的地方,这里存放着显示器要显示的图像,显示器会按照一定的频率来读取这块前缓冲区,并将前缓冲区中的图像显示在显示器上,不同的显示器读取的频率是不同的,通常情况下是 60HZ,也就是说显示器会每间隔 1/60 秒就读取一次前缓冲区。
如果浏览器要更新显示的图片,那么浏览器会将新生成的图片提交到显卡的后缓冲区中,提交完成之后,GPU 会将后缓冲区和前缓冲区互换位置,也就是前缓冲区变成了后缓冲区,后缓冲区变成了前缓冲区,这就保证了显示器下次能读取到 GPU 中最新的图片。
显示器从前缓冲区读取图片,和浏览器生成新的图像到后缓冲区的过程是不同步的,如下图所示:
好了,我们花了很大篇幅介绍了 VSync 和页面中的一帧是怎么显示出来,有了这些知识,我们就可以回到主线了,来分析下渲染进程是如何优化交互阶段页面的任务调度策略的?
从上图可以看出,当渲染进程接收到用户交互的任务后,接下来大概率是要进行绘制合成操作,因此我们可以设置,当在执行用户交互的任务时,将合成任务的优先级调整到最高。
接下来,处理完成 DOM,计算好布局和绘制,就需要将信息提交给合成线程来合成最终图片了,然后合成线程进入工作状态。现在的场景是合成线程在工作了,那么我们就可以把下个合成任务的优先级调整为最低,并将页面解析、定时器等任务优先级提升。在合成完成之后,合成线程会提交给渲染主线程提交完成合成的消息,如果当前合成操作执行的非常快,比如从用户发出消息到完成合成操作只花了 8 毫秒,因为 VSync 同步周期是 16.66(1/60)毫秒,那么这个 VSync 时钟周期内就不需要再次生成新的页面了。那么从合成结束到下个 VSync 周期内,就进入了一个空闲时间阶段,那么就可以在这段空闲时间内执行一些不那么紧急的任务,比如 V8 的垃圾回收,或者通过 window.requestIdleCallback() 设置的回调任务等,都会在这段空闲时间内执行。
4. 第四次迭代:任务饿死
以上方案看上去似乎非常完美了,不过依然存在一个问题,那就是在某个状态下,一直有新的高优先级的任务加入到队列中,这样就会导致其他低优先级的任务得不到执行,这称为任务饿死。
Chromium 为了解决任务饿死的问题,给每个队列设置了执行权重,也就是如果连续执行了一定个数的高优先级的任务,那么中间会执行一次低优先级的任务,这样就缓解了任务饿死的情况。
一帧内需要完成如下六个步骤的任务:
- 处理用户的交互
- JS 解析执行
- 帧开始。窗口尺寸变更,页面滚去等的处理
- requestAnimationFrame(rAF)
- 布局
- 绘制
requestAnimationFrame
通常js动画是利用setTimeout和setInterval,问题是,它们都不精确。它们的内在运行机制决定了时间间隔参数实际上只是指定了把动画代码添加到浏览器UI线程队列中以等待执行的时间。如果队列前面已经加入了其他任务,那动画代码就要等前面的任务完成后再执行。
setTimeout 或 setInterval 是使用定时器来触发回调函数的,而定时器并无法保证能够准确无误的执行,有许多因素会影响它的运行时机,比如说:当有同步代码执行时,会先等同步代码执行完毕,异步队列中没有其他任务,才会轮到自己执行。
由于大多数电脑显示器的刷新频率是60Hz,大概相当于每秒钟重绘60次。因此,最平滑动画的最佳循环间隔是1000ms/60,约等于16.6ms。
requestAnimationFrame采用系统时间间隔,保持最佳绘制效率,不会因为间隔时间过短,造成过度绘制,增加开销;也不会因为间隔时间太长,使用动画卡顿不流畅,让各种网页动画效果能够有一个统一的刷新机制,从而节省系统资源,提高系统性能,改善视觉效果
requestAnimationFrame的运行机制:(在每次渲染前执行,每一帧都会执行)
1,合并每一帧的dom操作,在一次重绘和回流中更新;
2,不操作隐藏元素
3,页面未激活状态下,动画暂停
语法:
requestID = requestAnimationFrame(callback);
cancelAnimationFrame方法用于取消定时器
if(!window.requestAnimationFrame){
var lastTime = 0;
window.requestAnimationFrame = function(callback){
var currTime = new Date().getTime();
var timeToCall = Math.max(0,16.7-(currTime - lastTime));
var id = window.setTimeout(function(){
callback(currTime + timeToCall);
},timeToCall);
lastTime = currTime + timeToCall;
return id;
}
}
requestIdCallback
requestIdCallback能够在主事件循环上执行后台和低优先级任务,而不会影响延迟关键事件,如动画输入响应
和requestAnimationFrame不同,在每一帧有空余时间时(正常帧任务没有超过16ms),执行requestIdCallback里面注册的任务
怎么确保requestIdCallback执行
假如浏览器一直处于非常忙碌的状态,requestIdleCallback 注册的任务有可能永远不会执行。
此时可通过设置 timeout来保证执行。
使用:
let handle = window.requestIdleCallback(callback[, options])
callback:回调,即空闲时需要执行的任务,该回调函数接收一个IdleDeadline对象作为入参。其中IdleDeadline对象包含:
didTimeout,布尔值,表示任务是否超时,结合 timeRemaining 使用。
timeRemaining(),表示当前帧剩余的时间,也可理解为留给任务的时间还有多少。
options:目前 options 只有一个参数
timeout。表示超过这个时间后,如果任务还没执行,则强制执行,不必等待空闲。
实例:
requestIdleCallback(myNonEssentialWork, { timeout: 2000 });
// 任务队列
const tasks = [
() => {
console.log(“第一个任务”);
},
() => {
console.log(“第二个任务”);
},
() => {
console.log(“第三个任务”);
},
];
function myNonEssentialWork (deadline) {
// 如果帧内有富余的时间,或者超时
while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && tasks.length > 0) {
work();
}
if (tasks.length > 0)
requestIdleCallback(myNonEssentialWork);
}
function work () {
tasks.shift()();
console.log(‘执行任务’);
}
cancelIdleCallback
与 setTimeout 类似,返回一个唯一 id,可通过 cancelIdleCallback 来取消任务。
二者的区别:
requestAnimationFrame属于高级任务,每一帧都会执行,
requestIdCallback属于低级任务,每一帧不一定执行
说明:
因为requestIdCallback发生在一帧的最后,此时页面布局已经完成,所以不建议在 requestIdleCallback 里再操作 DOM,这样会导致页面再次重绘。DOM 操作建议在 rAF 中进行。同时,操作 DOM 所需要的耗时是不确定的,因为会导致重新计算布局和视图的绘制,所以这类操作不具备可预测性。
Promise 也不建议在这里面进行,因为 Promise 的回调属性 Event loop 中优先级较高的一种微任务,会在 requestIdleCallback 结束时立即执行,不管此时是否还有富余的时间,这样有很大可能会让一帧超过 16 ms。