Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
aooy opened this issue May 2, 2017 · 67 comments
Open

Comments

@aooy
Copy link
Owner

aooy commented May 2, 2017

作者:杨敬卓

转载请注明出处

异步的思考

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

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

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

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队列就可以解答这个问题。

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

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

第一句话:

为了协调事件,用户交互,脚本,渲染,网络等,用户代理必须使用本节所述的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,分别是click1、setTimeout1、setTimeout2,所以会分别在3次event loop中进行。
下面截取的是setTimeout1、setTimeout2的部分。

例子3

我们修改了两次textContent,奇怪的是setTimeout1、setTimeout2之间没有paint,浏览器只绘制了textContent=1,难道setTimeout1、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中可以看出来,setTimeout1、setTimeout2应该运行在两次event loop中,textContent = 0的修改被跳过了。

setTimeout1、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涉及到的东西很多,本文有误的地方请指正。

references

@aooy aooy added the javascript label May 2, 2017
@FTAndy
Copy link

FTAndy commented May 3, 2017

microtask 是个好东西

@ayou33
Copy link

ayou33 commented May 4, 2017

用户代理可以理解为浏览器实现吗

@aooy
Copy link
Owner Author

aooy commented May 4, 2017

@hubu 我感觉可以这么理解

@PerkinJ
Copy link

PerkinJ commented May 4, 2017

感觉理解浏览器运行机制又深刻了一些,谢谢你的好文!

@webkonglong
Copy link

运行microtask的条件是 上下文执行盏为空的时候,也就是在运行task之后,更新渲染之前。

settimeout属于task,promise属于microtask 照这样 settimeout 0秒要早promise执行了,我整体这么理解下来 有些懵,求解答 不知道可否加个微信或者qq,等你闲了 我请教你

@aooy
Copy link
Owner Author

aooy commented May 9, 2017

@webkonglong 文章涉及到规范,所以有些生涩,建议可以先看一些通熟易懂的介绍task和microtask的文章。
我在文中有一个实例,列出了每次循环task队列和microtask的变化,你所说的这个问题要弄清楚,首先得理解这轮event loop的task是哪个任务,其次setTimeout它只是一个task任务源,并不会立即执行,它只是将一个setTimeout任务插进task队列,得排到它,它里面的函数才会执行。Promise.then是microtask任务源,会将任务插进microtask队列。

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

以上面的例子来说,这两个api仅仅是将相应的任务插进他们各自的队列中,此次event loop执行的task并不是setTimeout里的任务,setTimeout的任务排在后边了,还没轮到它。microtask队列的任务是会在当轮清空的,所以会看到promise1先于setTimeout1执行。

@zhanba
Copy link

zhanba commented Jul 18, 2017

好文!想问一下浏览器为何要区分task和microtask呢?

@aooy
Copy link
Owner Author

aooy commented Jul 23, 2017

@zhanba 感觉选择eventloop这个异步模型,自然就得分出两种task才合理,一种是在当轮eventloop执行的,一种是往后某轮才执行的。如果没有microtask就没法在当轮eventloop里添加异步操作了,有点像人的左右手吧,少了一个就不完整了。

@MeCKodo
Copy link

MeCKodo commented Sep 14, 2017

讲的太好了。。看过讲event loop最舒服的文章
尤其是用了大量的例子去验证浏览器渲染,一直想看这部分,今天终于如愿以偿
感谢作者

@rylan0119
Copy link

请问将任务放到队列的操作是在哪里完成的?

@dreamdevil00
Copy link

疑问 or Bug

关于 文中 一句 script里的代码被列为一个task,放入task队列。
不知道这句是根据什么推测来的, 难道 script 也是一个 task 源?

说下个人的理解,不当之处请指正

