mengtuifrontend / Blog

芦叶满汀洲,寒沙带浅流。二十年重过南楼。柳下系船犹未稳,能几日,又中秋。 黄鹤断矶头,故人今在否?旧江山浑是新愁。欲买桂花同载酒,终不似,少年游。
18 stars 5 forks source link

浏览器中的事件循环 #16

Open JingshunYang opened 5 years ago

JingshunYang commented 5 years ago

前言

js的一个特点是单线程,即浏览器中js引擎中负责解析执行js代码的线程只有一个。这是因为在浏览器环境中,我们常常需要对DOM做各种各样的操作。假设js是多线程的,那么当两个js线程同时对一个DOM进行一项操作,比如线程A希望删除这个DOM,而线程B希望改变其样式,这时就涉及到了复杂的同步问题。因此,为了保证不发生和上述场景类似的问题,js的执行只由一个线程完成。

而js中有许多原生的异步事件,诸如 setTimeout,setInterval,事件监听,Ajax请求等等。那么单线程的js是如何实现异步的呢?其核心在于js的事件循环机制。

宏任务、微任务

每个js线程拥有独立的Event loop,大多数的代码会依据正常的函数调用规则来执行,而遇到特殊的任务源,如 setTimeout/setInterval 则由他们将不同的任务分发到对应的任务队列中。

任务又分为宏任务(Macrotask)微任务(Microtask) 两种。在浏览器中, 宏任务:包括主代码块,setTimeout/setInterval回调,I/O,UI Rendering等; 如有必要,浏览器会在一个宏任务完成之后,下一个宏任务开始之前,重新渲染页面。 微任务:包括promise回调,MutationObserver回调。

事件循环过程

事件循环的过程可以用下图来表示,概括起来,事件循环的一轮迭代主要包括3个步骤:

  1. 从Macrotask队列中取出一个任务执行至结束;
  2. 将Microtask队列中的任务依次取出并执行,直到Microtask队列为空;
  3. 如果浏览器需要渲染,则重新渲染。 event-loop

    Case Study 1

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

首先,Macrotask队列中主代码块最先被执行,这里会首先输出 `script start` ,遇到 `setTimeout` 会将它的回调函数分发到Macrotask队列中,然后继续执行,遇到 `Promise` ,再将 `Promise` 的两个回调函数依次分发到Microtask队列中,接着向下执行输出 `script end` ,此时,主代码块执行结束。开始处理Microtask队列中的任务,即promise的两个回调函数,所以控制台接着输出 `promise1`  `promise2` ,到目前为止Microtask队列为空,一轮事件循环完成。

然后开始第二轮事件循环,从Macrotask队列中取出setTimeout回调并执行,控制台输出 `setTimeout` ,Microtask队列仍为空,第二轮事件循环结束。至此,程序运行完毕,控制台的所有输出汇总如下:

script start script end promise1 promise2 setTimeout

### Case Study 2
我们再来看一个涉及到html 的例子,我们创建两个div,inner div嵌套在outer div里面,代码如下:
```html
<div class="outer">
  <div class="inner"></div>
</div>
// 首先获取文档中的两个元素
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');

// 创建MutationObserver监听outer div的属性改化,如果发生变化就调用函数
new MutationObserver(function() {
  console.log('mutate');
}).observe(outer, {
  attributes: true
});

// 声明一个事件监听器
function onClick() {
  console.log('click');

  setTimeout(function() {
    console.log('timeout');
  }, 0);

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

  outer.setAttribute('data-random', Math.random());
}

// 将监听器分别和两个div绑定
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);

那么现在我们点击内部的div,控制台会如何输出呢? 首先,点击inner div相当于一次I/O事件,会触发inner div绑定的 onClick 函数 ,同时由于event bubble也会触发和outer div绑定的 onClick 函数,两个onClick函数作为Macrotask分发至Macrotask队列中。

接着,从Macrotask中取出onClick函数执行,接下来就和上一个例子差不多了,控制台输出 clicksetTimeout 将其回调函数分发至Macrotask队列,Promise将其回调函数分发至Microtask队列,然后执行到outer.setAttribute,注意它修改了outer div的属性,会触发MutationObserver绑定的回调函数,而该回调函数会分发至Microtask队列中。至此,inner div的事件监听器执行完毕。 此时,Macrotask队列和Microtask的情况分别是:

Macrotask队列:onClick | setTimeout callback
Microtask队列:Promise callback | Mutation observer

然后,开始处理所有的Microtask,控制台依次输出promise mutate,一轮事件循环结束。 此时,Macrotask队列和Microtask的情况分别是:

Macrotask队列:onClick | setTimeout callback
Microtask队列:空

第二轮事件循环开始执行第二个onClick函数,和上一个一样,控制台输出 clicksetTimeout 将其回调函数分发至Macrotask队列,Promise将其回调函数分发至Microtask队列,然后outer.setAttribute 触发MutationObserver绑定的回调函数,而该回调函数会分发至Microtask队列中。 此时,Macrotask队列和Microtask的情况分别是:

Macrotask队列:setTimeout callback | setTimeout callback
Microtask队列:Promise callback | Mutation observer

然后,开始处理所有的Microtask,控制台依次输出promise mutate,第二轮事件循环结束。 此时,Macrotask队列和Microtask的情况分别是:

Macrotask队列:setTimeout callback | setTimeout callback
Microtask队列:空

接着继续执行setTimeout的回调函数,控制台输出timeout timeout。至此,程序运行完毕,控制台的所有输出汇总如下:

click
promise
mutate
click
promise
mutate
timeout
timeout

参考资料

Tasks, microtasks, queues and schedules 深入理解js事件循环机制(浏览器篇) 《JavaScript核心技术开发揭秘》

AndreGeng commented 4 years ago

想到还有种场景,如果通过inner.click()来触发事件的话,console的输出顺序会是什么呢😉