vlambda博客
学习文章列表

前端浏览器的Event Loop

众所周知,JavaScript运行在浏览器中时是单线程的,因此,事件循环机制也就至关重要。这篇文章就来简单梳理一下前端浏览器中JS的事件循环机制。




01


先来理清几个基本概念:


执行上下文栈(Execution Context Stack):JavaScript引擎创建了执行上下文栈来管理执行上下文,可以把执行上下文栈认为是一个存储函数调用的栈结构。需要记住的是,JavaScript运行在单线程上,因此代码都是排队执行,每次只能执行一个函数。每当进入一个函数的执行就会创建函数的执行上下文,并且将它压入执行栈的顶部,并在执行完后出栈等待垃圾回收。浏览器的JavaScript执行引擎总是访问栈顶的执行上下文。



任务队列(Task Queue): 用于存放待执行的任务,分为宏任务队列和微任务队列。宏任务队列可以有很多,而微任务队列只有一个。每执行完一个宏任务队列,执行栈为空,此时会检查微任务队列是否为空,如果不为空,则一次执行微任务队列中的所有微任务直至清空微任务队列。然后浏览器会判断是否需要重新渲染页面,如果需要则会在此时重渲染。之后继续执行下一个宏任务队列。



事件循环(Event Loop): 事件循环是由JavaScript的宿主实现的,在前端,这个宿主就是浏览器。当JavaScript执行引擎遇到异步任务(DOM事件监听、AJAX、定时器)时,交给Web APIs(由C++实现的浏览器创建的线程)处理,处理后再由事件循环在适当时机将回调函数放入到宏任务队列。



02


明确了基本概念后,我们可以看出来最重要的事情就是知道哪些任务属于宏任务,哪些任务属于微任务。这样才能了解谁先执行谁后执行。常见的几种任务见下面这个表格。



宏任务
微任务
setTimeout

setInterval
requestAnimationFrame

MutationObserver

Promise.then/catch



03


基本概念及原理介绍完了,是不是觉得事件循环机制也不过如此。下面来几个例子实践一下。


1. 

let startTime = new Date();setTimeout(() => { console.log(`时间间隔:${new Date() - startTime}ms`);}, 200);while(new Date() - startTime < 500) {}

最终打印出来的时间间隔是多少呢?


答案是500ms(或略多于500ms)。

来分析一下这段代码的执行过程。首先,浏览器会将整个script的执行作为一个宏任务队列来执行。当遇到setTimeout时,会将这段代码交给上面提过的WebAPI,WebAPI将在200毫秒后创建一个新的宏任务队列,并将该回调函数加入。继续回到script的执行,遇到while循环,在此阻塞了500ms后,script执行完毕并退出执行栈,此时由于没有微任务,所以执行下一个宏任务队列,即setTimeout的回调函数。


也就是说在第200ms时,回调函数已经加入了宏任务队列,只是需要排队到第500ms时才能执行。



2.

console.log("script start");setTimeout(() => console.log("setTimeout"), 0);Promise.resolve() .then(() => console.log("promise1")) .then(() => console.log("promise2"));Promise.resolve()    .then(() => console.log("promise3"));console.log("script end");

在这个例子中,打印的内容会按怎样的顺序输出呢?


答案是:script start > script end > promise1 > promise3 > promise2 > setTimeout


再让我们来分析一下执行过程。


首先,script的执行还是第一个宏任务,遇到第一个console.log时,立即输出script start。接着,遇到setTimeout定时器,依旧交给WebAPI,由于延迟为0,所以回调函数会被立即加入到一个新的宏任务队列中。接着遇到了已经resolve的Promise,于是将第一个then函数加入到微任务队列中,第二个Promise也同理,将then函数加入到微任务队列中。最后遇到console.log,立即输出script end。至此,代码执行完成,该宏任务队列执行完毕。


由于这个宏任务队列已经执行完,调用栈为空,此时事件循环会检查微任务是否为空,此时的微任务队列不为空,因此开始依次执行微任务队列中的任务。第一个任务打印出了promise1。由于这个函数返回后后面还有then函数,于是再将这个then函数加入到微任务队列中。第二个微任务打印出promise3,第三个微任务打印出promise2。至此微任务队列清空。


