Skip to content

event-loop-process

JavaScript的事件循环机制

事件循环是什么呢?

简单来讲就是在 等待任务执行任务休眠状态 这几个状态之间转换的无限循环。

我们先讲讲为什么会有事件循环这一机制。

由于JavaScript的主要用途是与用户互动,以及操作DOM,所以使用一个线程来处理任务是最佳方案。如果使用多线程来处理任务,会有一些不可控的执行流程,假设我们现在有两个线程同时操作一个DOM,一个是删除这个DOM,一个是需要操作这个DOM,我们没有办法去识别以哪一个线程的操作为优先处理。

使用单线程的话也会有一个问题,假设我们需要执行一个文件读取的任务,而这个文件也很大,需要等待一分钟的时间才能读取完成,在此期间我们的网页无法进行任何的操作,因为鼠标移动和滚动网页也是在执行任务,而我们现在只能等待文件读取任务完成,才能去执行鼠标移动和滚动网页。

这肯定是用户所不能忍受的效果,所以JavaScript又将任务(stack)区分成了 同步(synchronous)和异步(asynchronous)两种任务类型。而前面我们也说了JavaScript是一个单线程的任务,所以无法同时执行同步任务和异步任务,所以就需要事件循环这一机制。

在异步任务中,又区分宏任务和微任务至于为什么会区分这两种任务,我们在下文中会讲述。

那接下来我们先去了解一下,事件循环由那些部分组成。

组成事件循环的三部分

事件循环由 调用堆栈、事件队列/回调队列和WebApi这三个部分组成。

1、 调用堆栈也叫调用栈(call stack)是追踪函数执行流的一种机制。 当执行环境中调用了多个函数时,通过这种机制,我们能够追踪到那个函数正在执行,执行的函数又调用了那个函数。

  • 每调用一个函数,解释器会把该函数添加进调用栈并开始执行。
  • 正在调用栈中执行的函数还调用了其他函数,那么新函数也会被添加进调用栈,一旦这个函数被调用,便会立即执行。
  • 当前函数执行完毕后,解释器将其清除调用栈,继续执行当前执行环境下的剩余代码。
  • 当分配的调用栈空间被占满时,会引发堆栈溢出错误。

2、事件队列(event queue)和回调队列(callback queue)都是在JavaScript运行时包含了一个待处理消息的消息队列。每一个消息都关联着一个用以处理这个消息的回调函数。在事件循环期间的某个时刻,运行时会从最先进入队列的消息开始处理队列中的消息。被处理的消息会被移除队列,并作为输入参数来调用与之关联的函数。

3、WebApi是浏览器提供的一套操作浏览器功能和页面元素的接口(在NodeJs的环境下就是,nodejs提供的一系列系统操作的接口)。我们在编写任何逻辑时,最后都会去执行WebApi。也可以将这一部分理解为服务提供者,没有这一部分的服务提供,所有的逻辑都是伪代码。

事件循环的执行流程

上文中,我们也了解了事件循环由那些部分组成。接下来我们去了解一下执行流程。

假设我们有下列代码:

JavaScript
console.log("start run!")

function fn(){
  console.log("run fn")
  setTimeout(() => {
    console.log("run set timeout")
  })
}

fn()

console.log("over run?")

正确的输出顺序会是怎样的?我们先来分析这段代码将会按照什么流程依此进入到调用栈中。

  1. 第一个执行的log是一个同步任务,会直接进入调用栈中,执行完成后执行下一个任务。
  2. 第二个执行的fn函数,也是一个同步任务,会直接进入调用栈中,执行内部逻辑。
  • 执行内部log,进入调用栈,执行完成移出执行下一块代码。
  • 执行内部setTimeout,由于是异步任务,不进入调用栈。执行下一块代码(如果有)。
    • setTimeout进入异步任务处理机制。
    • 2.1 注册事件表,进入事件队列。
    • 2.2 事件执行完成,移出事件队列,将事件的回调函数移动到回调队列,等待调用栈调用。
  • 内部函数执行完成。移除fn。执行下一块代码。
  1. 第三个执行的log,直接进入调用栈,执行完成移除。
  2. 调用栈已清空,执行回调队列中的函数。
  • 依次将队列的函数,移入到执行栈执行,执行完成,移出回调队列。
  • 回调函数中还有异步任务,同上异步任务处理机制,然后等待执行栈清空,再执行回调队列中的函数。

