一文带你搞懂浏览器的事件循环机制!

2023-11-21 08:47:25 浏览数 (1)

什么是事件循环

Event Loop 也叫做“事件循环”,它其实与 JavaScript 的运行机制有关,乍一看云里雾里,不用着急,读完本文你便会知晓它的含义,这一切都要从 JavaScript 的初始设计说起。

并发模型

JavaScript 的并发模型是基于事件循环机制的,这个机制被称为 Event Loop。它是一种单线程的执行模型,但是它可以通过异步编程来支持并发操作,从而实现高效的非阻塞 IO 操作。

在 JavaScript 中,所有的代码都是在同一个线程中执行的,这个线程被称为主线程或 UI 线程。当我们执行一段耗时较长的代码时,如果不采用异步编程的方式,那么这段代码将会阻塞主线程,导致整个应用程序变得不可响应。

为了避免这种情况,JavaScript 引入了异步编程的概念。异步编程使用回调函数、Promise、async/await 等方式来实现,它允许我们在主线程上同时处理多个任务,而不必等待任务完成。

在 JavaScript 中,异步任务通常被分为两类:宏任务和微任务 宏任务包括:

  • setTimeout
  • setInterval
  • I/O 操作等,

微任务则包括:

  • Promise
  • MutationObserver 等。

当主线程执行完当前的宏任务后,就会检查是否有微任务需要执行,如果有,则先执行微任务,然后再执行下一个宏任务。

JavaScript 的并发模型基于事件循环机制,它通过异步编程来实现高效的非阻塞 IO 操作。在 JavaScript 中,异步任务被分为宏任务和微任务,它们的执行顺序是由事件循环机制控制的。通过合理地使用异步编程,我们可以在单线程的 JavaScript 中实现高效的并发操作。

单线程

进程和线程是操作系统中的概念,在操作系统中,一个任务就是一个进程,比如你在电脑上打开了一个浏览器来观看视频,便是打开了一个浏览器进程,此时又想记录视频中的重要信息,于是你打开了备忘录,这便是一个备忘录进程,系统会为每个进程分配它所需要的地址空间,数据,代码等系统资源。如果把一个进程看做一个小的车间,车间里有很多工人,有的负责操作机器,有的负责搬运材料,每个工人可以看做一个线程,线程可以共享进程的资源。可以说,线程是进程的最小单位,一个进程可以包含多个线程。

JavaScript 在设计之初便是单线程,程序运行时,只有一个线程存在,在特定的时候只能有特定的代码被执行。这和 JavaScript 的用途有关,它是一门浏览器脚本语言,通常是用来操作 DOM 的,如果是多线程,一个线程进行了删除 DOM 操作,另一个添加 DOM,此时该如何处理?所以 JavaScript 在设计之初便是单线程的。

虽然 HTML5 增加了 Web Work可用来另开一个线程,但是该线程仍受主线程的控制,所以 JavaScript 的本质依然是单线程。

执行栈和任务队列

单线程的 JavaScript 一段一段地执行,前面的执行完了,再执行后面的,试想一个,如果前一个任务需要执行很久,比如接口请求、I/O 操作,此时后面的任务只能干巴巴地等待么?干等不仅浪费了资源,而且页面的交互程度也很差。JavaScript 意识到了这个问题,他们将任务分成了同步任务和异步任务,对于二者有不同的处理。

栈 Stack

函数调用形成了一个由若干帧组成的栈。

代码语言:javascript复制
function foo(b) {
  let a = 10;
  return a   b   11;
}

function bar(x) {
  let y = 3;
  return foo(x * y);
}

console.log(bar(7)); // 返回 42

当调用 bar 时,第一个帧被创建并压入栈中,帧中包含了 bar 的参数和局部变量。当 bar 调用 foo 时,第二个帧被创建并被压入栈中,放在第一个帧之上,帧中包含 foo 的参数和局部变量。当 foo 执行完毕然后返回时,第二个帧就被弹出栈(剩下 bar 函数的调用帧)。当 bar 也执行完毕然后返回时,第一个帧也被弹出,栈就被清空了

堆 Heap

对象被分配在堆中,堆是一个用来表示一大块(通常是非结构化的)内存区域的计算机术语。

在计算机科学中,堆(Heap)是一种常见的数据结构。它是一个特殊的完全二叉树(或者可以看作是一个数组),其中每个节点都满足堆属性。

堆通常用于实现优先队列(Priority Queue)和动态的、可高效地找到最大或最小元素的数据结构。

根据堆属性的不同,堆分为两种类型:

  1. 最大堆(Max Heap):在最大堆中,每个节点的值都大于或等于其子节点的值。这意味着堆的根节点具有最大的值。
  2. 最小堆(Min Heap):在最小堆中,每个节点的值都小于或等于其子节点的值。这意味着堆的根节点具有最小的值。

堆的主要操作包括插入和删除操作:

  • 插入操作:将一个新元素插入堆中时,需要保持堆属性。具体操作是将元素添加到堆的末尾,然后通过与父节点比较并交换位置的方式向上调整堆,直到满足堆属性。
  • 删除操作:删除堆顶元素时,也需要保持堆属性。具体操作是将堆顶元素与堆的最后一个元素交换位置,然后删除堆的最后一个元素。接着,通过与子节点比较并交换位置的方式向下调整堆,直到满足堆属性。

堆的插入和删除操作的时间复杂度都是 O(log n),其中 n 是堆中元素的数量。这使得堆非常适合用于需要频繁地插入和删除元素的场景。

值得注意的是,堆不是按照某种特定的排序顺序来排列元素的,而是确保根节点具有最大或最小的值。因此,除了找到最大或最小元素外,堆中的其他元素之间并没有特定的顺序关系。

总结起来,堆是一种用于实现优先队列和高效查找最大或最小元素的数据结构。它具有快速的插入和删除操作,并且可以根据需要实现最大堆或最小堆。

队列 Queue

一个 JavaScript 运行时包含了一个待处理消息的消息队列。每一个消息都关联着一个用以处理这个消息的回调函数。

在 事件循环期间的某个时刻,运行时会从最先进入队列的消息开始处理队列中的消息。被处理的消息会被移出队列,并作为输入参数来调用与之关联的函数。正如前面所提到的,调用一个函数总是会为其创造一个新的栈帧。

函数的处理会一直进行到执行栈再次为空为止;然后事件循环将会处理队列中的下一个消息(如果还有的话)。

JavaScript 运行时

JavaScript 在运行时会将变量存放在堆(heap)和栈(stack)中,堆中通常存放着一些对象,而变量及对象的指针则存放在栈中。JavaScript 在执行时,同步任务会排好队,在主线程上按照顺序执行,前面的执行完了再执行后面的,排队的地方叫执行栈(execution context stack)。JavaScript 对异步任务不会停下来等待,而是将其挂起,继续执行执行栈中的同步任务,当异步任务有返回结果时,异步任务会加入与执行栈不一样的队列,即任务队列(task queue),所以任务队列中存放的是异步任务执行完成后的结果,通常是回调函数。

当执行栈的同步任务已经执行完成,此时主线程闲下来,它便会去查看任务队列是否有任务,如果有,主线程会将最先进入任务队列的任务加入到执行栈中执行,执行栈中的任务执行完了之后,主线程便又去任务队列中查看是否有任务可执行。主线程去任务队列读取任务到执行栈中去执行,这个过程是循环往复的,这便是 Event Loop,事件循环。

网上有张流传甚广的图对这一过程进行了总结,在图中我们可以看到,JavaScript 在运行时产生了堆和栈,ajax、setTimeout 等异步任务被挂起,异步任务的返回结果加入任务队列,主线程会循环往复地读取任务队列中的任务,加入执行栈中执行。

为了更好的理解 JavaScript 的执行机制,我们来看个小例子。

代码语言:javascript复制
console.log(1)

setTimeout(function() {

console.log(2)

}, 300)

console.log(3)

输出的结果是 1,3,2。setTimeout 是一个定时器,延迟 300 毫秒执行,所以 300 毫秒后,打印 2 的回调函数才会进入任务队列,等到执行栈中的代码执行完成后,也就是打印出 1 和 3 后,打印出 2 的回调函数才进入执行栈执行。

如果将 setTimeout 的第二个参数设置为 0,它表示主线程空闲之后尽早执行它的回调,HTML5 规定 setTimeout 的第二个参数不得小于 4 毫秒。

代码语言:javascript复制
setTimeout(function() {

console.log(1)

}, 0)

console.log(2)


// 2,1

对于 setTimeout 还有一个需要注意的是,它的延迟时间并不是等待多少毫秒后就一定会执行,始终是要等待主线程已经空闲了才会去读取它,如果执行栈中的任务需要很长时间才能执行完,那任务队列中的任务只能等待。我们可以通过一个例子来体验一下。

