了解Event Loop这篇挺足的

1,316 阅读9分钟

在开始Event Loop之前,我们先来介绍下JS线程方面。

JS是一门单线程语言,在最新的 HTML5 中提出了Web-Worker,但JS是单线程这一核心仍未改变。所以一切Javascript版的"多线程"都是用单线程模拟出来的,一切JS多线程都是纸老虎!

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

JS任务也得一个一个顺序执行,如果遇到耗时比较久,那往后的任务是不是要一直等待下去?在此,我们将任务分为:同步任务异步任务

同步与异步任务

主线程类似一个加工厂,它只有一条流水线,待执行的任务就是流水线上的原料,只有前一个加工完,后一个才能进行。Event Loops就是把原料放上流水线的工人。 只要已经放在流水线上的,它们会被依次处理,称为同步任务。一些待处理的原料,工人会按照它们的种类排序,在适当的时机放上流水线,这些称为异步任务。 导图的意图:

  • 任务分为同步或异步,两者进入不同的环境。同步进入主线程,异步进入Event Table并注册函数。
  • 当指定的事件完成时,Event Table会将这个函数移入Event Queue
  • 主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行。
  • 上述过程会不断重复,也就是常说的Event Loop(事件循环)。

示例代码:

$.ajax({
    ...
    success:() => {
        console.log('b!');
    }
})
console.log('a');
  • ajax进入Event Table,注册回调函数success
  • 执行`console.log('代码执行结束')``。
  • ajax事件完成,回调函数success进入Event Queue
  • 主线程从Event Queue读取回调函数 success 并执行。

上例只是简单的介绍事件循环,我们来看看更加详细的介绍。

Event Loop 的处理过程

event loop翻译出来就是事件循环,可以理解为实现异步的一种方式。

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

(task 另外一个名称为:macrotask)

在规范的Processing model定义了event loop的循环过程: 一个 event loop 只要存在,就会不断执行下边的步骤:

1.在tasks队列中选择最老的一个task,如果没有任务,则跳到下边的microtasks步骤。

2.将上边选择的task设置为正在运行的task

3.Run: 运行被选择的task

4.将event loopcurrently 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会不断循环的去取tasks队列的中最老的一个任务推入栈中执行,并在当次循环里依次执行并清空microtask队列里的任务。
  • 执行完microtask队列里的任务,有可能会渲染更新。(浏览器很聪明,在一帧以内的多次dom变动浏览器不会立即响应,而是会积攒变动以最高60HZ的频率更新视图)。

microtasks 检查点

event loop运行的第 6 步,执行了一个microtask checkpoint,看看规范如何描述microtask checkpoint: 当用户代理去执行一个 microtask checkpoint,如果 microtask checkpoint 的 flag(标识)为 false,用户代理必须运行下面的步骤:

1.将microtask checkpointflag设为true

2.Microtask queue handling: 如果event loopmicrotask队列为空,直接跳到第八步(Done)。

3.在microtask队列中选择最老的一个任务。

4.将上一步选择的任务设为event loopcurrently running task

5.运行选择的任务。

6.将event loopcurrently 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 checkpointflag设为flase

microtask checkpoint所做的就是执行microtask队列里的任务。什么时候会调用microtask checkpoint呢?

  • 当上下文执行栈为空时,执行一个microtask checkpoint。 在event loop的第六步(Microtasks: Perform a microtask checkpoint)执行checkpoint,也就是在运行task之后,更新渲染之前。

macro-task(宏任务):

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

micro-task(微任务):

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

我们来看看示例代码:

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:

macrotaskmicrotask
script

1.从macrotask队列中取出script任务,推入栈中执行。

2.promise1列为microtasksetTimeout1列为tasksetTimeout2列为macrotask

macrotaskmicrotask
setTimeout1 setTimeout2promise1

3.script任务执行完毕,执行microtask checkpoint,取出microtask队列的promise1执行。

循环 2:

macrotaskmicrotask
setTimeout1 setTimeout2

4.从macrotask队列中取出setTimeout1,推入栈中执行,将promise2列为microtask

macrotaskmicrotask
setTimeout2promise2

5.执行microtask checkpoint取出microtask列的promise2行。

循环 3

macrotaskmicrotask
setTimeout2

6.acrotask列中取出setTimeout2推入栈中执行。

7.Timeout2务执行完毕,执行microtask checkpoint

macrotaskmicrotask

每次执行完microtask,视图就会更新吗?在我们日常开发中,好像并不是,让我们看看具体原因吧。

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

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

这篇文章较详细的讲解了渲染机制。

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

不同机子测试可能会得到不同的结果,这取决于浏览器,CPUGPU性能以及它们当时的状态。

例 1:

<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 个macrotask,分别是click1setTimeout1setTimeout2,所以会分别在 3 次event loop中进行。

当点击后,一共产生 3 个macrotask,分别是click1setTimeout1setTimeout2,所以会分别在 3 次event loop中进行。

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

例 2:

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

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

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

例 3:

var con = document.getElementById('con');
con.onclick = function () {
   setTimeout(function  setTimeout1() {
        con.textContent = 0;
   }, 0);
    setTimeout(function  setTimeout2() {
            con.textContent = 1;
    }, 16.7);
};

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

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

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

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

图中一共绘制了两帧,第一帧 910ms,第二帧 920ms,都远远高于每秒 60HZ(16.7ms)的频率,所以两次 event loop 的间隔很短同样会进行绘制。

例 5:

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

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

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

看看setTimeout2的运行情况。

可以看到setTimeout2这轮event loop没有run microtasksmicrotaskssetTimeout1被全部执行完了。

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

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

例 6:

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

总体的Timeline:

看看单个 requestAnimationFrameTimeline

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

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

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

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

  • 如果希望在每轮event loop都即时呈现变动,可以使用requestAnimationFrame


分享不易额,喜欢的话一定别忘了点💖!!!

只关注不点💖的都是耍流氓,只收藏也不点💖的也一样是耍流氓。

参考文献

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

这一次,彻底弄懂 JavaScript 执行机制