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

异步的思考

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

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

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

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

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

不少框架和库都会使用相似下面函数:node

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的变化的吗,这样凭空造出一个节点来反复修改它的内容,来触发观察的回调函数有何意义?git

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

定义

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

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

event loop

event loop翻译出来就是事件循环,能够理解为实现异步的一种方式,咱们来看看event loop在HTML Standard中的定义章节:chrome

第一句话:

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

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

task

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

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

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

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

哪些是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

microtask

每个event loop都有一个microtask队列,一个microtask会被排进microtask队列而不是task队列。

有两种microtasks:分别是solitary callback microtasks和compound microtasks。规范值只覆盖solitary callback microtasks。

若是在初期执行时,spin the event loop,microtasks有可能被移动到常规的task队列,在这种状况下,microtasks任务源会被task任务源所用。一般状况,task任务源和microtasks是不相关的。

microtask 队列和task 队列有些类似,都是先进先出的队列,由指定的任务源去提供任务,不一样的是一个
event loop里只有一个microtask 队列。

HTML Standard没有具体指明哪些是microtask任务源,一般认为是microtask任务源有:

  • process.nextTick
  • promises
  • Object.observe
  • MutationObserver

NOTE:
Promise的定义在 ECMAScript规范而不是在HTML规范中,可是ECMAScript规范中有一个jobs的概念和microtasks很类似。在Promises/A+规范的Notes 3.1中说起了promise的then方法能够采用“宏任务(macro-task)”机制或者“微任务(micro-task)”机制来实现。因此开头说起的promise在不一样浏览器的差别正源于此,有的浏览器将then放入了macro-task队列,有的放入了micro-task 队列。在jake的博文Tasks, microtasks, queues and schedules中说起了一个讨论vague mailing list discussions,一个广泛的共识是promises属于microtasks队列。

进一步了解event loops

知道了event loops大体作什么的,咱们再深刻了解下event loops

有两种event loops,一种在浏览器上下文,一种在workers中。

每个用户代理必须至少有一个浏览器上下文event loop,可是每一个单元的类似源浏览器上下文至多有一个event loop。

event loop 老是具备至少一个浏览器上下文,当一个event loop的浏览器上下文全都销毁的时候,event loop也会销毁。一个浏览器上下文总有一个event loop去协调它的活动。

Worker的event loop相对简单一些,一个worker对应一个event loop,worker进程模型管理event loop的生命周期。

反复提到的一个词是browsing contexts(浏览器上下文)。

浏览器上下文是一个将 Document 对象呈现给用户的环境。在一个 Web 浏览器内,一个标签页或窗口常包含一个浏览上下文,如一个 iframe 或一个 frameset 内的若干 frame。

结合一些资料,对上边规范给出一些理解(有误请指正):

  • 每一个线程都有本身的event loop
  • 浏览器能够有多个event loopbrowsing contextsweb workers就是相互独立的。
  • 全部同源的browsing contexts能够共用event loop,这样它们之间就能够相互通讯。

event loop的处理过程(Processing model)

在规范的Processing model定义了event loop的循环过程:

一个event loop只要存在,就会不断执行下边的步骤:
1.在tasks队列中选择最老的一个task,用户代理能够选择任何task队列,若是没有可选的任务,则跳到下边的microtasks步骤。
2.将上边选择的task设置为正在运行的task
3.Run: 运行被选择的task。
4.将event loop的currently running task变为null。
5.从task队列里移除前边运行的task。
6.Microtasks: 执行microtasks任务检查点。(也就是执行microtasks队列里的任务)
7.更新渲染(Update the rendering)...
8.若是这是一个worker event loop,可是没有任务在task队列中,而且WorkerGlobalScope对象的closing标识为true,则销毁event loop,停止这些步骤,而后进行定义在Web workers章节的run a worker
9.返回到第一步。

event loop会不断循环上面的步骤,归纳说来:

  • event loop会不断循环的去取tasks队列的中最老的一个任务推入栈中执行,并在当次循环里依次执行并清空microtask队列里的任务。
  • 执行完microtask队列里的任务,有可能会渲染更新。(浏览器很聪明,在一帧之内的屡次dom变更浏览器不会当即响应,而是会积攒变更以最高60HZ的频率更新视图)

microtasks检查点(microtask checkpoint)

event loop运行的第6步,执行了一个microtask checkpoint,看看规范如何描述microtask checkpoint

当用户代理去执行一个microtask checkpoint,若是microtask checkpoint的flag(标识)为false,用户代理必须运行下面的步骤:
1.将microtask checkpoint的flag设为true。
2.Microtask queue handling: 若是event loop的microtask队列为空,直接跳到第八步(Done)。
3.在microtask队列中选择最老的一个任务。
4.将上一步选择的任务设为event loop的currently running task
5.运行选择的任务。
6.将event loop的currently running task变为null。
7.将前面运行的microtask从microtask队列中删除,而后返回到第二步(Microtask queue handling)。
8.Done: 每个environment settings object它们的 responsible event loop就是当前的event loop,会给environment settings object发一个 rejected promises 的通知。
9.清理IndexedDB的事务
10.将microtask checkpoint的flag设为flase。

