从event loop规范探究javaScript异步及浏览器更新渲染时机

异步的思考

event loops隐藏得比较深,很多人对它很陌生。但提起异步,相信每个人都知道。异步背后的“靠山”就是event loops。这里的异步准确的说应该叫浏览器的event loops或者说是javaScript运行环境的event loops,因为ECMAScript中没有event loopsevent loops是在HTML Standard定义的。

event loops规范中定义了浏览器何时进行渲染更新,了解它有助于性能优化。

思考下边的代码运行顺序:

console.log('script start');
​
setTimeout(function () {
 console.log('setTimeout');
}, 0);
​
Promise.resolve()
 .then(function () {
 console.log('promise1');
 })
 .then(function () {
 console.log('promise2');
 });
​
console.log('script end');

上面的顺序是在chrome运行得出的,有趣的是在safari 9.1.2中测试,promise1 promise2会在setTimeout的后边,而在safari 10.0.1中得到了和chrome一样的结果。为何浏览器有不同的表现,了解tasks, microtasks队列就可以解答这个问题。

很多框架和库都会使用类似下面函数:

function flush() {
...
}
function useMutationObserver() {
 var iterations = 0;
 var observer = new MutationObserver(flush);
 var node = document.createTextNode('');
 observer.observe(node, { characterData: true });
​
 return function () {
 node.data = iterations = ++iterations % 2;
 };
}

初次看这个useMutationObserver函数总会很有疑惑,MutationObserver不是用来观察dom的变化的吗,这样凭空造出一个节点来反复修改它的内容,来触发观察的回调函数有何意义?

答案就是使用Mutation事件可以异步执行操作(例子中的flush函数),一是可以尽快响应变化,二是可以去除重复的计算。但是setTimeout(flush, 0)同样也可以执行异步操作,要知道其中的差异和选择哪种异步方法,就得了解event loop

定义

先看看它们在规范中的定义。

Note:本文的引用部分,就是对规范的翻译,有的部分会概括或者省略的翻译,有误请指正。

事件循环

event loop翻译出来就是事件循环,可以理解为实现异步的一种方式,我们来看看event loopHTML Standard中的定义章节:

第一句话:

为了协调事件,用户交互,脚本,渲染,网络等,用户代理必须使用本节所述的 event loop

事件,用户交互,脚本,渲染,网络这些都是我们所熟悉的东西,他们都是由event loop协调的。触发一个click事件,进行一次ajax请求,背后都有event loop在运作。

Task queues are sets , not queues , because step one of the event loop processing model grabs the first runnable task from the chosen queue, instead of dequeuing the first task.

任务队列是集合,而不是队列,因为事件循环处理模型的第一步从选定的队列中获取第一个可运行任务,而不是使第一个任务出队。

task

一个event loop有一个或者多个task队列。

当用户代理安排一个任务,必须将该任务增加到相应的event loop的一个tsak队列中。

每一个task都来源于指定的任务源,比如可以为鼠标、键盘事件提供一个task队列,其他事件又是一个单独的队列。可以为鼠标、键盘事件分配更多的时间,保证交互的流畅。

task也被称为macrotasktask队列还是比较好理解的,就是一个先进先出的队列,由指定的任务源去提供任务。

哪些是task任务源呢?

规范在Generic task sources中有提及:

DOM操作任务源: 此任务源被用来相应dom操作,例如一个元素以非阻塞的方式 插入文档

用户交互任务源: 此任务源用于对用户交互作出反应,例如键盘或鼠标输入。响应用户操作的事件(例如click)必须使用task队列。

网络任务源: 网络任务源被用来响应网络活动。

history traversal任务源: 当调用history.back()等类似的api时,将任务插进task队列。

task任务源非常宽泛,比如ajaxonloadclick事件,基本上我们经常绑定的各种事件都是task任务源,还有数据库操作(IndexedDB ),需要注意的是setTimeoutsetIntervalsetImmediate也是task任务源。总结来说task任务源:

  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • UI rendering

