浏览器事件循环(Event Loop)全解析:宏任务(Macrotask)与微任务(Microtask)的执行顺序

各位同仁,下午好!

今天,我们将深入探讨一个前端开发中至关重要,但又常常被误解的核心机制——浏览器事件循环(Event Loop)。理解事件循环,尤其是其宏任务(Macrotask)与微任务(Microtask)的执行顺序,是编写高性能、非阻塞且行为可预测的JavaScript代码的基础。

作为一门单线程语言,JavaScript如何在浏览器中实现看似并发的异步操作,同时又保持用户界面的流畅响应?答案就在事件循环中。我们将从最基础的单线程模型讲起,逐步揭示事件循环的奥秘,并通过丰富的代码示例,剖析宏任务与微任务的交互机制。

JavaScript的单线程本质与调用栈

首先,让我们明确一个基本事实:JavaScript是单线程的。这意味着,在任何给定时刻,JavaScript引擎只能执行一个任务。所有的代码都运行在一个单一的调用栈(Call Stack)中。

调用栈是一个后进先出(LIFO)的数据结构,用于跟踪当前正在执行的函数。当一个函数被调用时,它被推入栈顶;当函数执行完毕返回时,它从栈顶弹出。

function multiply(a, b) {
    return a * b;
}

function square(n) {
    return multiply(n, n);
}

function printSquare(n) {
    const result = square(n);
    console.log(`The square of ${n} is ${result}`);
}

printSquare(5);
console.log('Execution finished');

让我们追踪一下printSquare(5)的执行过程:

  1. printSquare(5)被推入调用栈。
  2. square(5)被推入调用栈(在printSquare内部)。
  3. multiply(5, 5)被推入调用栈(在square内部)。
  4. multiply(5, 5)执行完毕,返回25,从栈中弹出。
  5. square(5)执行完毕,返回25,从栈中弹出。
  6. console.log()被推入调用栈。
  7. console.log()执行完毕,从栈中弹出。
  8. printSquare(5)执行完毕,从栈中弹出。
  9. 全局代码的console.log('Execution finished')被推入调用栈。
  10. console.log('Execution finished')执行完毕,从栈中弹出。
  11. 调用栈为空。

这个过程是完全同步的。如果multiply函数内部有一个非常耗时的计算,那么在它完成之前,squareprintSquare都无法继续执行,更不用说后续的console.log('Execution finished')了。在浏览器环境中,这意味着用户界面会完全冻结,无法响应任何操作。这就是单线程同步执行的“阻塞”问题。

浏览器环境:Web APIs 与并发错觉

既然JavaScript是单线程的,那我们是如何实现异步操作的呢?例如,setTimeoutfetch、DOM事件监听等,它们都不会阻塞主线程。

这里的关键在于,这些异步功能并非由JavaScript引擎本身提供,而是由浏览器(或Node.js运行时)提供的“Web APIs”。它们是浏览器环境的一部分,可以执行耗时的操作,而不会阻塞JavaScript主线程。

当JavaScript代码调用setTimeoutfetch或添加事件监听器时,它实际上是向浏览器请求执行一个异步操作。浏览器会在后台(通常是独立于JS主线程的另一个线程)处理这些操作。一旦这些异步操作完成,它们关联的回调函数并不会立即执行,而是被放入一个队列中等待。

这个队列,我们称之为宏任务队列(Macrotask Queue),有时也被称为任务队列(Task Queue)回调队列(Callback Queue)

事件循环:核心协调者

事件循环(Event Loop)正是负责协调JavaScript主线程、Web APIs和任务队列的机制。它的核心职责是:当调用栈为空时,从任务队列中取出下一个任务(回调函数),并将其推入调用栈执行。