microtask checkpoint所作的就是执行microtask队列里的任务。何时会调用microtask checkpoint呢?

执行栈(JavaScript execution context stack)

task和microtask都是推入栈中执行的,要完整了解event loops还须要认识JavaScript execution context stack,它的规范位于https://tc39.github.io/ecma262/#execution-context-stack。

javaScript是单线程,也就是说只有一个主线程,主线程有一个栈,每个函数执行的时候,都会生成新的execution context(执行上下文),执行上下文会包含一些当前函数的参数、局部变量之类的信息,它会被推入栈中, running execution context(正在执行的上下文)始终处于栈的顶部。当函数执行完后,它的执行上下文会从栈弹出。

执行调用栈

举个简单的例子:

function bar() { console.log('bar'); } function foo() { console.log('foo'); bar(); } foo();

执行过程当中栈的变化:
执行栈示意图

完整异步过程

规范晦涩难懂,作一个形象的比喻:
主线程相似一个加工厂,它只有一条流水线,待执行的任务就是流水线上的原料,只有前一个加工完,后一个才能进行。event loops就是把原料放上流水线的工人。只要已经放在流水线上的,它们会被依次处理,称为同步任务。一些待处理的原料,工人会按照它们的种类排序,在适当的时机放上流水线,这些称为异步任务。

过程图:

eventLoop示意图

举个简单的例子,假设一个script标签的代码以下:

Promise.resolve().then(function promise1 () { console.log('promise1'); }) setTimeout(function setTimeout1 (){ console.log('setTimeout1') Promise.resolve().then(function promise2 () { console.log('promise2'); }) }, 0) setTimeout(function setTimeout2 (){ console.log('setTimeout2') }, 0) 

运行过程:

script里的代码被列为一个task,放入task队列。

循环1:

  • 【task队列:script ;microtask队列:】
  1. 从task队列中取出script任务,推入栈中执行。
  2. promise1列为microtask,setTimeout1列为task,setTimeout2列为task。
  • 【task队列:setTimeout1 setTimeout2;microtask队列:promise1】
  1. script任务执行完毕,执行microtask checkpoint,取出microtask队列的promise1执行。

循环2:

  • 【task队列:setTimeout1 setTimeout2;microtask队列:】
  1. 从task队列中取出setTimeout1,推入栈中执行,将promise2列为microtask。
  • 【task队列:setTimeout2;microtask队列:promise2】
  1. 执行microtask checkpoint,取出microtask队列的promise2执行。

循环3:

  • 【task队列:setTimeout2;microtask队列:】
  1. 从task队列中取出setTimeout2,推入栈中执行。
  2. setTimeout2任务执行完毕,执行microtask checkpoint。
  • 【task队列:;microtask队列:】

event loop中的Update the rendering(更新渲染)

这是event loop中很重要部分,在第7步会进行Update the rendering(更新渲染),规范容许浏览器本身选择是否更新视图。也就是说可能不是每轮事件循环都去更新视图,只在有必要的时候才更新视图。

https://www.html5rocks.com/zh/tutorials/internals/howbrowserswork 这篇文章较详细的讲解了渲染机制。

渲染的基本流程:
渲染的基本流程

  1. 处理 HTML 标记并构建 DOM 树。
  2. 处理 CSS 标记并构建 CSSOM 树, 将 DOM 与 CSSOM 合并成一个渲染树。
  3. 根据渲染树来布局,以计算每一个节点的几何信息。
  4. 将各个节点绘制到屏幕上。

Note: 能够看到渲染树的一个重要组成部分是CSSOM树,绘制会等待css样式所有加载完成才进行,因此css样式加载的快慢是首屏呈现快慢的关键点。

下面讨论一下渲染的时机。规范定义在一次循环中,Update the rendering会在第六步Microtasks: Perform a microtask checkpoint 后运行。

验证更新渲染(Update the rendering)的时机

不一样机子测试可能会获得不一样的结果,这取决于浏览器,cpu、gpu性能以及它们当时的状态。

例子1

咱们作一个简单的测试

<div id='con'>this is con</div> <script> var t = 0; var con = document.getElementById('con'); con.onclick = function () {  setTimeout(function setTimeout1 () {  con.textContent = t;  }, 0) }; </script>

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

绿色和紫色部分能够认为是Update the rendering。

例子1

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

例子2

如今换成一个microtask任务,看看有什么变化

<div id='con'>this is con</div> <script> var con = document.getElementById('con'); con.onclick = function () {  Promise.resolve().then(function Promise1 () {  con.textContext = 0;  }) }; </script>

