天天看点

从一道题浅说 JavaScript 的事件循环

阮老师在其推特上放了一道题:

看到此处的你可以先猜测下其答案,然后再在浏览器的控制台运行这段代码,看看运行结果是否和你的猜测一致。

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. There are two kinds of event loops: those for browsing contexts, and those for workers.
本文所涉及到的事件循环是基于 Browsing Context。

那么在事件循环机制中,又通过什么方式进行函数调用或者任务的调度呢?

在此次 tick 中选择最先进入队列的任务(oldest task),如果有则执行(一次)

检查是否存在 Microtasks,如果存在则不停地执行,直至清空 Microtasks Queue

更新 render

主线程重复执行上述步骤

仔细查阅规范可知,异步任务可分为 <code>task</code> 和 <code>microtask</code> 两类,不同的API注册的异步任务会依次进入自身对应的队列中,然后等待 Event Loop 将它们依次压入执行栈中执行。

查阅了网上比较多关于事件循环介绍的文章,均会提到 macrotask(宏任务) 和 microtask(微任务) 两个概念,但规范中并没有提到 macrotask,因而一个比较合理的解释是 task 即为其它文章中的 macrotask。另外在 ES2015 规范中称为 microtask 又被称为 Job。

(macro)task主要包含:script(整体代码)、setTimeout、setInterval、I/O、UI交互事件、setImmediate(Node.js 环境)

microtask主要包含:Promise、MutaionObserver、process.nextTick(Node.js 环境)

在 Node 中,会优先清空 next tick queue,即通过process.nextTick 注册的函数,再清空 other queue,常见的如Promise

setTimeout/Promise 等API便是任务源,而进入任务队列的是他们指定的具体执行任务。来自不同任务源的任务会进入到不同的任务队列。其中setTimeout与setInterval是同源的。

<a href="https://link.juejin.im/?target=https%3A%2F%2Fcamo.githubusercontent.com%2Fdda53fb4e1e98b6b75871c08b3038569fbbc96e3%2F68747470733a2f2f736661756c742d696d6167652e62302e7570616979756e2e636f6d2f3134392f3930352f313439393035313630392d356138616434376663653736345f61727469636c6578" target="_blank"></a>

纯文字表述确实有点干涩,这一节通过一个示例来逐步理解:

首先,事件循环从宏任务(macrotask)队列开始,这个时候,宏任务队列中,只有一个script(整体代码)任务;当遇到任务源(task source)时,则会先分发任务到对应的任务队列中去。所以,上面例子的第一步执行如下图所示:

<a href="https://link.juejin.im/?target=https%3A%2F%2Fcamo.githubusercontent.com%2Ff2f62d7aa5cfb04474513d12d86c1564db8a19a8%2F68747470733a2f2f736661756c742d696d6167652e62302e7570616979756e2e636f6d2f3238352f3334312f3238353334313831342d356138623030653839383435335f61727469636c6578" target="_blank"></a>

然后遇到了 <code>console</code> 语句,直接输出 <code>script start</code>。输出之后,script 任务继续往下执行,遇到 <code>setTimeout</code>,其作为一个宏任务源,则会先将其任务分发到对应的队列中:

<a href="https://link.juejin.im/?target=https%3A%2F%2Fcamo.githubusercontent.com%2Fc7f22f1d044f92c3df13ca935d6abb143546d5d6%2F68747470733a2f2f736661756c742d696d6167652e62302e7570616979756e2e636f6d2f3132342f3536312f313234353631313735322d356138623031613537663963335f61727469636c6578" target="_blank"></a>

script 任务继续往下执行,遇到 <code>Promise</code> 实例。Promise 构造函数中的第一个参数,是在 <code>new</code> 的时候执行,构造函数执行时,里面的参数进入执行栈执行;而后续的 <code>.then</code> 则会被分发到 microtask 的 <code>Promise</code> 队列中去。所以会先输出 <code>promise1</code>,然后执行 <code>resolve</code>,将 <code>then1</code> 分配到对应队列。

构造函数继续往下执行,又碰到 <code>setTimeout</code>,然后将对应的任务分配到对应队列:

<a href="https://link.juejin.im/?target=https%3A%2F%2Fcamo.githubusercontent.com%2F192572c3d5e70c177372087093625a3be1266ab8%2F68747470733a2f2f736661756c742d696d6167652e62302e7570616979756e2e636f6d2f3335342f3731342f333534373134373638302d356138623034613239326436615f61727469636c6578" target="_blank"></a>