HTML-Standard 中 8.1.3.4 一节 (calling-scripts)[https://html.spec.whatwg.org/#calling-scripts]
根据第九步 clean up after running script
当执行栈为空时, perform a microtask checkpoint,

我认为步骤应该是这样:
tick1:
1、 queue a microtask promise1
task queue [], microtask queue [promise1]
2、 queue a task setTimeout1
task queue [setTimeout1], microtask queue [promise1]
3、 queue a task setTimeout2
task queue [setTimeout1, setTimeout2], microtask queue [promise1]

此刻 execution context stack 为空 则
perform a microtask checkpoint
执行所有 microtask queue 里的microtask , 也就是 执行 microtask promise1, 执行完毕后 queue 是这样的
task queue [setTimeout1, setTimeout2], microtask queue []

tick2:
执行 task queue 中 oldest task, 也就是 setTimeout1, 同时 queue a microtask promise2, 执行完毕后
task queue [setTimeout2], microtask queue [promise2]
接着 perform a microtask checkpoint, 执行 microtask queue 中的所有microtask , 此时 也就是要执行 promise2 , 执行完毕后
task queue [setTimeout2], microtask queue []

tick3:
执行 task queue 中 oldest task, 也就是 setTimeout2
接着perform a microtask checkpoint 此时 microtask queue 为空。 执行完毕后

task queue [] microtask queue []

@jimczj
Copy link

jimczj commented Nov 16, 2017

很棒的文章。纠正一点

我们都知道javaScript是单线程,渲染计算和脚本运行共用同一线程(网络请求会有其他线程),导致脚本运行会阻塞渲染。

渲染计算应该是浏览器GUI渲染线程负责,是由浏览器用c++编写的模块负责的。GUI渲染线程与JS引擎是互斥的,当JS引擎执行时GUI线程会被挂起,GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。

@aooy
Copy link
Owner Author

aooy commented Nov 22, 2017

@jimczj 学习了,谢谢指正。

@aooy
Copy link
Owner Author

aooy commented Nov 22, 2017

@dreamdevil00 个人理解,<script>标签的解析和执行是在parse HTML这个阶段,是这个阶段的一部分,也就是最初构建DOM trees的时候。
在规范的8.1.4 Event loops中的8.1.4.1 Definitions里有这样的话:

An event loop has one or more task queues. A task queue is an ordered list of tasks, which are algorithms that are responsible for such work as:

Parsing
The HTML parser tokenizing one or more bytes, and then processing any resulting tokens, is typically a task.

Callbacks
Calling a callback is often done by a dedicated task.

提到了 HTML parser是一个典型的task,当我们解析和执行完<script>标签里的内容,后续执行的回调函数才会分到指定的task中执行。

可以做个测试,在文档中间插入一个执行的脚本,使用Developer tools里的performance(原timeline)看到结果是parser HTML -> 执行脚本 -> parser HTML ..... , 执行脚本是会中断文档解析的,因为脚本可能会修改dom trees,所以最初的文档解析和脚本执行应该是一个连续的过程,所以<script>标签的代码也是一个task。

@dreamdevil00
Copy link

@aooy 恩恩 有道理 这里有个问题想请教下 call stack 和 execution context stack 是一回事么
在网上看视频 Philip Roberts- Help I'm stuck in an event-loop.-Mobile

他是拿 call stack 来讲的 event loop
这个call stack 和 execution context stack 感觉有相似之处啊 是一回事么

@fulvaz
Copy link

fulvaz commented Dec 6, 2017

@dreamdevil00
Copy link

@fulvaz 我看过啊 这个我懂了。 我的问题是 call stack 和 execution context stack 的区别啊

@Aeolos1994
Copy link

很棒的文章 我最近也在写这个原理 学习了很多之前比较模糊的点

@sponia-joker
Copy link

sponia-joker commented Feb 4, 2018

请教博主一个问题,烦请指教!当我反复查看html规范时候

An event loop has one or more task queues. A task queue is an ordered list of tasks, which are algorithms that are responsible for such work as:

  1. Events
    Dispatching an Event object at a particular EventTarget object is often done by a dedicated task.
    2.Parsing
    The HTML parser tokenizing one or more bytes, and then processing any resulting tokens, is typically a task.
    ....

规范里面说到event loop有多个task queues.一个task queue就是任务的列表,它们负责以下工作。
看里很久有两个疑惑,恳请解答
1.dispatching an event 不是浏览器有专门的线程监听各种事件吗?然后相应的event handler 会进入task queues.为什么规范里说:是任务队列Dispatching an Event。因为任务队列里的任务都会被JavaScript engines执行,也就是说是JavaScript engines在触发事件?
2.html解析不是通过渲染引擎来解析的吗,怎么这里又说是JavaScript engines解析?在一次event loop中,如果页面需要更新。这时候会停止JavaScript引擎,启动渲染引擎来更新页面吗?
谢谢了
@aooy

@yilikun
Copy link

yilikun commented Apr 8, 2018

学习了 十分感谢

@WinnieFE
Copy link

十分感谢分享!有个疑问想请教下,应用一节提到 “可以看到如果task队列如果有大量的任务等待执行时,将dom的变动作为microtasks而不是task能更快的将变化呈现给用户。” 这里是将dom的变动操作放在microtasks中执行,那么如果要获得最新的DOM是在哪步执行呢? Vuejs的nextTick回调函数里说是可以获得最新DOM,可是UI rendering是在microtasks之后执行,那么是怎么获取到最新DOM的呢? 这点实在困惑

@AsceticBoy
Copy link

@WinnieFE 当时我和你有一样的疑惑,写点我现在的理解!也欢迎一起来讨论

首先明确 Task -> MicroTask -> UI Render 顺序是一定的,Vue中对于异步更新的运用主要是维护异步队列dom更新合并,以及nextTick。

而nextTick的实质也是MicroTask,只是它会在执行时立刻追加到异步队列后面,而当你依次执行队列时,UI虽然没渲染,但是DOM其实已经更新了,注意:DOM更新是及时的,但是更新是异步的。

我相信你已经理解了!如果有异议也可以一起讨论下!

@easonwanger
Copy link

easonwanger commented Mar 25, 2020

取出下一个宏任务的时候,会从上一个宏任务所在队列开始往后检查是否有下一个任务。也就是说,如果当前宏队列还有任务,那么取出一个执行;如果当前宏队列没有任务,会执行下一个宏队列的任务(而不是回到第一个Timers Queue中)。

@imageslr您好,执行完一个宏任务之后不应该是执行该宏任务后面的微任务么?你看下面就是promise在second timeout之前,不知道有没有正确理解你的意思,请指教
`setTimeout(
() => {
setTimeout(() => {
console.log('timeout');
}, 0);

Promise.resolve().then(()=>{
console.log('promise')
});

process.nextTick(()=>{
console.log('tick');
})
setImmediate(() => {
console.log('immediate');
});
},0)

setTimeout(()=>{
console.log('second timeout')
})`

输出:
tick
promise
second timeout
immediate
timeout

@luckymore
Copy link

postMessage是task,那它是在,setTimeout1之后插入的,为啥会在 setTimeout1 之前执行呢?

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')

@AlexZhong22c
Copy link

所有同源的browsing contexts可以共用event loop,这样它们之间就可以相互通信。

这句话是有前提的吧,否则效果太吓人了。这句话出自哪里?

@LeeeeeeM
Copy link

LeeeeeeM commented Jun 4, 2020

在一轮event loop中多次修改同一dom,只有最后一次会进行绘制。

楼主,小结第一条是不是描述有误,应该是可能会绘制,并且只绘制一次。
如果想每一次event loop都进行绘制,那么就要在渲染之前使用requestAnimationFrame对DOM进行修改。

@Jack-Works
Copy link

指正 task queue 不是队列,而是 set

规范:
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

@crazcdll
Copy link

关于第一题,我发现 只修改 textContent 或者 innerHTML 的话貌似不会触发 LayoutPaint,只会触发 Schedule Style Recalculation

append 新的元素后,会触发 LayoutPaint

Chrome 版本 83.0.4103.97(正式版本) (64 位)

<div id='con'>this is con</div>
<script>
var t = 0;
    var con = document.getElementById('con');
    con.onclick = function () {
      setTimeout(function setTimeout1() {
        // 只有这行只会触发 Schedule Style Recalculation
        con.textContent = t;
       // 加上下面的代码后,会触发 Layout 和 Paint
        const child = document.createElement('div');
        child.innerHTML = 'I am child';

        con.appendChild(child);
      }, 0)
    };
</script>

相关截图

修改 textContent 和 appendChild
修改 textContent 和 appendChild

修改 textContent 或者 innerHTML
修改 textContent 或者 innerHTML

只有 appendChild
只有 appendChild

@steady-join
Copy link

steady-join commented Jun 17, 2020

postMessage是task,那它是在,setTimeout1之后插入的,为啥会在 setTimeout1 之前执行呢?

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')

估计浏览器实现不一样吧。。测了下safari(13.0.2),输出和想法一致。。当然,firefox在非特殊场景中也是一样。具体(https://stackoverflow.com/questions/62421016/event-execution-sequence-and-rendering)

@97Yates05
Copy link

97Yates05 commented Jul 14, 2020

好不容易搞清楚了宏任务和微任务,现在又被ui渲染困住了,请问如果宏任务或微任务执行超过16.7ms,那是会等结束后立即执行渲染吗?还有requestAnimationFrame是在一轮循环内执行,还是一定在每次渲染前执行。加上ui渲染后好迷惑啊

@steady-join
Copy link

@97Yates05 并不是每一轮的event loop都伴随着渲染,浏览器会基于一些“规则”,比如这次渲染不会带来视觉上的改变会跳过这次渲染,详情见(https://html.spec.whatwg.org/multipage/webappapis.html#event-loops),而RAF是在渲染前执行的,当然在不同的chrome版本测试可能是有问题的,比如version 80,存在这个bug。

@ZhaZhengRefn
Copy link

相逢恨晚,文章写得非常好,感谢作者

@lmlife2016
Copy link

好文!想问一下浏览器为何要区分task和microtask呢?

像setTimeout这种task是浏览器本身提供的(没有ecma层面的规范);promise这种microtask是ecma语言层面的规范,由浏览器来实现。
所以可以说promise出现之前,js的异步靠浏览器提供的api,promise出现后js有了语言层面的异步操作

@xiaoxiaoqian1217
Copy link

同感同感,这是我看得最舒服也学到最多的文章了

@steady-join
Copy link

steady-join commented Dec 15, 2021 via email

@yanyang1116
Copy link

postMessage是task,那它是在,setTimeout1之后插入的,为啥会在 setTimeout1 之前执行呢?

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')

因为,settimeout 默认有 1ms 延时。虽然先进入 task queue。
但是任务执行去取 task 的时候,由于这个延时,会先取出后面的 postmessage task

@wingtao
Copy link

wingtao commented Dec 28, 2021

楼主写的非常棒。有点小疑惑望不吝赐教,如果希望在每轮event loop都即时呈现变动,可以使用requestAnimationFrame这句话的基础是每轮event loop都会检查执行requestAnimationFrame吧,并且有requestAnimationFrame回调会就一定会执行渲染。但实际应该是渲染前才会调用requestAnimationFrame吧,使用requestAnimationFrame并不能保证每轮都能渲染,只能保证渲染前一定调用了requestAnimationFrame的回调,求指教

@SK-Runner
Copy link

我这里有一段代码,可以麻烦您讲解一下执行流程吗,多谢多谢?
`async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}

async function async2() {
console.log('async2')
return new Promise(function (resolve, reject) {
console.log('promise2')
reject('promise2 reject');
}).then(function (res) {
console.log(res)
}).catch(err => {
console.log(err)
});
}

console.log('script start')
setTimeout(function () {
console.log('setTimeout')
}, 0)

async1();

new Promise(function (resolve) {
console.log('promise1')
resolve();
}).then(function () {
console.log('promise1 resolve')
})

console.log('script end')`

@steady-join
Copy link

steady-join commented Jan 20, 2022 via email

@xiaojueji
Copy link

@WinnieFE 当时我和你有一样的疑惑,写点我现在的理解!也欢迎一起来讨论

首先明确 Task -> MicroTask -> UI Render 顺序是一定的,Vue中对于异步更新的运用主要是维护异步队列dom更新合并,以及nextTick。

而nextTick的实质也是MicroTask,只是它会在执行时立刻追加到异步队列后面,而当你依次执行队列时,UI虽然没渲染,但是DOM其实已经更新了,注意:DOM更新是及时的,但是更新是异步的。

我相信你已经理解了!如果有异议也可以一起讨论下!

其实就是vue的nextTick可以拿到还没有绘制出来的dom,拿到的dom除了还没绘制之外已经可以进行任何api操作了

@hexiaolong24
Copy link

楼主写的非常棒。有点小疑惑望不吝赐教,如果希望在每轮event loop都即时呈现变动,可以使用requestAnimationFrame这句话的基础是每轮event loop都会检查执行requestAnimationFrame吧,并且有requestAnimationFrame回调会就一定会执行渲染。但实际应该是渲染前才会调用requestAnimationFrame吧,使用requestAnimationFrame并不能保证每轮都能渲染,只能保证渲染前一定调用了requestAnimationFrame的回调,求指教

 个人认为是这样的

@Smallnuo
Copy link

好文!想问一下浏览器为何要区分task和microtask呢?
比如你去一个饭店吃饭,如果只能到店排队的话那么你只能到店后从最后开始排队,但是如果提供可以取号排队,你可以在需要去饭店的前半个小时就开始排队,等你到店就差不多吃上饭了~上面是一个比较形象的例子,但是也可以看出task的局限性就是无法处理高优先级的任务,microtask 提供了一种方式可以处理高优先级的任务。作者文章中也做了比较好的解释。

@JuctTr
Copy link

JuctTr commented Oct 26, 2022

worker也是一种用户代理,我理解能够提供一个完整的上下文环境,有着自己的事件循环处理模型的一个终端,都可以称之为一种用户代理

@L0st1
Copy link

L0st1 commented Jan 23, 2024

作者你好,关于第九个例子,我在运行时的结果和示例不一样
如下:

sync
setTimeout1
postMessage
promise1
setTimeout2

setTimeout并不会因为4ms的规定而晚于postMessage执行

调换postMessage与setTimeout1的顺序

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

打印如下:

sync
setTimeout1
postMessage
promise1
setTimeout2

表明setTimeout1会早于postMessage执行

但如果为setTimeout指定1ms的延迟会发现它晚于postMessage执行,这种情况可能是什么原因导致的

上述结果的浏览器环境
mozilla/5.0 (windows nt 10.0; win64; x64) applewebkit/537.36 (khtml, like gecko) chrome/120.0.0.0 safari/537.36 edg/120.0.0.0

在更早版本的Chrome浏览器中,会得到和示例一样的结果,好像和浏览器有关
旧版本浏览器
Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests