JS 引擎本身不实现事件循环机制,这是由它的宿主实现的

JS异步执行原理

JS 是单线程的,同一个时刻只能做一件事情。指的是执行 JS 代码的线程只有一个

比如,浏览器 JS 异步执行

  • 浏览器是多线程的,执行 JS 代码线程的是浏览器提供的 JS 引擎线程(主线程)
  • JS 需要执行异步任务时,浏览器会另外启动一个线程去执行该任务,即HTTP请求线程

比如主线程中需要发一个 Ajax 请求,就把这个任务交给另一个浏览器线程(HTTP 请求线程)去真正发送请求,待请求回来了,再将 callback 里需要执行的 JS 回调交给 JS 引擎线程去执行。即浏览器才是真正执行发送请求这个任务的角色,而 JS 只是负责执行最后的回调处理。所以这里的异步不是 JS 自身实现的,其实是浏览器为其提供的能力。

事件循环

组成部分

JavaScript 的事件循环机制包括几个主要的组成部分:

  1. 执行栈(Call Stack):存放当前执行的代码函数同步代码依次进入执行栈。
  2. 宏任务队列(Macro Task Queue):存放所有待处理的宏任务
    • setTimeout()setInterval()setImmediate()(在Node.js中)
    • 事件处理程序(如 onclick)等
    • I/O 操作等
  3. 微任务队列(Microtask Queue):存放微任务
    • Promise:通过Promise设置的异步任务,如 Promise 的 then()catch() 回调
    • async/await:在异步函数中通过await关键字等待的异步任务。
    • MutationObserver:用于观察DOM树变化的异步任务。

宏任务特征:有明确的异步任务需要执行和回调;需要其他异步线程支持。

微任务特征:没有明确的异步任务需要执行,只有回调;不需要其他异步线程支持。

原理解析

JS 在解析一段代码时,会将同步代码按顺序排在某个地方,即执行栈,然后依次执行里面的函数。当遇到异步任务时交给其他线程处理,待当前执行栈所有同步代码执行完成后,会从一个队列中去取出已完成的异步任务的回调加入执行栈继续执行,遇到异步任务时又交给其他线程,…..,如此循环往复。而其他异步任务完成后,将回调放入任务队列中待执行栈来取出执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
console.log('同步代码1');

setTimeout(() => {
console.log('setTimeout')
}, 0)

new Promise((resolve) => {
console.log('同步代码2')
resolve()
}).then(() => {
console.log('promise.then')
})

console.log('同步代码3');

/** 最终输出
"同步代码1"
"同步代码2"
"同步代码3"
"promise.then"
"setTimeout"
*/
  • 异步宏任务的处理

    • JS 遇到异步任务时会将此任务交给其他线程去处理,自己的主线程继续往后执行同步任务。
    • 比如 setTimeout() 的计时会由浏览器的定时器线程来处理,待计时结束,就将定时器回调任务放入任务队列等待主线程来取出执行。
  • 微任务的处理

  • 当执行到 promise.then() 时,V8 引擎不会将异步任务交给浏览器其他线程,而是将回调存在自己的一个队列中,待当前执行栈执行完成后,立马去执行 promise.then() 存放的队列。

  • promise.then() 微任务没有多线程参与, 甚至从某些角度说,微任务都不能完全算是异步,它只是将书写时的代码修改了执行顺序而已。

setTimeout() 有“定时等待”这个任务,需要定时器线程执行

ajax 请求有“发送请求”这个任务,需要 HTTP 线程执行

promise.then() 它没有任何异步任务需要其他线程执行,它只有回调,即使有,也只是内部嵌套的另一个宏任务。

定时器误差

当执行 setTimeout() 时,浏览器启动新的线程去计时(与主线程并行),计时结束后触发定时器事件将回调存入宏任务队列,等待 JS 主线程来取出执行。

  • 如果这时主线程还在执行同步任务的过程中,那么此时的宏任务就只有先挂起,这就造成了计时器不准确的问题。
  • 同步代码耗时越长,计时器的误差就越大
  • 由于微任务会优先执行,所以微任务也会影响计时
  • 假设同步代码中有一个死循环或者微任务中递归不断在启动其他微任务,那么宏任务里面的代码可能永远得不到执行。所以主线程代码的执行效率提升是一件很重要的事情。
