JavaScript事件循环机制

众所周知JS是一门单线程执行环境的语言,对于同步任务而言,同一时刻只能执行一个任务,后续的任务都要在当前执行的任务后面排队。这种模式在遇到一些执行时间较长的任务的时候就会出问题,会导致页面失去响应。所以这些时间较长的任务我们在编写的时候一般会把他们用异步的方式去调用,并指定任务完成时对结果进行后续处理的回调函数。而JS的事件循环机制就是负责对这些同步任务和异步任务的执行顺序进行调度的。

JavaScript的函数堆栈以及任务队列

JavaScript引擎中存在着一个主线程,所有的同步任务都会在这个主线程上执行,每当一个同步任务要执行了,主线程就会把这个同步任务推入函数堆栈中,待执行完成后,主线程读取下一个同步任务到堆栈中,继续执行;而在这个过程中若存在被注册的异步任务回调函数,这些异步任务会交给引擎中的其他模块进行处理,并在异步任务完成或是合适的时机(比如setTimeout指定的时间间隔达到了)将回调函数放到任务队列当中,一般来说不同的异步任务会有不同的任务队列,而不是所有回调都放在同一个任务队列当中。当主线程中的函数堆栈中不再有更多的同步任务时,主线程就会开始读取任务队列中的回调函数,将其一一执行。
可以用一张图来表示这个过程:
一张图
如图所示,当主线程遇到了像是Ajax,setTimeout和DOM操作这一类异步任务的时候,它会把这些任务交给特定的其他模块去处理,然后它自个继续执行接下去的同步任务,而那些异步任务执行完毕之后,则会将注册的回调函数放入任务队列当中,待主线程将同步任务执行完后,就会去读取这些回调函数。

异步任务的类型以及事件循环的过程

我在学习事件循环的时候,在别的博客了解到了JavaScript的任务分成两种不同的类型这一概念:一种称为macro task(宏任务),包括script(主代码),setTimeout,setInterval,setImmediate,I/O, UI rendering。另一种称为micro task(微任务),包括process.nextTick, Promise, Object.observe,MutationObserver这些任务都有他们自己独立的任务队列,也就是他们的回调函数会被放到不同的队列中等待主线程读取执行。而在这个过程中事件循环就开始起作用了,具体来说,事件循环的过程如下:
首先要明确,每一次事件循环,只会执行一个macrotask,在执行完这个macrotask后,主线程会开始去读取microtask的队列,将所有microtask在这一次事件循环中执行完毕,也就是会将microtask队列清空;这也就说明,如果在执行microtask的回调函数的过程中,如果又注册了新的microtask,那么这些microtask也都会在这一轮的事件循环中执行。microtask队列清空以后,主线程从macrotask的队列中读取下一个macrotask,进行执行。
(1)事件循环首先从整体代码片开始(要注意整体代码其实也是一个macrotask),读取代码片中的同步任务,将其推入函数堆栈中执行。
(2)在处理整体代码片中,若遇到了宏任务和微任务的调用,像是setTimeout或是new Promise的话,就把他们交给他们对应的模块去处理,那些模块会负责把回调函数推入他们对应的任务队列中。
(3)当函数堆栈中不再有正在执行的同步任务后,开始读取任务队列中的回调函数,事件循环会先查看微任务中的回调函数,优先执行微任务的回调函数。
(4)当微任务的回调函数执行完后,事件循环拿出一个宏任务的回调函数,并执行,执行完后,事件循环会回到第三步,再次查看有没有新的微任务的回调函数,有的话则取出来执行。

接下来用几个例子来更直观地描述事件循环的过程,现在存在如下代码:

console.log('golb1');
setImmediate(function() {
    console.log('immediate1');
    process.nextTick(function() {
        console.log('immediate1_nextTick');
    })
    new Promise(function(resolve) {
        console.log('immediate1_promise');
        resolve();
    }).then(function() {
        console.log('immediate1_then')
    })
})
setTimeout(function() {
    console.log('timeout1');
    process.nextTick(function() {
        console.log('timeout1_nextTick');
    })
    new Promise(function(resolve) {
        console.log('timeout1_promise');
        resolve();
    }).then(function() {
        console.log('timeout1_then')
    })
    setTimeout(function() {
        console.log('timeout1_timeout1');
    process.nextTick(function() {
        console.log('timeout1_timeout1_nextTick');
    }, 0);
    setImmediate(function() {
        console.log('timeout1_setImmediate1');
    })
    });
}, 0);
new Promise(function(resolve) {
    console.log('glob1_promise');
    resolve();
}).then(function() {
    console.log('glob1_then')
})
process.nextTick(function() {
    console.log('glob1_nextTick');
})

