JavaScript 运行机制——事件循环(Event Loop)、微任务(Microtask)​ 和宏任务(Macrotask)

0 阅读5分钟

一道高频且经典的面试题,考察对 JavaScript 运行机制,特别是事件循环(Event Loop)微任务(Microtask) ​ 和宏任务(Macrotask) ​ 的理解。

console.log('1');

setTimeout(function() {
    console.log('2');
    new Promise(function(resolve) {
        console.log('3');
        resolve();
    }).then(function() {
        console.log('4');
    });
}, 0);

new Promise(function(resolve) {
    console.log('5');
    resolve();
}).then(function() {
    console.log('6');
}).then(function() {
    console.log('7');
});

setTimeout(function() {
    console.log('8');
    new Promise(function(resolve) {
        console.log('9');
        resolve();
    }).then(function() {
        console.log('10');
    });
}, 0);

console.log('11');

第一步:同步代码执行(调用栈)

JavaScript 是单线程的,它首先会顺序执行所有同步代码(位于调用栈中)。

  1. console.log('1')-> 输出 1
  2. 遇到第一个 setTimeout,将其回调函数交给Web API(或宿主环境) ​ 计时,计时(0ms)结束后,回调函数被放入宏任务队列。我们记作 Macro1
  3. 执行 new Promise构造函数部分,这是同步的。输出 5,并立即调用 resolve()。因为这个 Promise 已经 resolve,它的 .then()回调(输出6)被放入微任务队列。我们记作 Micro1。这个 .then又返回一个新的 Promise,它的 .then回调(输出7)会在 Micro1执行后才能确定状态,暂时不管。
  4. 遇到第二个 setTimeout,同理,其回调被放入宏任务队列。我们记作 Macro2
  5. console.log('11')-> 输出 11

此时,输出为:1, 5, 11

调用栈已清空。

微任务队列: ​ [Micro1 (输出6)]

宏任务队列: ​ [Macro1 (输出2,3,4), Macro2 (输出8,9,10)]


第二步:清空微任务队列

每当调用栈(Call Stack)被清空,事件循环(Event Loop) ​ 会优先检查微任务队列,并依次执行其中的所有任务,直到微任务队列为空

  1. 执行 Micro1,输出 6

    • 关键点:当第一个 .then(输出6)执行完成后,它返回的 Promise 被 resolve。因此,链式调用的第二个 .then的回调(输出7)会被立即放入当前的微任务队列末尾
  2. 微任务队列不为空,继续执行。事件循环取出这个新加入的微任务(Micro2)执行,输出 7

此时,输出为:1, 5, 11, 6, 7

调用栈再次清空。

微任务队列: ​ [](已完全清空)

宏任务队列: ​ [Macro1, Macro2]


第三步:执行一个宏任务

微任务队列清空后,事件循环会从宏任务队列中取出第一个任务(这里是 Macro1)放入调用栈执行。

  1. 执行 Macro1的第一行同步代码:console.log('2')-> 输出 2
  2. 执行 new Promise构造函数,输出 3,并立即 resolve()。这个 resolve()将其 .then回调(输出4)放入微任务队列

此时,Macro1的同步代码部分执行完毕。

调用栈再次清空。

微任务队列: ​ [Micro3 (输出4)](注意:这是在执行宏任务过程中产生的微任务)

宏任务队列: ​ [Macro2]


第四步:再次清空微任务队列

重要规则:在每一个宏任务执行完毕后、取出下一个宏任务之前,都必须先清空当前的微任务队列。

  1. 调用栈清空,事件循环检查微任务队列,发现 Micro3,执行它,输出 4

此时,输出为:1, 5, 11, 6, 7, 2, 3, 4

微任务队列: ​ []

宏任务队列: ​ [Macro2]


第五步:执行下一个宏任务

  1. 事件循环取出 Macro2执行。console.log('8')-> 输出 8
  2. 执行 new Promise构造函数,输出 9,并立即 resolve()。将其 .then回调(输出10)放入微任务队列
  3. Macro2同步代码执行完毕,调用栈清空。
  4. 清空产生的微任务:执行输出10的微任务,输出 10

最终完整输出:1, 5, 11, 6, 7, 2, 3, 4, 8, 9, 10

核心知识点总结

  1. 同步任务优先:所有同步代码(普通调用、new Promise构造函数内的代码)按顺序立即执行。

  2. 异步任务分类

    • 宏任务setTimeoutsetIntervalsetImmediate(Node), I/O, UI rendering。
    • 微任务Promise.then/.catch/.finallyprocess.nextTick(Node), MutationObserver
  3. 事件循环流程

    • 执行全局脚本(作为一个宏任务)。
    • 清空调用栈
    • 执行所有位于微任务队列中的任务。
    • 渲染页面(如有需要)。
    • 从宏任务队列中取出一个任务执行。
    • 重复:清空微任务 -> (渲染) -> 取一个宏任务 ...(循环往复)。

流程图

image.png

    A[开始: 执行整体script<br>(作为一个宏任务)] --> B[执行所有同步代码]
    B --> C[调用栈清空]
    
    C --> D{微任务队列<br>是否为空?}
    D -- 否 --> E[执行队列中所有微任务<br>(直到队列清空)]
    E --> D
    D -- 是 --> F{是否需要UI渲染?<br>(浏览器控制)}
    
    F -- 是 --> G[执行UI渲染]
    F -- 否 --> H{宏任务队列<br>是否有任务?}
    
    H -- 是 --> I[取出一个宏任务执行]
    I --> B
    
    H -- 否 --> J[等待新任务<br>(空闲状态)]
    J --> I
    
    G --> H

核心算法伪代码

// 事件循环核心逻辑
while (true) {
 // 1. 执行当前宏任务(从宏任务队列取出)
 task = getNextMacroTask();
 if (task) {
   execute(task);
 }
 
 // 2. 执行所有微任务(直到队列清空)
 while (microtaskQueue.hasTasks()) {
   microtask = microtaskQueue.dequeue();
   execute(microtask);
 }
 
 // 3. 必要时进行UI渲染(浏览器优化控制)
 if (isTimeToRender()) {
   // 3.1 样式计算
   // 3.2 布局
   // 3.3 绘制
   render();
 }
}
  1. 关键原则

    • 每个宏任务执行完后,都会检查并清空整个微任务队列
    • 微任务队列具有最高优先级。如果在执行微任务过程中产生了新的微任务,会继续执行,直到队列为空。这可能导致宏任务被“饥饿”。