script任务继续往下执行,最后只有一句输出了 <code>script end</code>,至此,全局任务就执行完毕了。

根据上述,每次执行完一个宏任务之后,会去检查是否存在 Microtasks;如果有,则执行 Microtasks 直至清空 Microtask Queue。

因而在script任务执行完毕之后,开始查找清空微任务队列。此时,微任务中,只有 <code>Promise</code>队列中的一个任务 <code>then1</code>,因此直接执行就行了,执行结果输出 <code>then1</code>。当所有的 <code>microtast</code> 执行完毕之后,表示第一轮的循环就结束了。

<a href="https://link.juejin.im/?target=https%3A%2F%2Fcamo.githubusercontent.com%2F8ed5185bd11181e56d1eefa5e583aa37d2e25efa%2F68747470733a2f2f736661756c742d696d6167652e62302e7570616979756e2e636f6d2f3431382f3935382f343138393538323435372d356138623036386636653538315f61727469636c6578" target="_blank"></a>

这个时候就得开始第二轮的循环。第二轮循环仍然从宏任务 <code>macrotask</code>开始。此时,有两个宏任务:<code>timeout1</code> 和 <code>timeout2</code>。

取出 <code>timeout1</code> 执行,输出 <code>timeout1</code>。此时微任务队列中已经没有可执行的任务了,直接开始第三轮循环:

<a href="https://link.juejin.im/?target=https%3A%2F%2Fcamo.githubusercontent.com%2Fb7b7b68df390145dd9222c95602423224151506f%2F68747470733a2f2f736661756c742d696d6167652e62302e7570616979756e2e636f6d2f3236372f3739302f323637373930333835322d356138623036666433653637665f61727469636c6578" target="_blank"></a>

第三轮循环依旧从宏任务队列开始。此时宏任务中只有一个 <code>timeout2</code>,取出直接输出即可。

这个时候宏任务队列与微任务队列中都没有任务了,所以代码就不会再输出其他东西了。那么例子的输出结果就显而易见:

在回头看本文最初的题目:

这段代码的流程大致如下:

script 任务先运行。首先遇到 <code>Promise</code> 实例,构造函数首先执行,所以首先输出了 4。此时 microtask 的任务有 <code>t2</code> 和 <code>t1</code>

script 任务继续运行,输出 3。至此,第一个宏任务执行完成。

执行所有的微任务,先后取出 <code>t2</code> 和 <code>t1</code>,分别输出 2 和 1

代码执行完毕

综上,上述代码的输出是:4321

为什么 <code>t2</code> 会先执行呢?理由如下:

实践中要确保 onFulfilled 和 onRejected 方法异步执行,且应该在 <code>then</code> 方法被调用的那一轮事件循环之后的新执行栈中执行

<code>Promise.resolve</code> 方法允许调用时不带参数,直接返回一个<code>resolved</code> 状态的 <code>Promise</code> 对象。立即 <code>resolved</code> 的 <code>Promise</code> 对象,是在本轮“事件循环”(event loop)的结束时,而不是在下一轮“事件循环”的开始时。

<a href="https://link.juejin.im/?target=http%3A%2F%2Fes6.ruanyifeng.com%2F%23docs%2Fpromise%23Promise-resolve" target="_blank">es6.ruanyifeng.com/#docs/promi…</a>

所以,<code>t2</code> 比 <code>t1</code> 会先进入 microtask 的 <code>Promise</code> 队列。

<a href="https://link.juejin.im/?target=http%3A%2F%2Fwww.ruanyifeng.com%2Fblog%2F2014%2F10%2Fevent-loop.html" target="_blank">JavaScript 运行机制详解:再谈Event Loop</a>

<a href="https://link.juejin.im/?target=https%3A%2F%2Fzhuanlan.zhihu.com%2Fp%2F33087629" target="_blank">Event Loop的规范和实现</a>

<a href="https://link.juejin.im/?target=http%3A%2F%2Fes6.ruanyifeng.com%2F%23docs%2Fpromise%23Promise-resolve" target="_blank">Promise-resolve</a>

<a href="https://link.juejin.im/?target=http%3A%2F%2Fwww.ituring.com.cn%2Farticle%2F66566" target="_blank">Promises/A+规范</a>

<a href="https://link.juejin.im/?target=http%3A%2F%2Fwww.ituring.com.cn%2Farticle%2F66566" target="_blank"></a>

继续阅读