微任务和宏任务总搞混?JavaScript执行顺序彻底讲清楚

各位同仁,各位对JavaScript异步编程充满好奇的开发者们,大家好!

今天,我们将深入探讨一个在JavaScript世界中经常令人感到困惑,但又至关重要的概念:微任务(Microtask)和宏任务(Macrotask)。它们是理解JavaScript事件循环(Event Loop)机制,掌握异步代码执行顺序的关键。许多开发者,即使是经验丰富的,也常常在这两者之间摇摆不定,导致对代码行为的预测出现偏差。

我的目标是,通过这次深入的讲座,彻底剖析微任务和宏任务的本质,揭示它们在事件循环中的优先级和执行机制,让大家能够清晰、准确地预判任何异步JavaScript代码的执行流程。我们将从最基础的概念开始,逐步深入到复杂的场景,并辅以大量的代码示例进行演示。


一切的根源:JavaScript的单线程特性

在深入微任务和宏任务之前,我们必须先巩固一个最基本的概念:JavaScript是单线程的。这意味着在任何给定时刻,JavaScript引擎只能执行一个任务。它只有一个调用栈(Call Stack),用于跟踪当前正在执行的函数。

1. 调用栈 (Call Stack)

想象一下一个堆叠的盘子。当你调用一个函数时,它被“推入”栈顶。当函数执行完毕并返回时,它被“弹出”栈。JavaScript引擎总是执行栈顶的函数。

function third() {
    console.log('3: third function');
}

function second() {
    console.log('2: second function');
    third(); // third() 被推入栈
}

function first() {
    console.log('1: first function');
    second(); // second() 被推入栈
}

first(); // first() 被推入栈
console.log('4: script end');

执行顺序与调用栈变化:

  1. first() 进入栈
  2. console.log('1: first function') 执行
  3. second() 进入栈
  4. console.log('2: second function') 执行
  5. third() 进入栈
  6. console.log('3: third function') 执行
  7. third() 完成,弹出栈
  8. second() 完成,弹出栈
  9. first() 完成,弹出栈
  10. console.log('4: script end') 执行
  11. 主脚本完成,弹出栈

输出:

1: first function
2: second function
3: third function
4: script end

这就是同步代码的执行方式。然而,在现代Web应用中,我们需要处理网络请求、定时器、用户交互等异步操作。如果这些操作都阻塞主线程,用户界面就会卡死,用户体验将不堪设想。这就是为什么我们需要异步机制。

异步的基石:事件循环 (Event Loop)

为了解决单线程阻塞问题,JavaScript引入了事件循环(Event Loop)机制。事件循环是JavaScript运行时(如浏览器或Node.js环境)中的一个核心组件,它不断地检查是否有任务需要执行,并协调任务的顺序。

事件循环的组成部分:

  1. 调用栈 (Call Stack): 我们已经讨论过,用于执行同步代码。
  2. Web APIs (或宿主环境提供的API): 浏览器(或Node.js)提供的一些异步功能,例如:
    • setTimeout, setInterval (定时器)
    • fetch, XMLHttpRequest (网络请求)
    • DOM事件监听 (addEventListener)
    • requestAnimationFrame
  3. 任务队列 (Task Queue / Callback Queue / Macrotask Queue): 当Web API完成其异步操作后,会将对应的回调函数放入这个队列。
  4. 微任务队列 (Microtask Queue): 这是一个优先级更高的队列,专门用于存放微任务的回调。

事件循环的基本流程(简化版):

  1. 执行主线程上的同步代码,直到调用栈清空。
  2. 调用栈清空后,事件循环开始工作。
  3. 它首先会检查微任务队列。如果微任务队列中有任务,它会清空并执行所有这些微任务,直到队列为空。
  4. 微任务队列清空后,如果浏览器要进行渲染,会执行渲染操作。
  5. 然后,它会检查宏任务队列(即任务队列)。如果宏任务队列中有任务,它会取出一个宏任务来执行。
  6. 执行完一个宏任务后,事件循环会再次回到步骤3,检查并清空微任务队列,然后进行渲染,再取下一个宏任务。