HTML parser是一个典型的task

chromeDeveloper toolsTimeline查看各部分运行的时间点。 当我们点击这个div的时候,下图截取了部分时间线,黄色部分是脚本运行,紫色部分是更新render树、计算布局,绿色部分是绘制。

绿色和紫色部分可以认为是Update the rendering

例子1

在这一轮事件循环中,setTimeout1是作为task运行的,可以看到paint确实是在task运行完后才进行的。

例子2

现在换成一个microtask任务,看看有什么变化

this is con

例子2

和上一个例子很像,不同的是这一轮事件循环的taskclick的回调函数,Promise1则是microtaskpaint同样是在他们之后完成。

标准就是那么定义的,答案似乎显而易见,我们把例子变得稍微复杂一些。

例子3

this is con

当点击后,一共产生3个task,分别是click1、setTimeout1、setTimeout2,所以会分别在3次event loop中进行。 下面截取的是setTimeout1、setTimeout2的部分。

例子3

我们修改了两次textContent,奇怪的是setTimeout1、setTimeout2之间没有paint,浏览器只绘制了textContent=1,难道setTimeout1、setTimeout2在同一次event loop中吗?

例子4

在两个setTimeout中增加microtask

this is con

例子4

run microtasks中可以看出来,setTimeout1、setTimeout2应该运行在两次event loop中,textContent = 0的修改被跳过了。

setTimeout1、setTimeout2的运行间隔很短,在setTimeout1完成之后,setTimeout2马上就开始执行了,我们知道浏览器会尽量保持每秒60帧的刷新频率(大约16.7ms每帧),是不是只有两次event loop间隔大于16.7ms才会进行绘制呢?

例子5

将时间间隔加大一些。

this is con

例子5

两块黄色的区域就是 setTimeout,在1224ms处绿色部分,浏览器对con.textContent = 0的变动进行了绘制。在1234ms处绿色部分,绘制了con.textContent = 1

可否认为相邻的两次event loop的间隔很短,浏览器就不会去更新渲染了呢?继续我们的实验

例子6

我们在同一时间执行多个setTimeout来模拟执行间隔很短的task。

this is con

例子6

图中一共绘制了两帧,第一帧4.4ms,第二帧9.3ms,都远远高于每秒60HZ(16.7ms)的频率,第一帧绘制的是con.textContent = 4,第二帧绘制的是 con.textContent = 6。所以两次event loop的间隔很短同样会进行绘制。

例子7

有说法是一轮event loop执行的microtask有数量限制(可能是1000),多余的microtask会放到下一轮执行。下面例子将microtask的数量增加到25000。

this is con

总体的timeline例子7-1

可以看到一大块黄色区域,上半部分有一根绿线就是点击后的第一次绘制,脚本的运行耗费大量的时间,并且阻塞了渲染。