此时事件循环会再找下一个宏任务队列,于是轮到setTimeout的宏任务队列执行,打印出setTimeout,微任务队列依然为空。执行结束。



3. 

当事件循环遇到事件冒泡

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

const outer = document.querySelector(".outer");const inner = document.querySelector(".inner");
new MutationObserver(() => console.log("mutate")).observe(outer, { attributes: true});
function onClick() { console.log("click");     setTimeout(() => console.log("setTimeout"), 0);        Promise.resolve().then(() => console.log("promise"));        outer.setAttribute("data-random", Math.random());}
inner.addEventListener("click", onClick);outer.addEventListener("click", onClick);

当我们点击内层div时,会按什么顺序打印输出呢?


答案是:click > promise > mutate > click > promise > mutate > setTimeout > setTimeout


再来看一下代码执行的过程


首先执行script代码这个宏任务,注册了一个MutationObserver和两个点击事件。执行完成后没有其他任务。当内层div被点击时,事件循环将回调函数即onClick函数添加至一个新的宏任务队列并开始执行。首先打印出了click,紧接着遇到setTimeout,添加了一个新的宏任务队列,然后Promise添加了一个微任务,最后设置外层div的属性时触发了MutationObserver,它也是一个微任务。函数执行完成后触发事件冒泡,将该点击事件派发到外层div上,此时会在当前宏任务队列中添加一个宏任务,即外层div的点击事件回调函数。但是由于当前回调函数已经处理完毕,出栈后执行栈为空,于是事件循环开始执行微任务,打印出promise和mutate。

之后再来执行宏任务队列,开始执行外层div的点击回调函数,再打印出click、promise和mutate。最后是setTimeout的两个回调函数。


如果在JavaScript最后加上

inner.click();

手动触发内层div的点击事件,结果会不会不同呢?


答案是:会。click > click > promise > mutate > promise > setTimeout > setTimeout


产生不同的原因是,手动触发的时候,click回调函数是在代码执行过程中被调用的,此时的宏任务队列是script执行任务,当内层回调函数处理完成出栈后,并将外层的回调函数添加进宏任务队列时,此时执行栈不为空,因此会继续检查该宏任务队列是否为空,因而继续执行外层div的回调函数。在外层回调函数向微任务添加MutationObserver回调函数时,由于微任务队列中已有该微任务,不会重复添加,因此只会打印一次mutate。



4. 

从上面的例子来扩展一下

function onClick1() { console.log(111); Promise.resolve().then(() => console.log("promise1"));}
function onClick2() { console.log(222); Promise.resolve().then(() => console.log("promise2"));}
// 1inner.addEventListener("click", onClick1);inner.addEventListener("click", onClick2);
// 2inner.addEventListener("click", function cb() { onClick1(); onClick2();});

1和2执行结果相同吗?


答案是不同。

1的结果是:111 > promise1 > 222 > promise2

2的结果是:111 > 222 > promise1 > promise2


不同的原因就是当1触发事件时,会创建一个新的宏任务队列,其中有两个宏任务,分别是onClick1和onClick2,但是当第一个执行完时,由于执行栈为空,因此开始执行微任务,之后才会执行第二个宏任务。

而当2触发事件时,会创建一个只有一个函数的宏任务队列,在这个回调函数cb中调用两个函数,因此直到onClick2执行完之前,执行栈都不会为空。所以最后才去执行微任务队列。



04




了解了原理及实际例子之后,那么怎么才能利用事件循环机制更好地优化我们的代码呢?换句话说,如何在实际应用中使用它呢?


Vue给了我们一个很好的现实范例。由于事件循环机制会在执行完一个宏任务队列和一个微任务队列之后刷新页面,而宏任务队列我们能控制的很少,因此,我们就要充分利用Promise和微任务队列。

在Vue中,数据发生变化后,被Watcher察觉,此时可能有多个Watcher依赖该数据并需要更新。Vue的做法是创建一个队列,将这些Watcher都加入进来,然后使用Promise将他们一并加入一个微任务队列中,使其可以一次更新,并且在他们更新的过程中如果出现了新的数据变化导致的Watcher需要更新,还可以随时添加进来。并且这些Watcher会按照ID来一次执行,保证从父节点到子节点按序执行,这就是题外话了。




希望这篇文章对你理解JS的事件循环机制有所帮助。