这个循环会一直持续下去,直到所有任务都被处理完毕(或者页面关闭)。


宏任务 (Macrotasks):重量级选手

宏任务是构成事件循环“大循环”的基本单位。每次事件循环迭代(称为一个“tick”或“turn”)都会从宏任务队列中取出一个宏任务来执行。

常见的宏任务包括:

  • 主脚本 (Initial Script): 整个JavaScript文件本身就是第一个宏任务。
  • setTimeout 的回调函数
  • setInterval 的回调函数
  • I/O 操作 (如文件读写,网络请求的回调)
  • UI 渲染事件 (如浏览器重绘/回流)
  • MessageChannel 的回调
  • requestAnimationFrame 的回调 (虽然与渲染紧密相关,但它通常在宏任务之后、渲染之前执行)
  • setImmediate (Node.js 独有)

宏任务的特点:

  • 优先级较低: 每次事件循环迭代只会执行一个宏任务。
  • 独立性: 每个宏任务执行完毕后,都会给JavaScript引擎一个“喘息”的机会,让它去检查微任务队列,执行渲染等。
  • 可能引入延迟: 定时器(setTimeout, setInterval)的延迟时间只是一个最小延迟,实际执行时间可能会更长,因为它必须等待当前宏任务执行完毕,并且微任务队列被清空。

示例:setTimeout 作为宏任务

console.log('1: Start Script');

setTimeout(() => {
    console.log('4: setTimeout callback');
}, 0); // 尽管延迟为0,它仍然是一个宏任务

console.log('2: End Script');

// 预期输出:
// 1: Start Script
// 2: End Script
// 4: setTimeout callback

解释:

  1. console.log('1: Start Script') 同步执行。
  2. setTimeout 被调用。它的回调函数被注册到Web API中,等待0ms后被放入宏任务队列。
  3. console.log('2: End Script') 同步执行。
  4. 主脚本(第一个宏任务)执行完毕,调用栈清空。
  5. 事件循环检查微任务队列(此时为空)。
  6. 事件循环检查宏任务队列,发现 setTimeout 的回调函数。
  7. 取出并执行 console.log('4: setTimeout callback')

微任务 (Microtasks):高优先级的插队者

微任务是在当前宏任务执行结束后,但在下一个宏任务开始之前执行的任务。它们可以被认为是“在当前事件循环迭代中尽快执行”的任务。

常见的微任务包括:

  • Promise.then(), Promise.catch(), Promise.finally() 的回调函数
  • async/await 中的 await 之后的代码 (本质上是 Promise)
  • MutationObserver 的回调函数
  • queueMicrotask() 方法
  • process.nextTick() (Node.js 独有,优先级高于所有微任务,非常特殊)

微任务的特点:

  • 高优先级: 在一个宏任务执行完毕后,事件循环会立即检查并清空整个微任务队列,然后再进行渲染或处理下一个宏任务。这意味着所有排队的微任务都会在一个宏任务结束后立即执行,不会等到下一个事件循环迭代。
  • 快速响应: 适合需要尽快执行的异步操作,例如Promise链式调用,确保Promise的状态变化能够迅速反映。
  • 可能导致UI卡顿: 如果微任务队列中存在大量或耗时的任务,它们会长时间阻塞UI渲染和下一个宏任务的执行,导致页面无响应。

示例:Promise 作为微任务

console.log('1: Start Script');

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

console.log('2: End Script');

// 预期输出:
// 1: Start Script
// 2: End Script
// 3: Promise microtask

解释:

  1. console.log('1: Start Script') 同步执行。
  2. Promise.resolve() 返回一个已解决的Promise。
  3. .then() 回调函数被调度为一个微任务,放入微任务队列。
  4. console.log('2: End Script') 同步执行。
  5. 主脚本(第一个宏任务)执行完毕,调用栈清空。
  6. 事件循环检查微任务队列,发现 Promise.then() 的回调函数。
  7. 清空微任务队列,执行 console.log('3: Promise microtask')

宏任务与微任务的执行顺序:核心机制

理解宏任务与微任务如何协同工作的关键在于这个循环:

一个事件循环周期(一次“tick”)的简化流程:

  1. 从宏任务队列中取出一个宏任务并执行它。 (通常是主脚本,或者一个 setTimeout 回调,一个DOM事件回调等)。
  2. 执行过程中,如果遇到异步代码:
    • setTimeout/setInterval 等回调被注册到Web API,待条件满足后,进入宏任务队列
    • Promise.then()/catch/finally() 等回调被注册到Web API,待条件满足后,进入微任务队列
  3. 当前宏任务执行完毕,调用栈清空。
  4. 检查微任务队列。 如果非空,则清空并执行所有微任务队列中的任务,直到微任务队列为空。
  5. (浏览器环境特有)执行浏览器渲染。 重新计算样式、布局和绘制页面。
  6. 回到步骤1,从宏任务队列中取出下一个宏任务。

表格总结:宏任务 vs 微任务

特性 宏任务 (Macrotask) 微任务 (Microtask)
队列名称 任务队列 (Task Queue / Callback Queue) 微任务队列 (Microtask Queue)
执行时机 在一个事件循环周期中,每次只取出一个宏任务执行。 在每个宏任务执行完毕后,下一个宏任务开始前,清空所有微任务。
优先级 较低 较高
典型示例 script (整体代码), setTimeout, setInterval, I/O, UI渲染 Promise.then/catch/finally, async/await, MutationObserver, queueMicrotask
对渲染影响 执行完一个宏任务后,有机会进行UI渲染。 大量微任务会阻塞UI渲染,直到所有微任务执行完毕。
Node.js 特有 setImmediate process.nextTick (优先级最高)

实战演练:逐步提升复杂度

现在,让我们通过一系列代码示例来加深理解。请大家尝试预测输出,然后对照解释。

示例 1:宏任务与微任务混合

console.log('1: Script Start'); // 宏任务 (主脚本)

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

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

Promise.resolve().then(() => {
    console.log('4: Promise microtask 2'); // 微任务
});

console.log('2: Script End'); // 宏任务 (主脚本)

预测输出:

1: Script Start
2: Script End
3: Promise microtask 1
4: Promise microtask 2
5: setTimeout 0ms

解释:

  1. 主脚本作为第一个宏任务开始执行。
    • console.log('1: Script Start') 同步执行。
    • setTimeout 被注册到Web API,回调进入宏任务队列。
    • Promise.resolve().then(...) 的回调进入微任务队列。
    • 第二个 Promise.resolve().then(...) 的回调进入微任务队列。
    • console.log('2: Script End') 同步执行。
  2. 第一个宏任务(主脚本)执行完毕,调用栈清空。
  3. 事件循环检查微任务队列。 发现两个Promise微任务,按顺序执行:
    • console.log('3: Promise microtask 1')
    • console.log('4: Promise microtask 2')
    • 微任务队列清空。
  4. 事件循环检查宏任务队列。 发现 setTimeout 的回调。
  5. 执行 setTimeout 的回调。
    • console.log('5: setTimeout 0ms')
    • 宏任务队列清空。
  6. 所有任务执行完毕。

示例 2:链式 Promise 与 setTimeout

console.log('1: Global Start');

setTimeout(() => {
    console.log('8: setTimeout 1'); // 宏任务
    Promise.resolve().then(() => {
        console.log('9: Promise in setTimeout'); // 微任务 (属于setTimeout宏任务内部)
    });
}, 0);

Promise.resolve().then(() => {
    console.log('3: Promise 1'); // 微任务
    setTimeout(() => {
        console.log('7: setTimeout in Promise'); // 宏任务 (属于Promise微任务内部)
    }, 0);
}).then(() => {
    console.log('4: Promise 2'); // 微任务 (链式Promise)
});

console.log('2: Global End');

预测输出:

1: Global Start
2: Global End
3: Promise 1
4: Promise 2
7: setTimeout in Promise
8: setTimeout 1
9: Promise in setTimeout