这个过程可以概括为以下步骤:

  1. 执行主脚本(同步代码):JavaScript引擎从上到下执行当前的同步代码,将其函数推入调用栈。
  2. 调用栈清空:当所有同步代码执行完毕,调用栈变为空。
  3. 事件循环介入:事件循环持续监视调用栈和任务队列。当调用栈为空时,它会检查任务队列。
  4. 取出任务并执行:如果任务队列中有待处理的任务,事件循环会取出队列中的第一个任务(回调函数),将其推入调用栈,然后JavaScript引擎开始执行这个任务。
  5. 重复:任务执行完毕后,调用栈再次清空,事件循环再次检查任务队列,如此往复。

这个机制确保了JavaScript主线程始终保持响应,避免了长时间阻塞。

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

setTimeout(() => {
    console.log('setTimeout callback'); // 异步任务的回调
}, 0); // 即使是0毫秒,也是异步的

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

执行顺序分析:

  1. console.log('Start')推入调用栈并执行,输出 "Start"。
  2. setTimeout被调用,它是一个Web API。浏览器启动一个计时器。计时器到期后(几乎立即,因为是0ms),setTimeout的回调函数被放入宏任务队列。
  3. console.log('End')推入调用栈并执行,输出 "End"。
  4. 所有同步代码执行完毕,调用栈为空。
  5. 事件循环发现调用栈为空,并检查宏任务队列。
  6. 宏任务队列中存在setTimeout的回调函数。事件循环将其取出并推入调用栈。
  7. setTimeout的回调函数执行,console.log('setTimeout callback')输出 "setTimeout callback"。
  8. 回调函数执行完毕,从调用栈弹出。调用栈再次为空。
  9. 宏任务队列为空。事件循环继续等待新的任务。

最终输出:

Start
End
setTimeout callback

微任务的引入:更精细的异步控制

随着Web技术的发展,特别是Promises的出现,开发者需要一种机制来执行一些高优先级的异步操作,这些操作需要比普通宏任务更快地得到执行,最好能在当前宏任务执行完毕后,但在渲染或下一个宏任务开始之前就完成。这就是微任务(Microtask)的用武之地。

微任务队列(Microtask Queue)是一个独立的队列,它拥有比宏任务队列更高的优先级。

一个重要的规则是:在每一个宏任务执行完毕后,事件循环会立即清空微任务队列,然后再去检查宏任务队列。

这意味着,如果在当前宏任务执行过程中产生了一些微任务,它们会在当前宏任务结束后,并且在任何后续宏任务开始之前,全部得到执行。

宏任务与微任务的分类

为了更好地理解它们的执行顺序,我们先来看看哪些操作会产生宏任务,哪些会产生微任务。

类别 示例 描述
宏任务 script(整体代码块)、setTimeoutsetIntervalsetImmediate (Node.js特有)、I/O(网络请求、文件读写)、UI渲染事件(如click, mousemove等)、MessageChannelrequestAnimationFrame (通常在渲染阶段执行,可以视为一个特殊的宏任务或独立的渲染任务) 每次事件循环迭代(称为一个“tick”)都会处理一个宏任务。当一个宏任务执行完毕后,浏览器可能会进行渲染,然后才开始处理微任务队列。如果没有微任务,则直接进入下一个宏任务的选取。宏任务之间的间隔可能会包含渲染操作,因此它们是实现UI响应性和非阻塞操作的主要手段。
微任务 Promise.then()Promise.catch()Promise.finally()回调、async/await的后续部分(await后的代码)、MutationObserver回调、queueMicrotask()

事件循环的详细执行顺序

1现在,让我们用更细致的步骤来描绘浏览器中一个完整的事件循环“迭代”:

  1. 执行当前宏任务:从宏任务队列中取出一个宏任务(例如,一段初始的script代码,或者一个setTimeout的回调),并将其推入调用栈执行。
  2. 清空微任务队列:当前宏任务执行完毕,调用栈为空。
    • 此时,事件循环会检查微任务队列。
    • 它会循环执行并清空微任务队列中的所有微任务,直到微任务队列为空。
    • 注意:如果在执行微任务的过程中,又产生了新的微任务,这些新的微任务也会被添加到微任务队列的末尾,并在当前批次的微任务处理中被执行
  3. 渲染(可选):如果浏览器判断有必要进行UI更新(例如,DOM结构或样式发生了变化),它会执行渲染操作。requestAnimationFrame的回调通常在这个阶段执行,因为它是在浏览器重绘之前执行的。
  4. 开始下一个宏任务的选取:渲染完成后,浏览器会再次从宏任务队列中取出一个新的宏任务,重复步骤1。

