理解 JavaScript 的异步编程——事件循环

84 阅读4分钟

什么是事件循环

在讲解事件循环之前,我们得先知道一个事实,那就是 JavaScript 是一门单线程的编程语言。

这意味着在任何时刻,JavaScript 引擎只能执行一个任务。其它的任务只能等待,这就是为什么如果我们有一个很耗时的任务(比如大量的计算或者网络请求),会阻塞后面的代码执行。

单线程的 JavaScript,一次只能做一件事。

但有时候,你可能需要进行一些很耗费时间的工作,比如从服务器获取数据。

这种情况下,你不喜欢这个操作阻塞其他操作,所以你会把它放在背后运行。这就是所谓的异步操作。

调用堆栈(Call Stack)和任务队列(Task Queue)

首先,调用堆栈(Call Stack)。这是 JavaScript 用来管理函数调用的地方。

这其实就是一个存储函数调用的地方。当一个函数被调用时,它就会被添加到栈的顶部。当这个函数完成时,它就会从栈的顶部移除。这就是所谓的"后进先出"。

这就是为什么我们说 JavaScript 是 "单线程" 的,因为在任何一个时间点,堆栈中只会有一个函数在执行。

主线程中的任务(即同步任务)都是在调用栈中执行的。

JavaScript 引擎遇到一个任务,比如一个函数调用,就会为这个任务创建一个执行上下文并压入调用栈,然后开始执行,这些都是同步操作,即它们会立刻开始执行,并按照被调用顺序执行。

现在来看任务队列(Task Queue)也被称为事件队列。

任务队列也叫消息队列或事件队列,是用来处理异步任务的。

比如 setTimeout、Promise、Ajax等操作,当定时器的时间到了,它的回调函数并不会立刻执行,而是会被放入任务队列中等待执行。其他的异步事件,比如 DOM 事件、网络请求的回调等,也都会被放到这个任务队列中等待执行。

任务队列是一个等待JavaScript引擎处理的任务列表。只有当调用栈中没有任务时,JavaScript引擎才会查看任务队列。如果队列中有任务,引擎会取出一个任务并运行。

宏任务与微任务

在 JavaScript 中,任务队列可以分为两类任务:宏任务(MacroTask)和微任务(MicroTask)。这两类任务最大的区别在于他们被执行的时间。

宏任务(MacroTask):包括整体代码的 script,setTimeout,setInterval,setImmediate(Node.js 环境),I/O,UI rendering 等。

浏览器为了能够使得 JS 内部(task)任务与 DOM 任务能够有序的执行,会在一个宏任务执行结束后,在下一个宏任务开始前,对页面进行重新渲染。

微任务(MicroTask):Promise.then(或者catch、finally),process.nextTick(Node.js 环境),MutationObserver(浏览器的 MutationObserver API)等。


最后,我们整合一下,看看这些是如何在事件循环中工作的:

  1. JavaScript主线程首先处理所有在调用栈中的同步任务。
  2. 当调用栈为空时,JavaScript查看是否有微任务需要处理。如果有,它会处理所有微任务,直到微任务队列为空。
  3. 在微任务队列为空之后,JavaScript查看是否有宏任务。如果有,它会处理一个宏任务(注意是一个,不是全部),然后返回第二步,检查微任务队列。
  4. 微任务队列和宏任务队列都为空时,JavaScript引擎进入等待状态,直到新的任务到来。

当JavaScript运行时遇到异步任务时,它并不会立刻执行这个任务,而是将它挂起,继续执行下面的同步任务。这个异步任务会被放入任务队列中。

这时,我们需要注意,异步任务被放入任务队列的时间和方式取决于这个异步任务是微任务还是宏任务。

如果是微任务(如Promise),它会被立刻放入微任务队列。而如果是宏任务(如setTimeout),它会被放入宏任务队列。

当所有同步任务执行完毕,调用栈被清空后,JavaScript引擎开始查看微任务队列,如果有任务,就执行所有的微任务。

一旦微任务队列为空,引擎会查看宏任务队列,取出一个宏任务执行。注意只执行一个宏任务,然后再次查看微任务队列,执行所有微任务。然后再执行一个宏任务,如此往复。

也就是说,JavaScript引擎在每次执行完一个宏任务后,都会去检查并执行所有的微任务,然后再执行下一个宏任务。这就是事件循环的工作机制。