解释:

  1. 主脚本(第一个宏任务)开始执行。
    • console.log('1: Global Start') 同步执行。
    • 第一个 setTimeout 被注册,回调进入宏任务队列 [setTimeout1]
    • 第一个 Promise.then() 被注册,回调进入微任务队列 [Promise1]
    • console.log('2: Global End') 同步执行。
  2. 主脚本执行完毕,调用栈清空。
  3. 清空微任务队列。
    • 执行 Promise 1 的回调:
      • console.log('3: Promise 1')
      • 内部的 setTimeout 被注册,回调进入宏任务队列 [setTimeout1, setTimeoutInPromise]
      • 由于 Promise 1.then() 返回了一个非Promise值(或者隐式返回 undefined),它会立即调度下一个 .then() 回调。
    • 执行 Promise 2 的回调(链式调用):
      • console.log('4: Promise 2')
    • 微任务队列清空。
  4. 事件循环检查宏任务队列。 取出 setTimeout1
    • 执行 setTimeout1 的回调:
      • console.log('8: setTimeout 1')
      • 内部的 Promise.resolve().then(...) 的回调进入微任务队列 [PromiseInSetTimeout]
  5. setTimeout1 宏任务执行完毕,调用栈清空。
  6. 清空微任务队列。
    • 执行 Promise in setTimeout 的回调:
      • console.log('9: Promise in setTimeout')
    • 微任务队列清空。
  7. 事件循环检查宏任务队列。 取出 setTimeoutInPromise
    • 执行 setTimeoutInPromise 的回调:
      • console.log('7: setTimeout in Promise')
    • 宏任务队列清空。
  8. 所有任务执行完毕。

注意: 这里的 7: setTimeout in Promise 为什么在 8: setTimeout 1 之后才执行?因为 setTimeoutInPromise 是在 Promise 1 这个微任务中调度的,而 Promise 1 是在 Global End 之后,setTimeout 1 之前执行的。所以 setTimeoutInPromise 晚于 setTimeout 1 进入宏任务队列。


示例 3:async/awaitqueueMicrotask

async/await 是 ES2017 引入的异步语法糖,它建立在 Promise 之上。await 关键字会暂停 async 函数的执行,并等待其后的 Promise 解决。当 Promise 解决后,await 之后的代码将作为微任务被重新调度执行。

queueMicrotask() 是一个直接将函数放入微任务队列的方法。

console.log('1: Script Start');

async function asyncFunc() {
    console.log('4: Async Func Start'); // 微任务 (await 之前的同步代码)
    await Promise.resolve(); // 暂停 asyncFunc,await 后面的代码进入微任务队列
    console.log('6: Async Func Await End'); // 微任务
}

asyncFunc();

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

queueMicrotask(() => {
    console.log('3: queueMicrotask'); // 微任务
});

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

console.log('2: Script End');

预测输出:

1: Script Start
2: Script End
3: queueMicrotask
4: Async Func Start
5: Promise Then
6: Async Func Await End
7: setTimeout