看看setTimeout2的运行情况。 [](https://github.com/aooy/aooy....setTimeout2这轮event loop没有run microtasks,microtaskssetTimeout1被全部执行完了。

25000个microtasks不能说明event loopmicrotasks数量没有限制,有可能这个限制数很高,远超25000,但日常使用基本不会使用那么多了。

对microtasks增加数量限制,一个很大的作用是防止脚本运行时间过长,阻塞渲染。

例子8

使用requestAnimationFrame

this is con

总体的Timeline: 例子8-1 点击后绘制了3帧,把每次变动都绘制了。

看看单个requestAnimationFrameTimeline例子8-2

setTimeout很相似,可以看出requestAnimationFrame也是一个task,在它完成之后会运行run microtasks

例子9

验证postMessage是否是task

setTimeout(function setTimeout1(){
        console.log('setTimeout1')
}, 0)
var channel = new MessageChannel();
channel.port1.onmessage = function onmessage1 (){
    console.log('postMessage')
    Promise.resolve().then(function promise1 (){
        console.log('promise1')
    })
};
channel.port2.postMessage(0);
setTimeout(function setTimeout2(){
        console.log('setTimeout2')
}, 0)
console.log('sync')

执行顺序:

sync
postMessage
promise1
setTimeout1
setTimeout2

timelime:

例子9

第一个黄块是onmessage1,第二个是setTimeout1,第三个是setTimeout2。显而易见,postMessage属于task,因为setTimeout的4ms标准化了,所以这里的postMessage会优先setTimeout运行。

小结

上边的例子可以得出一些结论:

  • 在一轮event loop中多次修改同一dom,只有最后一次会进行绘制。
  • 渲染更新(Update the rendering)会在event loop中的tasksmicrotasks完成后进行,但并不是每轮event loop都会更新渲染,这取决于是否修改了dom和浏览器觉得是否有必要在此时立即将新状态呈现给用户。如果在一帧的时间内(时间并不确定,因为浏览器每秒的帧数总在波动,16.7ms只是估算并不准确)修改了多处dom,浏览器可能将变动积攒起来,只进行一次绘制,这是合理的。
  • 如果希望在每轮event loop都即时呈现变动,可以使用requestAnimationFrame

应用

event loop的大致循环过程,可以用下边的图表示:

event loop 过程

假设现在执行到currently running task,我们对批量的dom进行异步修改,我们将此任务插进task: 从event loop规范探究javaScript异步及浏览器更新渲染时机_第1张图片

此任务插进microtasks从event loop规范探究javaScript异步及浏览器更新渲染时机_第2张图片

可以看到如果task队列如果有大量的任务等待执行时,将dom的变动作为microtasks而不是task能更快的将变化呈现给用户。

同步简简单单就可以完成了,为啥要异步去做这些事?

对于一些简单的场景,同步完全可以胜任,如果得对dom反复修改或者进行大量计算时,使用异步可以作为缓冲,优化性能。

举个小例子:

现在有一个简单的元素,用它展示我们的计算结果:

this is result

有一个计算平方的函数,并且会将结果响应到对应的元素

function bar (num, id) {
  var  product  = num  * num;
  var resultEle = document.getElementById( id );
  resultEle.textContent = product;
}

现在我们制造些问题,假设现在很多同步函数引用了bar,在一轮event loop里,可能bar会被调用多次,并且其中有几个是对id='result'的元素进行操作。就像下边一样:

...
bar( 2, 'result' )
...
bar( 4, 'result' )
...
bar( 5, 'result' )
...

似乎这样的问题也不大,但是当计算变得复杂,操作很多dom的时候,这个问题就不容忽视了。

用我们上边讲的event loop知识,修改一下bar

var store = {}, flag = false;
function bar (num, id) {
  store[ id ] = num;
  if(!flag){
    Promise.resolve().then(function () {
       for( var k in store ){
           var num = store[k];
            var product  = num  * num;
            var resultEle = document.getElementById( k );
            resultEle.textContent = product;
       }
    });
    flag = true;
  }
}

现在我们用一个store去存储参数,统一在microtasks阶段执行,过滤了多余的计算,即使同步过程中多次对一个元素修改,也只会响应最后一次。

写了个简单插件asyncHelper,可以帮助我们异步的插入taskmicrotask

例如:

//生成task
var myTask = asyncHelper.task(function () {
    console.log('this is task')
});
//生成microtask
var myMicrotask = asyncHelper.mtask(function () {
    console.log('this is microtask')
});

//插入task
myTask()
//插入microtask
myMicrotask();

对之前的例子的使用asyncHelper

var store = {};
//生成一个microtask
var foo = asyncHelper.mtask(function () {
        for( var k in store ){
            var num = store[k];
            var product  = num  * num;
            var resultEle = document.getElementById( k );
            resultEle.textContent = product;
       }
}, {callMode: 'last'});

function bar (num, id) {
  store[ id ] = num;
  foo();
}

如果不支持microtask将回退成task

参考

https://github.com/aooy/blog/issues/5

https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/

https://html.spec.whatwg.org/multipage/webappapis.html#event-loop

你可能感兴趣的