例子2

和上一个例子很像,不一样的是这一轮事件循环的task是click的回调函数,Promise1则是microtask,paint一样是在他们以后完成。

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

例子3

<div id='con'>this is con</div> <script> var con = document.getElementById('con'); con.onclick = function click1() {  setTimeout(function setTimeout1() {  con.textContent = 0;  }, 0)  setTimeout(function setTimeout2() {  con.textContent = 1;  }, 0) }; </script>

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

例子3

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

例子4

在两个setTimeout中增长microtask。

<div id='con'>this is con</div> <script> var con = document.getElementById('con'); con.onclick = function () {  setTimeout(function setTimeout1() {  con.textContent = 0;  Promise.resolve().then(function Promise1 () {  console.log('Promise1')  })  }, 0)  setTimeout(function setTimeout2() {  con.textContent = 1;  Promise.resolve().then(function Promise2 () {  console.log('Promise2')  })  }, 0) }; </script>

例子4

从run microtasks中能够看出来,setTimeout一、setTimeout2应该运行在两次event loop中,textContent = 0的修改被跳过了。

setTimeout一、setTimeout2的运行间隔很短,在setTimeout1完成以后,setTimeout2立刻就开始执行了,咱们知道浏览器会尽可能保持每秒60帧的刷新频率(大约16.7ms每帧),是否是只有两次event loop间隔大于16.7ms才会进行绘制呢?

例子5

将时间间隔加大一些。

<div id='con'>this is con</div> <script> var con = document.getElementById('con'); con.onclick = function () {  setTimeout(function setTimeout1() {  con.textContent = 0;  }, 0);  setTimeout(function setTimeout2() {  con.textContent = 1;  }, 16.7); }; </script>

例子5

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

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

 

例子6

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

<div id='con'>this is con</div> <script> var con = document.getElementById('con'); con.onclick = function () {  setTimeout(function(){  con.textContent = 0;  },0)  setTimeout(function(){  con.textContent = 1;  },0)  setTimeout(function(){  con.textContent = 2;  },0)  setTimeout(function(){  con.textContent = 3;  },0)  setTimeout(function(){  con.textContent = 4;  },0)  setTimeout(function(){  con.textContent = 5;  },0)  setTimeout(function(){  con.textContent = 6;  },0) }; </script>

例子6

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

例子7

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

<div id='con'>this is con</div> <script> var con = document.getElementById('con'); con.onclick = function () {  setTimeout(function setTimeout1() {  con.textContent = 'task1';  for(var i = 0; i < 250000; i++){  Promise.resolve().then(function(){  con.textContent = i;  });  }  }, 0);  setTimeout(function setTimeout2() {  con.textContent = 'task2';  }, 0); }; </script>

整体的timeline:
例子7-1

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

看看setTimeout2的运行状况。
例子7-2
能够看到setTimeout2这轮event loop没有run microtasks,microtasks在setTimeout1被所有执行完了。

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

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

例子8

使用requestAnimationFrame。

<div id='con'>this is con</div> <script> var con = document.getElementById('con'); var i = 0; var raf = function(){  requestAnimationFrame(function() {  con.textContent = i;  Promise.resolve().then(function(){  i++;  if(i < 3) raf();  });  }); } con.onclick = function () {  raf(); }; </script>

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

看看单个 requestAnimationFrame的Timeline:
例子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中的tasks和microtasks完成后进行,但并非每轮event loop都会更新渲染,这取决因而否修改了dom和浏览器以为是否有必要在此时当即将新状态呈现给用户。若是在一帧的时间内(时间并不肯定,由于浏览器每秒的帧数总在波动,16.7ms只是估算并不许确)修改了多处dom,浏览器可能将变更积攒起来,只进行一次绘制,这是合理的。
  • 若是但愿在每轮event loop都即时呈现变更,可使用requestAnimationFrame。

应用

event loop的大体循环过程,能够用下边的图表示:

event loop 过程

假设如今执行到currently running task,咱们对批量的dom进行异步修改,咱们将此任务插进task:
event loop 插入dom修改2

此任务插进microtasks:
event loop 插入dom修改3

能够看到若是task队列若是有大量的任务等待执行时,将dom的变更做为microtasks而不是task能更快的将变化呈现给用户。

同步简简单单就能够完成了,为啥要异步去作这些事?

对于一些简单的场景,同步彻底能够胜任,若是得对dom反复修改或者进行大量计算时,使用异步能够做为缓冲,优化性能。

举个小例子:

如今有一个简单的元素,用它展现咱们的计算结果:

<div id='result'>this is result</div>

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

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,能够帮助咱们异步的插入task和microtask。

例如:

//生成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。

结语

event loop涉及到的东西不少,本文有误的地方请指正。

相关文章
相关标签/搜索