解释:

  1. 主脚本(第一个宏任务)开始执行。
    • console.log('1: Script Start') 同步执行。
    • asyncFunc() 被调用。
      • console.log('4: Async Func Start') 同步执行(这是 asyncFunc 内部 await 之前的同步代码)。
      • await Promise.resolve()asyncFunc 暂停,await 之后的 console.log('6: Async Func Await End') 被封装成一个微任务,进入微任务队列。
    • Promise.resolve().then(...) 的回调进入微任务队列。
    • queueMicrotask() 的回调进入微任务队列。
    • setTimeout 被注册,回调进入宏任务队列。
    • console.log('2: Script End') 同步执行。
  2. 主脚本执行完毕,调用栈清空。
  3. 清空微任务队列。 此时微任务队列中有三个任务:
    • queueMicrotask 的回调
    • Promise.then 的回调
    • await 之后的回调(Async Func Await End
    • 执行顺序取决于它们进入队列的顺序:
      • console.log('3: queueMicrotask')
      • console.log('5: Promise Then')
      • console.log('6: Async Func Await End')
    • 微任务队列清空。
  4. 事件循环检查宏任务队列。 取出 setTimeout 的回调。
    • console.log('7: setTimeout')
    • 宏任务队列清空。
  5. 所有任务执行完毕。

注意 asyncFunc4: Async Func Start6: Async Func Await End 的区别。 await 之前的代码是同步执行的,它仍然是当前宏任务的一部分。而 await 之后的代码,只有在被 await 的 Promise 解决后,才会被作为微任务调度。


Node.js 环境下的特例:process.nextTicksetImmediate

在Node.js环境中,除了浏览器中的宏任务和微任务,还有两个特殊的API:process.nextTicksetImmediate,它们对任务队列的优先级有额外的影响。

  • process.nextTick(callback):

    • 这不是标准的微任务或宏任务,但它的行为与微任务非常相似,甚至优先级更高。
    • 它会在当前执行栈清空后,立即执行,在任何微任务之前执行。可以看作是“超级微任务”。
    • 如果在一个宏任务中调用了 process.nextTick,它的回调会在该宏任务结束之后,微任务队列清空之前执行。
    • 如果在微任务中调用了 process.nextTick,它的回调也会在当前微任务结束后,其他微任务之前执行。
  • setImmediate(callback):

    • 这是一个宏任务。
    • 它的优先级低于 setTimeout(fn, 0)
    • 它会在当前事件循环迭代的Check阶段被执行,通常在I/O轮询之后,setTimeout 之前。

Node.js 事件循环阶段(简化):

  1. timers: 执行 setTimeoutsetInterval 的回调。
  2. pending callbacks: 执行一些系统操作的回调。
  3. idle, prepare: 内部使用。
  4. poll: 等待新的I/O事件,执行I/O相关的回调。
  5. check: 执行 setImmediate 的回调。
  6. close callbacks: 执行 close 事件的回调。

注意: 在每个阶段之间,Node.js 都会检查并清空 process.nextTick 队列和微任务队列。process.nextTick 队列总是最先被清空。

Node.js 示例:

console.log('1: Script Start');

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

setImmediate(() => {
    console.log('5: setImmediate');
});

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

process.nextTick(() => {
    console.log('2: process.nextTick');
});

console.log('6: Script End');

Node.js 环境下的预测输出:

1: Script Start
6: Script End
2: process.nextTick
3: Promise microtask
4: setTimeout 0ms
5: setImmediate

解释 (Node.js):

  1. 主脚本作为第一个宏任务开始执行。
    • console.log('1: Script Start') 同步执行。
    • setTimeout 被注册,回调进入 timers 阶段队列。
    • setImmediate 被注册,回调进入 check 阶段队列。
    • Promise.then() 的回调进入微任务队列。
    • process.nextTick() 的回调进入 nextTick 队列。
    • console.log('6: Script End') 同步执行。
  2. 主脚本执行完毕,调用栈清空。
  3. 清空 process.nextTick 队列。
    • console.log('2: process.nextTick')
  4. 清空微任务队列。
    • console.log('3: Promise microtask')
  5. 进入事件循环的 timers 阶段。
    • 执行 setTimeout 的回调:console.log('4: setTimeout 0ms')
  6. 进入事件循环的 check 阶段。
    • 执行 setImmediate 的回调:console.log('5: setImmediate')
  7. 所有任务执行完毕。

常见误区与最佳实践

误区一:setTimeout(fn, 0) 会立即执行
setTimeout(fn, 0) 只是将回调函数推迟到当前宏任务执行完毕,并且微任务队列清空之后,才会在下一个宏任务周期中执行。它不保证立即执行。

误区二:认为宏任务是并行执行的
JavaScript是单线程的,宏任务和微任务都是在主线程上串行执行的。所谓的“异步”只是将任务调度到未来的某个时间点,而不是同时执行。

误区三:微任务队列无限膨胀导致UI卡顿
如果在一个微任务中又调度了新的微任务(例如一个无限循环的Promise链),那么微任务队列将永远无法清空,导致JavaScript引擎一直忙于处理微任务,无法进行UI渲染,也无法处理下一个宏任务。这会使页面完全卡死。

// 示例:无限微任务循环 (慎用,会导致浏览器卡死)
function createInfiniteMicrotask() {
    Promise.resolve().then(() => {
        console.log('Infinite microtask');
        createInfiniteMicrotask(); // 递归调用,不断添加微任务
    });
}
// createInfiniteMicrotask(); // 不要轻易在生产环境或浏览器中运行

最佳实践:

  • 理解异步流: 在编写异步代码时,始终在脑海中模拟事件循环的执行过程。
  • 优先使用 Promise/async-await: 它们基于微任务,响应更快,代码更具可读性。
  • 避免在微任务中进行大量耗时操作: 如果有大量计算或长时间运行的任务,考虑使用 Web Workers 或将其拆分为多个宏任务,以避免阻塞UI。
  • 正确使用 queueMicrotask 当你需要确保某个任务在当前同步代码执行完毕后,但在任何新的宏任务或UI渲染之前执行时,queueMicrotask 是一个很好的选择。例如,某些库需要在DOM更新后立即执行清理或验证操作。
  • 谨慎处理 setTimeout(fn, 0) 了解其执行时机,不要期望它能立即响应。如果需要更精细的控制,可以考虑 requestAnimationFrame 用于动画,或者 queueMicrotask 用于更快的非渲染相关更新。

拓展视野:浏览器渲染与 requestAnimationFrame

我们多次提到UI渲染。在浏览器环境中,UI渲染通常发生在每个宏任务执行完毕,并且微任务队列被清空之后。

requestAnimationFrame(callback) 是另一个特殊的API,用于优化动画和视觉更新。它的回调函数会在浏览器下一次重绘之前执行。它既不是宏任务也不是微任务,但它的执行时机通常在所有微任务之后,但在浏览器实际渲染之前。这使得它成为进行DOM操作和动画的最佳场所,因为它能与浏览器的渲染周期同步,避免不必要的重绘和卡顿。

执行顺序(包含渲染和 requestAnimationFrame):

  1. 执行一个宏任务
  2. 清空微任务队列
  3. 执行 requestAnimationFrame 的回调
  4. 浏览器进行渲染
  5. 回到步骤1,执行下一个宏任务
console.log('1: Script Start');

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

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

requestAnimationFrame(() => {
    console.log('4: requestAnimationFrame'); // 在渲染前执行
    Promise.resolve().then(() => {
        console.log('5: Promise in rAF'); // 微任务 (属于rAF宏任务内部)
    });
});

console.log('2: Script End');

预测输出:

1: Script Start
2: Script End
3: Promise microtask
4: requestAnimationFrame
5: Promise in rAF
6: setTimeout

解释:

  1. 主脚本执行,1: Script Start2: Script End 同步输出。
  2. setTimeout 回调进入宏任务队列。
  3. Promise.then 回调进入微任务队列。
  4. requestAnimationFrame 回调注册。
  5. 主脚本完成,调用栈清空。
  6. 清空微任务队列3: Promise microtask 输出。
  7. 执行 requestAnimationFrame 回调4: requestAnimationFrame 输出。在此回调内部,又有一个 Promise.then 被调度,进入微任务队列。
  8. 清空微任务队列 (第二次)5: Promise in rAF 输出。
  9. 浏览器可能会进行渲染。
  10. 取下一个宏任务setTimeout 回调执行,6: setTimeout 输出。

深入理解,提升代码质量

微任务和宏任务的区分,以及它们在事件循环中的精确执行顺序,是JavaScript异步编程的基石。掌握这一点,你将能够:

  • 准确预测代码行为: 不再为异步代码的输出感到困惑。
  • 编写高性能代码: 避免因不当的异步调度而导致的UI卡顿和响应延迟。
  • 调试更轻松: 知道任务在哪里被阻塞,在哪里被执行。
  • 深入理解框架原理: 许多现代JavaScript框架(如React, Vue)的调度机制都与事件循环、微任务和宏任务息息相关。

这是一个需要反复实践和思考的知识点。我鼓励大家多写代码,多做实验,亲手去验证这些执行顺序。只有这样,你才能真正将这些理论内化为自己的编程直觉。


希望这次讲座能彻底解开大家对JavaScript微任务和宏任务的疑惑。理解并熟练运用这些概念,将是你成为一名优秀JavaScript开发者的重要一步。

发表回复

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