上述流程就是事件循环的执行流程。文字描述有点太绕来绕去了,我们看下列中的流程图来对照理解会好理解的多。 event-loop-process

总结一下就是,我们运行这一块代码的时候,最终都会调用WebApi,然后js引擎去区分任务类型,同步任务直接移入到调用栈中执行,异步任务需要先注册事件表然后移入到事件队列,等待完成后将事件回调移入进回调队列,等待调用栈清空后去查回调队列中是否有需要执行的回调,如果有则移入到调用栈中执行,没有则进入休眠状态,等待下一次任务然后再去调用栈空检查是否又任务执行,没有则继续查看回调队列是否有回调,然后无限循环,这就是事件循环。

TIP

请注意这里的调用栈(call stack)和执行栈(execute stack)在讲述上来说作用都是相同的,如果需要严格区分的话,调用栈是指追踪到当前运行的代码在那个函数内或在代码块的那一行。执行栈就单单只是执行这一任务的栈。

同步任务和异步任务

同步任务是非耗时的任务,直接在主线程的执行栈中运行。
异步任务是耗时的任务,会进入任务队列中去,等待所有同步任务执行完成后才会开始执行。

  • 同步任务
    发起调用后会直接等待执行完成,在此期间并不会有其他任务插入进来执行。
  • 异步任务 发起调用后不会直接等待执行完成,在此期间会先完成后续任务,等待执行栈中的同步任务清空后,回去检查异步任务的回调是否存在于回调队列中,如果有则开始执行这个回调。

关于同步任务没有太多笔墨需要去讲的,只需要清楚同步任务在调用后会理解得出结果即可,而不像异步任务需要去等待。

异步任务内部又分两种类型,一种是正常异步任务回调,一种是优先级高于正常异步任务,需要抢先去执行,这两种任务分别为宏任务微任务

宏任务队列 (macro task)
微任务队列 (micro task)

下面则是宏任务和微任务的一些WebApi

  • 宏任务

    • <script> 标签中的运行代码
    • 事件触发的回调函数,例如: DOM EventI/OrequestAnimationFrame
    • setTimeoutsetInterval两种定时器的回调函数
  • 微任务

    • promise: Promise.thenPromise.catchPromise.finally
    • MutationObserver
    • queueMicrotask
    • process.nextTick nodejs独有

TIP

需要注意在node环境中process.nextTick的优先级最高。

我们接下来看看异步任务中,宏任务和微任务的执行流程,还是先看一段代码来总结输出顺序。

JavaScript
  setTimeout(() => {
    console.log("first")
  })

  new Promise((resolve) => {
    resolve("two")
  }).then(data => {
    console.log(data)
  }).finally(() => {
    console.log("success!")
  })

  setTimeout(() => {
    console.log("three")
  })

我们来依次分析这段代码的执行流程是怎样的。

  1. 第一个setTimeout,会压入宏任务队列
  2. 第二个Promise是同步任务会直接压入执行栈进行
    1. then方法是微任务,进入微任务队列
    1. finally方法是微任务,进入微任务队列
  1. 第三个setTimeout,压入宏任务队列

至此,所有需要执行的代码都已经入栈或入队。

除了同步任务Promise直接进入执行栈执行,剩下的四个方法都依次进入宏任务队列或微任务队列。 等待Promise执行完成后,当前执行栈为空。

宏任务和微任务都是异步任务,是异步任务就会注册事件表,然后进入事件队列,内部再区别宏任务队列和微任务队列,此时队列内图如下:

event-loop-process

根据图示,我们在将第一个定时器的回调压入回调队列前,应当将微任务队列内的thenfinally的回调函数依次压入回调队列,然后再将第一个第二个定时器的回调函数压入回调队列。

此时回调队列内应该如下:

// 回调队列
=========
setTimeout
setTimeout
finally 
then
=========

然后执行栈依次取出回调函数进行执行。

最后输出结果是: 'two' -> 'success!' -> 'first' -> 'three'

TODO: 待完善

Chasing the Wind!