理解浏览器中的Event Loop(事件循环)
Event Loop是什么?
Event Loop是一个执行模型
,在不同的平台有不同的实现。浏览器和NodeJS基于不同的技术实现了各自的Event Loop。注意,在浏览器中,它发生在渲染进程
中。
Event Loop用来做什么?为什么会有这个东西?
我们知道JS是单线程
的,某一时刻就执行一个任务。如果浏览器仅仅是这样的,那类似Ajax这样的操作,对浏览器来说就是同步的,请求的时候会阻塞其它任务的执行。当然,事实上显然不是这样。在线程运行的过程中,显然我们希望浏览器能接受并执行新的任务,此时就引入了事件循环的机制。达到下图这样的效果。
事件循环中的一些概念
在事件循环中,有一些概念,我们先抛出来。
宏任务
: macrotask 也叫tasks。一些异步任务的回调依次进入macro task queue(宏任务队列),等待后续被调用,这些异步任务包括:
-
setTimeout
-
setInterval
-
setImmediate(Node独有)
-
requestAnimationFrame(浏览器独有)
-
I/O
-
UI rendering(浏览器独有)
一系列宏任务构成了宏任务队列,它具有队列先进先出的特性。
微任务
:microtask,也叫jobs。另一些异步任务的回调会依次进入micro task queue(微任务队列),等待后续被调用。这些异步任务包括: -
process.nextTick (Node独有)
-
Promise.then()
-
Object.observe
-
MutationObserver
一系列微任务构成了微任务队列。
注意:Promise构造函数里的代码是同步执行的
通过一个题目理解这个过程:下面这段JS脚本运行后,先后打印输出的内容是什么?
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');
在最新版的chrome中运行这段脚本,发现答案是script start、script end、promise1、promise2、setTimeout。接下来,我们就通过这段代码搞懂事件循环这种执行模型。英文原版解释可以参考这篇文章
为什么是这样?
为了理解这个打印逻辑,我们需要知道事件循环是怎么处理宏任务和微任务的。如果这是你第一次了解这些知识,可能会让你头疼。深呼吸下...😅
每个"线程"都有自己的事件循环,因此每个web工作线程也有自己的事件循环,因此它可以独立执行,而同源下的所有窗口共享一个事件循环并且他们可以同步通信。事件循环持续运行,执行所有排列好的任务。事件循环有多个任务源,这些任务源有一定的执行顺序(类似indexedDB定义了自己的),浏览器在每次循环中要选择从哪个源执行任务。这使得浏览器可以优先执行对性能敏感的任务,例如用户输入。ok ok,跟着我继续🤓。(意思就是宏任务队列和微任务队列都执行任务的源,浏览器会在合适的时机选择这两个源中的任务,放到调用栈中去执行)
宏任务是被计划好的,以便浏览器可以从内部访问JavaScript/DOM,并确保这些操作按顺序发生。在宏任务之间,浏览器可以渲染更新。从鼠标单击进入事件回调需要安排任务,解析HTML也是这样,还是上面的setTimeout。(意思就是这三种情况都是宏任务)。
在上面的例子中setTimeout是有一定的延迟的,被安排到了下一个宏任务中执行回调。这就是为什么setTimeout在script end之后打印,因为script end是在第一个宏任务中,而setTimeout在另外一个宏任务中执行。能理解到这很不错了,加油接着往下看...😋
通常,微任务是在当前执行脚本之后立即发生的事情安排的,例如对一批动作做出反应,或者做某些异步的事情,又不必占用整个宏任务。在一次事件循环中,如果调用栈中没有其它需要执行的JS脚本,微任务队列中的任务就会进入调用栈中执行。新的微任务会被加入到微任务队列的尾部,先加入的先执行,后加入的后执行。微任务包括mutation observer回调,还有上面的promise回调。(机翻没法忍,加入了自己的理解,可以参看原文😇)
一旦新建了一个promise,就会被放到微任务队列的末尾,等待执行回调。这样确保了promise是被异步调用的,尽管promise已经被建立了。微任务中的**.then会在下一个微任务之前执行完,就是说微任务是依次执行的。为什么promise1和promise2会在script end之后打印,因为微任务的执行要等当前运行的脚本执行完。promise1和promise2在setTimeout**之前打印,因为微任务发生在下一个宏任务之前。(这段的意思是,微任务 的执行是在两个宏任务之间,微任务队里中的任务是依次执行的)。为了有助于理解,一定要在这个页面一步一步执行下看。执行的动画图解。顺便说下,作者Jake是个有趣的人。
总结下,当浏览器去执行一段代码的时候,也就是一个宏任务开始了,遇到普通代码就放到调用栈中去执行,遇到宏任务就把它放到宏任务队列中,遇到微任务就放到微任务队列中。当前宏任务代码运行完后,即调用栈空了,此时就会取出微任务队列中的微任务放到调用栈中去执行,当微任务队列中的微任务全部执行完后,会从宏任务队列中取出下一个宏任务放到调用栈中去执行。这里有个问题要注意,如果在微任务运行的过程中,一直有微任务添加进来,那就会一直运行微任务,直到运行完所有微任务,才会运行宏任务。
有些浏览器打印顺序不同?
一些浏览器的打印是script start,script end,setTimeout,promise1,promise2,他们运行promise回调是在setTimeOut之后。这些浏览器调用promise作为一个宏任务而不是一个微任务。
将promise作为宏任务会引起一些性能问题,可能会引起一些不必要的延迟,比如渲染被延迟。它还会由于与其它任务源的交互而导致不确定性,并可能破坏与其它api的交互。也是因为这种影响,Safair和火狐在后续的版本中修复了这个问题。(意思是,后续的浏览器逐渐将promise视作为微任务)
如果有理解错误的地方,欢迎指出。如果这篇文章没有帮到你,可以看下面两个JSconf大会的视频,希望可以帮到你。
尔不用求记,却宜求个清楚 (曾国藩)