这个过程周而复始,确保了异步操作的执行,并维持了UI的响应性。

深入代码示例解析

理解理论是第一步,但通过代码示例来亲手追踪执行流程才是真正掌握的关键。我们将逐步增加示例的复杂性。

示例 1: 宏任务与微任务的基本顺序

console.log('Script start');

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

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

console.log('Script end');

执行流程分析:

  1. 第一轮宏任务(主脚本执行)

    • console.log('Script start')执行,输出 "Script start"。
    • setTimeout被调用,其回调函数被放入宏任务队列
    • Promise.resolve().then()被调用,.then()的回调函数被放入微任务队列
    • console.log('Script end')执行,输出 "Script end"。
    • 主脚本执行完毕,调用栈清空。
  2. 清空微任务队列

    • 事件循环发现调用栈为空,开始检查微任务队列。
    • 微任务队列中有Promise callback。它被取出并推入调用栈执行。
    • console.log('Promise callback')执行,输出 "Promise callback"。
    • 微任务队列清空。
  3. 第二轮宏任务

    • 微任务队列清空后,事件循环检查宏任务队列。
    • 宏任务队列中有setTimeout callback。它被取出并推入调用栈执行。
    • console.log('setTimeout callback')执行,输出 "setTimeout callback"。
    • 宏任务执行完毕,调用栈清空。

最终输出:

Script start
Script end
Promise callback
setTimeout callback

这个例子清晰地展示了,在当前宏任务(主脚本)执行完毕后,微任务会优先于下一个宏任务执行。

示例 2: 多个宏任务和微任务

console.log('Start');

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

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

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

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

console.log('End');

执行流程分析:

  1. 第一轮宏任务(主脚本执行)

    • console.log('Start'),输出 "Start"。
    • setTimeout 1回调放入宏任务队列
    • Promise 1回调放入微任务队列
    • setTimeout 2回调放入宏任务队列
    • Promise 2回调放入微任务队列
    • console.log('End'),输出 "End"。
    • 主脚本执行完毕,调用栈清空。
    • 此时:
      • 微任务队列: [Promise 1, Promise 2]
      • 宏任务队列: [setTimeout 1, setTimeout 2]
  2. 清空微任务队列

    • 事件循环发现调用栈为空,处理微任务队列。
    • 取出Promise 1,执行 console.log('Promise 1'),输出 "Promise 1"。
    • 取出Promise 2,执行 console.log('Promise 2'),输出 "Promise 2"。
    • 微任务队列清空。
  3. 第二轮宏任务

    • 事件循环检查宏任务队列。
    • 取出setTimeout 1,执行 console.log('setTimeout 1'),输出 "setTimeout 1"。
    • 宏任务执行完毕,调用栈清空。
  4. 清空微任务队列 (本轮无微任务)

    • 事件循环检查微任务队列,发现为空。
  5. 第三轮宏任务

    • 事件循环检查宏任务队列。
    • 取出setTimeout 2,执行 console.log('setTimeout 2'),输出 "setTimeout 2"。
    • 宏任务执行完毕,调用栈清空。
  6. 清空微任务队列 (本轮无微任务)

    • 事件循环检查微任务队列,发现为空。

最终输出:

Start
End
Promise 1
Promise 2
setTimeout 1
setTimeout 2

示例 3: 宏任务中产生微任务

console.log('Script start');

setTimeout(() => {
    console.log('setTimeout 1');
    Promise.resolve().then(() => {
        console.log('Promise inside setTimeout');
    });
}, 0);

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

console.log('Script end');

执行流程分析:

  1. 第一轮宏任务(主脚本执行)

    • console.log('Script start'),输出 "Script start"。
    • setTimeout 1回调放入宏任务队列
    • Promise 1回调放入微任务队列
    • console.log('Script end'),输出 "Script end"。
    • 主脚本执行完毕,调用栈清空。
    • 此时:
      • 微任务队列: [Promise 1]
      • 宏任务队列: [setTimeout 1]
  2. 清空微任务队列

    • 事件循环发现调用栈为空,处理微任务队列。
    • 取出Promise 1,执行 console.log('Promise 1'),输出 "Promise 1"。
    • 微任务队列清空。
  3. 第二轮宏任务

    • 事件循环检查宏任务队列。
    • 取出setTimeout 1,执行其回调函数。
    • setTimeout 1回调中:
      • console.log('setTimeout 1'),输出 "setTimeout 1"。
      • Promise.resolve().then(() => { console.log('Promise inside setTimeout'); })被调用。其回调函数被放入微任务队列
    • setTimeout 1回调执行完毕,调用栈清空。
    • 此时:
      • 微任务队列: [Promise inside setTimeout]
      • 宏任务队列: []
  4. 清空微任务队列

    • 事件循环发现调用栈为空,再次处理微任务队列。
    • 取出Promise inside setTimeout,执行 console.log('Promise inside setTimeout'),输出 "Promise inside setTimeout"。
    • 微任务队列清空。

最终输出:

Script start
Script end
Promise 1
setTimeout 1
Promise inside setTimeout

这个例子非常重要,它展示了宏任务内部产生的微任务,会在当前宏任务执行完毕后,立即得到执行,而不是等到下一个宏任务周期。

示例 4: 微任务中产生宏任务和微任务

console.log('Start');

Promise.resolve().then(() => {
    console.log('Promise 1');
    setTimeout(() => {
        console.log('setTimeout inside Promise');
    }, 0);
    Promise.resolve().then(() => {
        console.log('Promise inside Promise');
    });
});

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

console.log('End');

执行流程分析:

  1. 第一轮宏任务(主脚本执行)

    • console.log('Start'),输出 "Start"。
    • Promise 1回调放入微任务队列
    • setTimeout 1回调放入宏任务队列
    • console.log('End'),输出 "End"。
    • 主脚本执行完毕,调用栈清空。
    • 此时:
      • 微任务队列: [Promise 1]
      • 宏任务队列: [setTimeout 1]
  2. 清空微任务队列

    • 事件循环发现调用栈为空,处理微任务队列。
    • 取出Promise 1,执行其回调函数。
    • Promise 1回调中:
      • console.log('Promise 1'),输出 "Promise 1"。
      • setTimeout inside Promise回调放入宏任务队列
      • Promise inside Promise回调放入微任务队列
    • Promise 1回调执行完毕,调用栈清空。
    • 此时:
      • 微任务队列: [Promise inside Promise]
      • 宏任务队列: [setTimeout 1, setTimeout inside Promise]
    • 微任务队列中还有任务,继续执行。
    • 取出Promise inside Promise,执行 console.log('Promise inside Promise'),输出 "Promise inside Promise"。
    • 微任务队列清空。
  3. 第二轮宏任务

    • 事件循环检查宏任务队列。
    • 取出setTimeout 1,执行 console.log('setTimeout 1'),输出 "setTimeout 1"。
    • 宏任务执行完毕,调用栈清空。
  4. 清空微任务队列 (本轮无微任务)

    • 事件循环检查微任务队列,发现为空。
  5. 第三轮宏任务

    • 事件循环检查宏任务队列。
    • 取出setTimeout inside Promise,执行 console.log('setTimeout inside Promise'),输出 "setTimeout inside Promise"。
    • 宏任务执行完毕,调用栈清空。

最终输出:

Start
End
Promise 1
Promise inside Promise
setTimeout 1
setTimeout inside Promise

这个例子进一步强调了微任务队列的“饥饿”特性:它会不断清空,直到所有微任务都执行完毕,才会让出控制权给下一个宏任务。

示例 5: async/await 与事件循环

async/await是基于Promise的语法糖,其行为也符合Promise的微任务特性。

async function asyncFunc() {
    console.log('Async function start');
    await Promise.resolve(); // 这一行后的代码被推入微任务队列
    console.log('Async function continues after await');
}

console.log('Script start');
asyncFunc();
console.log('Script end');

执行流程分析:

  1. 第一轮宏任务(主脚本执行)

    • console.log('Script start'),输出 "Script start"。
    • asyncFunc()被调用。
      • 进入asyncFuncconsole.log('Async function start'),输出 "Async function start"。
      • 遇到await Promise.resolve()Promise.resolve()立即resolve,但await会暂停asyncFunc的执行,并将await后的代码(即console.log('Async function continues after await')这部分)包装成一个回调,放入微任务队列
      • asyncFunc执行到await处暂停,将控制权返回给其调用者(即主脚本)。
    • console.log('Script end'),输出 "Script end"。
    • 主脚本执行完毕,调用栈清空。
    • 此时:
      • 微任务队列: [asyncFunc的await后续部分]
      • 宏任务队列: []
  2. 清空微任务队列

    • 事件循环发现调用栈为空,处理微任务队列。
    • 取出asyncFunc的await后续部分,推入调用栈执行。
    • console.log('Async function continues after await'),输出 "Async function continues after await"。
    • 微任务队列清空。

最终输出:

Script start
Async function start
Script end
Async function continues after await

这表明await后的代码会被视为一个微任务,在当前宏任务结束后,立即执行。

示例 6: queueMicrotask()

queueMicrotask()是一个直接将函数放入微任务队列的API,提供了更直接的微任务调度能力。

console.log('Start');

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

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

queueMicrotask(() => {
    console.log('queueMicrotask');
});

console.log('End');

执行流程分析:

  1. 第一轮宏任务(主脚本执行)

    • console.log('Start'),输出 "Start"。
    • setTimeout回调放入宏任务队列
    • Promise回调放入微任务队列
    • queueMicrotask回调放入微任务队列(在Promise回调之后)。
    • console.log('End'),输出 "End"。
    • 主脚本执行完毕,调用栈清空。
    • 此时:
      • 微任务队列: [Promise, queueMicrotask]
      • 宏任务队列: [setTimeout]
  2. 清空微任务队列

    • 事件循环发现调用栈为空,处理微任务队列。
    • 取出Promise,执行 console.log('Promise'),输出 "Promise"。
    • 取出queueMicrotask,执行 console.log('queueMicrotask'),输出 "queueMicrotask"。
    • 微任务队列清空。
  3. 第二轮宏任务

    • 事件循环检查宏任务队列。
    • 取出setTimeout,执行 console.log('setTimeout'),输出 "setTimeout"。
    • 宏任务执行完毕,调用栈清空。

最终输出:

Start
End
Promise
queueMicrotask
setTimeout

这再次验证了微任务的优先级高于宏任务,且queueMicrotask与Promise的回调处于相同的微任务队列中。

浏览器UI渲染与requestAnimationFrame

在浏览器环境中,UI渲染是一个重要的环节。渲染通常发生在每个宏任务执行完毕并且微任务队列被清空之后。如果在一个宏任务中修改了DOM,这些修改并不会立即体现在屏幕上,而是会等到浏览器有机会进行渲染时才更新。

requestAnimationFrame是一个特殊的Web API,它的回调函数会在浏览器下一次重绘之前执行。这意味着requestAnimationFrame的回调比setTimeout或事件回调有更高的优先级,因为它直接与浏览器的渲染周期挂钩。它通常在当前所有微任务执行完毕后,但在浏览器进行渲染操作之前被执行。

console.log('Script start');

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

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

requestAnimationFrame(() => {
    console.log('requestAnimationFrame');
});

console.log('Script end');

执行流程分析:

  1. 第一轮宏任务(主脚本执行)

    • console.log('Script start'),输出 "Script start"。
    • setTimeout回调放入宏任务队列
    • Promise回调放入微任务队列
    • requestAnimationFrame回调被浏览器调度,准备在下一次重绘前执行。
    • console.log('Script end'),输出 "Script end"。
    • 主脚本执行完毕,调用栈清空。
    • 此时:
      • 微任务队列: [Promise]
      • 宏任务队列: [setTimeout]
      • requestAnimationFrame等待执行。
  2. 清空微任务队列

    • 事件循环发现调用栈为空,处理微任务队列。
    • 取出Promise,执行 console.log('Promise'),输出 "Promise"。
    • 微任务队列清空。
  3. 渲染阶段

    • 微任务队列清空后,浏览器检查是否有待渲染的更新。如果有,它会执行渲染。
    • 在渲染之前,requestAnimationFrame的回调被执行。
    • console.log('requestAnimationFrame'),输出 "requestAnimationFrame"。
  4. 第二轮宏任务

    • 渲染完毕,事件循环检查宏任务队列。
    • 取出setTimeout,执行 console.log('setTimeout'),输出 "setTimeout"。
    • 宏任务执行完毕,调用栈清空。

最终输出:

Script start
Script end
Promise
requestAnimationFrame
setTimeout

这个例子展示了requestAnimationFrame在微任务之后,宏任务之前,且与浏览器渲染紧密结合的特性。

实践意义与最佳实践

理解事件循环及其宏任务与微任务的执行顺序,对于编写高效、响应迅速且易于维护的JavaScript代码至关重要。

  1. 保持UI响应性:避免在任何宏任务中执行长时间的同步计算。如果确实需要,可以考虑将计算拆分成多个小块,通过setTimeout(..., 0)将其分散到不同的事件循环周期中,或者使用Web Workers在后台线程中执行,从而不阻塞主线程。

  2. 精确控制异步时序

    • 当你需要一个异步操作在当前JS代码执行完毕后尽快执行,且在任何UI渲染或新的用户交互之前,使用微任务(如Promises、async/awaitqueueMicrotask)。这对于状态更新、UI组件的微小调整等场景非常有用。
    • 当你需要一个异步操作在当前JS代码执行完毕后,并且在UI渲染之后,甚至在一段可感知的延迟之后执行,使用宏任务(如setTimeout、事件监听器)。这适用于延迟执行、周期性任务、处理用户输入等场景。
  3. 调试异步代码:事件循环模型是理解异步代码执行顺序的“心智模型”。当你的异步代码行为不符合预期时,回顾事件循环的规则,可以帮助你定位问题。

  4. 避免“任务堆积”:如果宏任务队列或微任务队列中堆积了过多的任务,会导致UI卡顿。例如,一个无限循环的Promise.resolve().then(() => { /* ... */ Promise.resolve().then(...) })会在同一个事件循环周期内不断产生和执行微任务,导致浏览器失去响应。

总结

浏览器事件循环是JavaScript单线程模型中实现非阻塞异步操作的基石。它通过巧妙地协调调用栈、Web APIs、宏任务队列和微任务队列,使得JavaScript能够在不阻塞主线程的情况下,处理各种异步事件。

核心要点在于:

  • JavaScript是单线程的,所有代码都在调用栈中同步执行。
  • Web APIs提供异步能力,其回调函数被放入队列。
  • 事件循环不断检查调用栈,当其为空时,从队列中取出任务执行。
  • 宏任务(如setTimeout、DOM事件)在每个事件循环周期中,一次只取一个执行。
  • 微任务(如Promises、async/awaitqueueMicrotask)在每个宏任务执行完毕后,会清空所有当前存在的微任务,然后才进行渲染或进入下一个宏任务。

掌握这些原理,将赋予你对JavaScript异步行为的深刻洞察和精准控制。谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注