1
2
3
4
5
6
7
8
9
10
11
12
setTimeout(() => {
console.log('setTimeout')
}, 2000)

/**
......
其他同步代码
其他微任务
......
【共耗时3s】
*/
// 那么,setTimeout 将在 5s 后输出,而不是 2s

Click事件中的宏任务和微任务

代码如下

1
2
3
<body>
<button id="button">点击</button>
</body>
1
2
3
4
5
6
7
8
9
10
11
12
13
const buttonElement = document.querySelector("#button");
buttonElement.addEventListener("click", function funcA() {
queueMicrotask(() => {
console.log("queueMicrotask 1");
});
console.log("1");
});
buttonElement.addEventListener("click", function funcB() {
queueMicrotask(() => {
console.log("queueMicrotask 2");
});
console.log("2");
});

任务队列

  • click事件是宏任务
  • funcAfuncB 作为click事件的回调函数,也都是宏任务的一部分
  • queueMicrotask 用于将微任务(microtask)添加到微任务队列中。

微任务队列是在当前宏任务(例如 click 事件 的处理)完成后,下一次事件循环迭代之前执行的任务。

微任务会监听回调之间的执行

click 事件场景

首先我们有两种场景:

  • 第一种就是用户点击页面上的button按钮,触发click事件;

    • 场景一(用户点击button按钮)输出如下:1 -> queueMicrotask 1 -> 2 -> queueMicrotask 2
  • 第二种就是使用js代码,触发button按钮的click事件,也就是执行buttonElement.click()这行代码。然而,这两种场景下的输出却是不一样的。

    • 场景二(使用js代码触发click事件)v

场景一:用户点击button按钮

funcAfuncB串行执行的,先执行 funcA ,然后再执行 funcB 的。

执行流程如下:

  • 执行 funcA
    • queueMicrotask是微任务,将任务推到微任务队列
    • console.log("1") 是同步任务,放到主执行栈中执行,控制台中输出 1
  • funcA 执行完毕(也就是一次宏任务执行完毕),此时需要检查微任务队列中是否存在微任务,有就需要把所有微任务执行完毕,再开始执行下一轮宏任务(也就是执行 funcB )。此时就会执行微任务,控制台中输出 queueMicrotask 1
  • 执行 funcB:和 funcA 流程一致。

输出如下:1 -> queueMicrotask 1 -> 2 -> queueMicrotask 2

场景二:(使用js代码触发click事件)

使用js代码触发click事件的时候,虽然有 funcAfuncB 两个宏任务,但是由于此时 click 事件是同步分发的,导致微任务监听不到回调的执行,微任务会在监听器之后执行

  • 由于click事件的回调函数是同步执行的,即在当前的事件循环中,funcAfuncB会被依次执行,并且它们之间不会插入其他宏任务或微任务。即使在funcAfuncB中注册了微任务,这些微任务也只能在整个点击事件的回调函数执行完之后(即funcAfuncB都执行完)才会开始执行。

  • 简单来说 funvcAfuncB 在执行的时候,浏览器会把他们打包在一起,当做一个宏任务去执行。实际上代码可以理解为如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    function funcA() {
    queueMicrotask(() => {
    console.log("queueMicrotask 1");
    });
    console.log("1");
    }

    function funcB() {
    queueMicrotask(() => {
    console.log("queueMicrotask 2");
    });
    console.log("2");
    }

    funcA();
    funcB();

执行流程如下:

  • 执行funcA
    • queueMicrotask是微任务,将任务推到微任务队列
    • console.log("1")是同步任务,放到主执行栈中执行,控制台中输出1
  • funcA执行完毕,因为后面还有代码(一个宏任务还没执行完毕),还需要继续往下面执行,不会去执行微任务队列中的微任务(跟场景一的区别)
  • 执行funcB
    • queueMicrotask是微任务,将任务推到微任务队列
    • console.log("2")是同步任务,放到主执行栈中执行,控制台中输出2
  • funcB执行完毕,一次宏任务执行完毕。执行微任务,控制台分别输出queueMicrotask 1queueMicrotask 2

输出如下:1 -> 2 -> queueMicrotask 1 -> queueMicrotask 2