Skip to content

事件循环与异步机制 (Event Loop)

JavaScript 之所以能以单线程处理高并发、无阻塞 I/O,全靠事件循环 (Event Loop) 机制。

1. 为什么需要 Event Loop?

JavaScript 是单线程的(为了避免复杂的 DOM 同步问题)。

  • 如果所有任务都是同步的(Synchronous),遇到耗时操作(如网络请求、图片加载),整个页面就会卡死(阻塞)。
  • 为了解决这个问题,JS 引入了异步 (Asynchronous) 机制。
  • Event Loop 就是协调同步任务和异步任务的"交通指挥官"。

2. 宏任务与微任务

在深入流程之前,先区分两种异步任务队列。

2.1 宏任务 (MacroTask / Task)

宏任务是由宿主环境(浏览器 / Node.js)发起的任务。

  • script (整体代码)
  • setTimeout
  • setInterval
  • setImmediate (Node.js 独有)
  • I/O 操作 (UI 交互、网络请求完成)
  • requestAnimationFrame (有时归类为渲染任务)

2.2 微任务 (MicroTask)

微任务是由 JS 引擎(V8)自身发起的任务。

  • Promise.then / .catch / .finally
  • process.nextTick (Node.js 独有,优先级最高)
  • MutationObserver

记忆口诀:微任务是 VIP,每次干完一个宏任务,都要把所有排队的 VIP 微任务处理完,才能进行下一个宏任务。

3. Event Loop 执行流程

浏览器的 Event Loop 遵循以下步骤:

  1. 执行同步代码:从上到下执行调用栈(Call Stack)中的同步任务。
  2. 清空调用栈:同步代码执行完毕,调用栈清空。
  3. 执行微任务队列:取出微任务队列(MicroTask Queue)中的所有任务,依次执行,直到队列为空。(如果在执行微任务过程中产生了新的微任务,也会在这一轮里立刻执行掉)。
  4. 渲染页面:(如果有必要) 浏览器尝试进行 DOM 渲染更新。
  5. 执行一个宏任务:从宏任务队列(MacroTask Queue)中取出一个最早的任务入栈执行。
  6. 回到第 3 步:宏任务执行完后,再次检查微任务队列...

循环模型

Event Loop Start
    |
    v
[执行同步代码 (Script)]
    |
    v
[清空微任务队列 (All Microtasks)] <---+
    |                                 |
    v                                 |
[渲染 UI (Optional)]                  |
    |                                 |
    v                                 |
[取出一个宏任务执行 (One Macrotask)] -+

4. 实战代码分析

示例 1:基础顺序

javascript
console.log('1'); // 同步

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

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

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

输出1 -> 4 -> 3 -> 2

解析

  1. 执行同步 log('1')
  2. 遇到 setTimeout,放入宏任务队列。
  3. 遇到 Promise.then,放入微任务队列。
  4. 执行同步 log('4')
  5. 同步结束,检查微任务,执行 log('3')
  6. 微任务结束,检查宏任务,执行 log('2')

示例 2:微任务插队

javascript
setTimeout(() => {
    console.log('timer1');
    Promise.resolve().then(function() {
        console.log('promise1');
    });
}, 0);

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

输出timer1 -> promise1 -> timer2

解析

  1. 两个 setTimeout 进入宏任务队列:[timer1, timer2]
  2. 取出 timer1 执行,打印 timer1
  3. timer1 内部产生了一个微任务 promise1,放入微任务队列。
  4. timer1 宏任务结束。
  5. Event Loop 检查微任务队列,发现有 promise1,执行并打印。
  6. 微任务清空,开始下一个宏任务 timer2,打印 timer2

示例 3:复杂的嵌套

javascript
console.log('1');

setTimeout(() => {
    console.log('2');
    Promise.resolve().then(() => {
        console.log('3');
    });
});

new Promise((resolve, reject) => {
    console.log('4'); // Promise 构造函数是同步的!
    resolve(5);
}).then((data) => {
    console.log(data);
});

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

console.log('7');

输出1 -> 4 -> 7 -> 5 -> 2 -> 3 -> 6

解析

  1. log(1)
  2. 第一个 setTimeout (log(2)) 进宏队列。
  3. new Promise 构造器同步执行,log(4)
  4. resolve(5) 触发 .thenlog(5) 进微队列。
  5. 第二个 setTimeout (log(6)) 进宏队列。
  6. log(7)
  7. 同步结束
  8. 清微任务:执行 log(5)
  9. 取宏任务1:执行 log(2)。产生新微任务 log(3)
  10. 宏任务1结束,检查微任务。
  11. 清微任务:执行 log(3)
  12. 取宏任务2:执行 log(6)

5. async/await 在 Event Loop 中的表现

async/await 只是 Promise 的语法糖。

javascript
async function async1() {
    console.log('async1 start');
    await async2(); 
    // await 下面的代码相当于放在了 Promise.then 中,属于微任务
    console.log('async1 end');
}

async function async2() {
    console.log('async2');
}

async1();
console.log('script end');

输出async1 start -> async2 -> script end -> async1 end

解析

  1. 执行 async1(),打印 async1 start
  2. 执行 await async2(),调用 async2(),打印 async2
  3. await 会阻塞其后的代码,将其放入微任务队列。
  4. 跳出 async1,继续执行同步代码,打印 script end
  5. 同步结束,执行微任务,打印 async1 end

6. Node.js 与 浏览器的区别 (简略)

Node.js 的 Event Loop 基于 libuv,早期版本(< v11)在这方面与浏览器有很大不同。 但自 Node 11+ 起,Node.js 尽量向浏览器行为对齐:每执行完一个宏任务,就清空微任务队列

主要区别在于 process.nextTick

  • 在 Node.js 中,process.nextTick 的优先级高于 Promise.then
  • 它会在当前阶段(phase)结束后,下一个阶段开始前立即执行。

7. 总结

  1. JS 分为同步任务和异步任务。
  2. 同步任务在 Call Stack 中执行。
  3. 异步任务分宏任务(DOM, Timer, Network)和微任务(Promise)。
  4. 执行顺序:同步 -> 所有微任务 -> 渲染 -> 一个宏任务 -> 所有微任务 -> ...
  5. Promise 构造函数是同步的,.then 是微任务。
  6. await 会让出线程,其后的代码作为微任务执行。

MIT Licensed | Keep Learning.