代码语言:javascript复制
var enterTime = Date.now()




function sleep(time) {

for(var temp = Date.now(); Date.now() - temp <= time;);

}




setTimeout(function() {

var exeTime = Date.now()

console.log(exeTime - enterTime)

}, 300)




sleep(1000) // 睡眠 1 秒

我们定义了一个 sleep 函数,设置了 1 秒的执行时间,所以 setTimeout 要等待的时间肯定大于 1 秒,而不是 300 毫秒后就执行了。上述代码的执行结果是 1000 左右,值不固定,可以复制代码到控制台执行看看。

宏任务与微任务

异步任务有更深一层的划分,它们是宏任务(macro task)和微任务(micro task),二者的执行顺序也有差别。在上面我们讲到异步任务的结果会进入任务队列中,对于不同的事件类型,宏任务会加入宏任务队列,微任务会加入微任务队列。

常见的宏任务有 script(整体代码),setTimeout,setInterval;常见的微任务有 new Promise、process.nextTick(node.js 环境)。

在执行栈空的时候,主线程会从任务队列中取任务来执行,其过程如下: 1.选择最先进入队列的宏任务执行(最开始是 script 整体代码) 2.检查是否存在微任务,如果存在,执行微任务队列中得所以任务,直至清空微任务队列 3.重复以上步骤

我们来通过代码体验一下宏任务与微任务的执行顺序。

代码语言:javascript复制
console.log(1)

setTimeout(function() {

console.log(2)

new Promise(function(resolve) {

console.log(3)

resolve(4)

}).then(function(num) {

console.log(num)

})

}, 300)


new Promise(function(resolve) {

console.log(5)

resolve(6)

}).then(function(num) {

console.log(num)

})

setTimeout(function() {

console.log(7)

}, 400)

我们一步步来分析上面的执行顺序,这段代码作为宏任务进入主线程开始执行,首先打印出 1,然后遇到了 setTimeout,主程序将它挂起,300 毫秒后它的回调函数进入宏任务队列,我们记做 setTimeout1。随后遇到了 new Promise,resolve 部分是同步执行的,所以会打印出 5,then 中的回调函数进入微任务队列,我们暂时记做 promise1。最后是 setTimeout,同理在 400 毫秒后加入了宏任务队列,我们记做 setTimeout2。此时任务队列的情况如下:

宏任务

微任务

setTimeout1

promise1

setTimeout2

此时已经执行完一个宏任务(script 整体代码),接着主线程查看微任务队列,发现存在微任务,于是把 promise1 执行了,打印出 6。此时微任务队列已经空了,任务队列的情况如下:

宏任务

微任务

setTimeout1

setTimeout2

以上便是一次循环。

接着主线程又开始查看宏任务队列,将 setTimeout1 的回调函数加入任务栈开始执行,于是首先打印出 2,之后是 3,再将 then 中的回调函数加入微任务队列,我们记做 promise2。此时任务队列的情况如下:

宏任务

微任务

setTimeout2

promise2

此时执行栈也空了,于是将微任务 promise2 加入执行栈,打印出 4。此时微任务已经执行完,这便完成了第二次循环。然后再查看宏任务队列,于是执行 setTimeout2,打印出 7。所以代码中的输出顺序是 1,5,6,2,3,4,7。需要注意的是,主线程对微任务的读取是逐个读取,直到微任务队列为空。对宏任务队列的读取在一次循环中只读取一个。

小结

在本节中,我们了解了 JavaScript 的运行机制,它是单线程的。JavaScript 中的任务可分为同步任务和异步任务,同步任务总是先进入执行栈中执行,异步任务会被挂起,直到有结果返回时,异步任务会进入任务队列中等待主线程读取执行。当执行栈为空时,主线程便会循环往复地读取任务队列中的事件,进入执行栈执行,这个过程叫 Event Loop。主线程对任务队列的读取也有先后之分,首先会读取宏任务,最开始是 script 整体代码,执行完一个宏任务后,会去查找微任务,将微任务队列的事件都执行完,这个过程也是循环往复的。 所以本篇我主要讲了:

  • JavaScript 是单线程的本质;
  • 执行栈和任务队列是什么;
  • 什么是 Event Loop;
  • 宏任务和微任务的区别。

0 人点赞