开始执行后,首先主代码入栈,执行console.log(‘golb1’),这没什么好说的,接着遇到了第一个宏任务setImmediate, 将他交给对应的执行模块;同理下面遇到的setTimeout,Promise以及process.nextTick都被交给特定的模块处理,接着他们的回调函数被放入他们对应的任务队列中。注意Promise的构造函数的参数是一个在主代码片中执行的同步任务,所以会输出glob1_promise。
接下来,主代码片执行完成,事件循环机制开始读取微任务的任务队列,首先会执行process.nextTick的回调函数,输出glob1_nextTick;接着执行Promise.then中指定的回调函数,输出glob1_then。
现在微任务的任务队列清空了,事件循环机制开始读取宏任务的任务队列。首先是setTimeout的回调函数,在其中又指定了两个微任务的调用和两个宏任务的调用。这个回调函数执行完后,事件循环机制查看微任务的任务队列,发现多了两个微任务的回调函数,于是把他们按顺序读取执行。
微任务再次清空,事件循环读取一开始在主代码片中执行的setImmediate的回调函数,输入immediate1,同理,这里又注册了两个微任务,所以在执行完后会去读取他们并执行。
最后异步任务只剩在setTimeout的回调函数中执行的setTimeout:timeout1_timeout1,将这个回调函数读取入栈并执行,事件循环就结束了。整体输入结果如下:

    golb1
    globl_promise
    glob1_nextTick
    glbl1_then
    timeout1
    timeout1_promise
    timeout1_nextTick
    timeout1_then
    immediate1
    immediate1_promise
    immediate1_nextTick
    immediate1_then
    timeout1_timeout1
    timeout1_timeout1_nextTick
    timeout1_setImmediate1

再用一个我在一篇文章中看到的很有说服力的例子来描述事件循环:
假设现在用如下代码:

<div class="outer">
    <div class="inner"></div>
</div>

var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');

// Let's listen for attribute changes on the
// outer element
new MutationObserver(function() {
  console.log('mutate');
}).observe(outer, {
  attributes: true
});

// Here's a click listener…
function onClick() {
  console.log('click');

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

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

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

inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);

按照图中的代码描述,现在如果我们点击了子div,也就是class为inner的div,那么控制台的输出会是什么呢?

在这里揭晓答案:

click
promise
mutation
click
promise
mutation
timeout
timeout

为何会输出这样的结果呢?我们一步步讲解:
在点击了inner以后,触发了一个点击事件,我们叫这个点击事件的task为Dispatch Click。
此时它是macrotask中唯一一个task,取出来执行。然后就会在函数堆栈中推入点击事件的处理函数onClick,先输出click;接着在onClick中一共注册了三个异步回调:setTimeout,Promise.then,还有MutationObserver观测到outer的属性变化而注册的回调,其中按照前文所述,setTimeout被推入macrotask队列,而Promise和MutationObserver按顺序被推入microtask的队列中;到这,onClick函数执行完毕,被推出堆栈中,此时函数堆栈为空,所以事件循环会开始去读取microtask队列,也就是刚刚注册的Promise和MutationObserver的回调函数,输出promise和mutation;接着,由于点击事件会冒泡,又触发了outer上的点击事件,同样是onClick函数,被压入函数堆栈。接着上述过程就重复了一次。最后,macrotask队列中只剩下两个setTimeout的回调函数,依次执行,输出timeout。

不过,如果将上述点击事件的触发方式修改一下,却会出现不同的结果,我们在上述代码的最后添加一行:

//....省略上述代码
inner.click();

我们将点击inner触发点击事件改为用JS代码显式调用点击事件方法,去触发onClick,在这种情况下,上述代码在控制台的输出结果会变为:

click
click
promise
mutation
promise
timeout
timeout

为什么会变成这样呢?我们再按刚刚的方式,一步步分析任务队列和函数堆栈的情况:
由于现在是通过inner.click()这种通过JS代码显式调用的方法调用click事件,那么函数堆栈中首先会存在一个我们称为script的顶层执行上下文,也就是前文所述的整体代码片;调用click方法后,onClick函数被执行,压入函数堆栈中,注意此时函数堆栈中有两个执行环境,一个是script,一个是onClick。此时当然还是会先输出click,以及注册回调函数,和前面一样;然后onClick执行完毕后,情况就有所不同了,onClick被推出堆栈,但是script还存在在堆栈中。记得前面所说的吗?当函数堆栈为空时才会去读取microtask任务队列,所以此时,microtask中的Promise和MutationObserver都不会被读到函数堆栈中去执行。接着,点击事件冒泡,再次执行onClick,重复前述步骤,不过这里有一处不同,当执行到给outer设置属性这一步代码时,由于此时microtask队列中已经存在同一个MutationObserver注册的回调函数了,所以不会再推出一个MutationObserver的回调。也就是说此时microtask的队列是这样的:Promise -> Mutation -> Promise。接着,onClick事件调用完毕,推出堆栈,此时整体代码片也已经运行完了,script也推出堆栈,函数堆栈为空,事件循环开始读取microtask。接着的执行过程就和前面的例子一样了,只是少了一个mutation的输出。
关于这个例子,如果想要看到更详细的讲解的话可以参考下面这篇文章,我也是看了这篇文章之后对事件循环有了更深刻的理解:
Tasks, microtasks, queues and schedules

参考文章:
深入浅出Javascript事件循环机制(上)
深入浅出Javascript事件循环机制(下)

  • 3
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值