事件循环机制

事件循环机制(Event Loop)是 JavaScript 中处理异步操作的核心机制。由于 JavaScript 是单线程语言,它需要通过事件循环机制来协调同步任务和异步任务的执行,使得程序能够以非阻塞的方式处理 I/O 操作、定时器、用户交互等异步事件。

1. JavaScript 执行模型

JavaScript 的执行分为两种任务:

  • 同步任务:直接在主线程上排队执行,只有当前任务完成后,才能执行下一个任务。
  • 异步任务:不会立即执行,异步操作将注册回调,当异步任务完成后,回调函数会被放入任务队列中,等待主线程空闲时再执行。

2. 事件循环的核心概念

事件循环的工作流程可以概括为以下步骤:

  1. 执行所有同步任务:首先执行全局代码中的同步任务。
  2. 检查异步任务队列:主线程空闲后,检查是否有异步任务完成并将其回调函数放入任务队列。
  3. 执行任务队列中的回调:按照顺序从任务队列中取出回调函数,并将其执行。
  4. 重复步骤 2 和 3:不断重复此过程,以确保事件能够持续处理。

3. 任务队列类型

任务队列(任务队列或消息队列)分为两类:

  • 宏任务队列(Macro Task Queue):包括整体代码(script)、setTimeoutsetIntervalI/O 操作、事件监听等。
  • 微任务队列(Micro Task Queue):包括 Promise.then()process.nextTick()MutationObserver 等。

事件循环在每一轮中都会优先执行所有的微任务队列,然后再执行一个宏任务。

4. 事件循环的执行顺序

事件循环的执行顺序非常关键,遵循以下流程:

  1. 同步代码先执行:主线程上所有同步任务先按顺序执行。
  2. 执行所有的微任务:当同步代码执行完后,立即执行微任务队列中的所有微任务,直到微任务队列清空。
  3. 执行一个宏任务:接着执行宏任务队列中的第一个宏任务。
  4. 循环往复:每次宏任务执行完后,都会进入微任务队列,优先执行微任务,再回到宏任务,形成循环。

5. 宏任务与微任务的示例

下面通过示例展示宏任务和微任务的执行顺序:

1
2
3
4
5
6
7
8
9
10
11
console.log('1'); // 同步任务

setTimeout(() => {
console.log('2'); // 宏任务
}, 0);

Promise.resolve().then(() => {
console.log('3'); // 微任务
});

console.log('4'); // 同步任务

执行顺序解析:

  1. 首先执行同步任务,依次打印 '1''4'
  2. setTimeout 回调放入宏任务队列,并将 Promise.then 回调放入微任务队列。
  3. 执行完同步代码后,进入微任务阶段,执行微任务 Promise.then 回调,打印 '3'
  4. 最后进入宏任务阶段,执行 setTimeout 回调,打印 '2'

最终输出顺序为:

1
2
3
4
1
4
3
2

6. 示例详解:宏任务与微任务交替

为了更好地理解事件循环,可以再来看一个更复杂的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
console.log('A');

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

Promise.resolve().then(() => {
console.log('C');
});

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

Promise.resolve().then(() => {
console.log('E');
});

console.log('F');

执行顺序解析:

  1. 同步任务依次执行,打印 'A''F'
  2. 宏任务 setTimeout 的回调进入宏任务队列,而 Promise.then 的回调进入微任务队列。
  3. 同步任务执行完毕后,进入微任务阶段,执行所有微任务,依次打印 'C''E'
  4. 微任务执行完毕后,进入宏任务阶段,执行宏任务队列中的回调,依次打印 'B''D'

最终输出顺序为:

1
2
3
4
5
6
A
F
C
E
B
D

7. 浏览器中的事件循环

在浏览器环境中,事件循环与 UI 渲染之间也存在密切联系。每次宏任务执行结束后,浏览器有机会去渲染页面,所以页面的更新通常会在宏任务结束后执行,而不会在微任务中间插入。

8. Node.js 中的事件循环

在 Node.js 中,事件循环的机制与浏览器类似,但有一些区别。Node.js 的事件循环中有多个阶段,每个阶段都有不同类型的任务处理。例如:

  • timers 阶段:处理 setTimeoutsetInterval
  • I/O callbacks 阶段:处理系统操作的回调。
  • poll 阶段:检索新的 I/O 事件。
  • check 阶段:执行 setImmediate() 回调。
  • close 阶段:关闭回调处理。

每个阶段结束后,都会执行微任务队列(Promise.then()process.nextTick())。

9. 常见面试题分析

问题 1:

1
2
3
4
5
6
7
8
9
10
11
12
13
console.log('start');

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

Promise.resolve().then(() => {
console.log('promise1');
}).then(() => {
console.log('promise2');
});

console.log('end');

执行顺序

  1. 同步代码先执行,打印 'start''end'
  2. setTimeout 回调进入宏任务队列。
  3. Promise.then() 回调进入微任务队列。
  4. 执行微任务队列,打印 'promise1''promise2'
  5. 最后执行宏任务 setTimeout,打印 'setTimeout'

输出

1
2
3
4
5
'start'
'end'
'promise1'
'promise2'
'setTimeout'

总结:

  • 事件循环是 JavaScript 中处理异步任务的核心机制,它通过任务队列和微任务队列的配合实现非阻塞的异步操作。
  • 在每个事件循环周期中,先执行同步代码,然后执行微任务队列,最后执行宏任务队列中的一个宏任务。
  • 微任务(如 Promise.then())优先于宏任务(如 setTimeout())执行,这导致了在实际编写代码时,微任务通常会比宏任务先执行。

小测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
console.log('script start'); 

setTimeout(function() {
console.log('setTimeout');
}, 0);

new Promise((res,rej)=> {
console.log('promise')
rej()
}).then(function() {
console.log('promise1');
}).catch(function(){
return 1;
}).then(function() {
console.log('promise2');
});

console.log('script end');

让我们逐行分析这段代码的输出顺序:

  1. console.log('script start');:立即输出 'script start'
  2. setTimeout(..., 0);:这个回调会被放入宏任务队列,等待执行。
  3. new Promise(...);
    • 立即执行,输出 'promise'
    • 调用 rej(),进入 .catch()
  4. .catch(...):处理拒绝状态,返回 1,但没有输出(只是返回值)。
  5. 接下来的 .then(...):这个 then 会在微任务队列中被添加。
  6. console.log('script end');:立即输出 'script end'

接下来,微任务队列会先执行:
7. 输出 'promise2':因为上一个 then 返回了一个值。

最后,宏任务队列中的 setTimeout 回调执行:
8. 输出 'setTimeout'

所以,最终输出顺序为:

1
2
3
4
5
'script start'
'promise'
'script end'
'promise2'
'setTimeout'

事件循环机制
https://garlandqian.github.io/2024/09/24/Interview/js/事件循环机制/
作者
Garland Qian
发布于
2024年9月24日
许可协议