在事件循环上下文中,微任务和宏任务的区别

259

我刚刚读完了 Promises/A+ 规范,并遇到了单词 "microtask" 和 "macrotask": 参见http://promisesaplus.com/#notes

我以前从未听说过这些术语,现在我很好奇它们之间的区别是什么?

我已经尝试在网上找一些信息,但我找到的只有 w3.org 存档中的这篇文章(它对我没有解释区别):http://lists.w3.org/Archives/Public/public-nextweb/2013Jul/0018.html

此外,我还发现了一个名为“macrotask”的 npm 模块:https://www.npmjs.org/package/macrotask。 同样,它也没有明确说明区别。

所有我知道的是,它与事件循环有关,如https://html.spec.whatwg.org/multipage/webappapis.html#task-queuehttps://html.spec.whatwg.org/multipage/webappapis.html#perform-a-microtask-checkpoint中所述。

我知道理论上应该能够根据 WHATWG 规范自己提取区别。但我相信其他人也可以从专家的简短解释中受益。


3
简而言之:多个嵌套事件队列。你甚至可以自己实现一个:while (task = todo.shift()) task(); - Bergi
3
想要更多细节的读者可以阅读《JavaScript 忍者秘籍》第二版第13章“事件生存指南”。 - Ethan
6个回答

366
事件循环的一次循环将从宏任务队列中处理恰好一个任务(在WHATWG规范中,此队列称为任务队列)。完成此宏任务后,将处理所有可用的微任务,即在同一循环周期内。当处理这些微任务时,它们可以排队更多的微任务,直到完全排空微任务队列。

这会有什么实际影响?

如果微任务递归地排队其他微任务,可能需要很长时间才能处理下一个宏任务。这意味着,您可能会遇到阻塞用户界面或在应用程序中闲置的已完成的I/O。

但是,关于Node.js的process.nextTick函数(它排队微任务),它具有内置保护措施,即通过process.maxTickDepth的方式来防止阻塞。该值默认设置为1000,在达到此限制后会削减对微任务的进一步处理,以便处理下一个宏任务

那么什么时候使用什么?

基本上,当您需要以同步方式异步执行任务时(即您会说在最近的未来执行此(微)任务)使用微任务。否则,请使用宏任务

例子

宏任务:setTimeoutsetIntervalsetImmediaterequestAnimationFrameI/O、UI渲染。
微任务:process.nextTickPromisequeueMicrotaskMutationObserver


4
尽管事件循环中有一个微任务检查点,但大多数开发人员不会在这里遇到微任务。当JS堆栈为空时,将处理微任务。这可能会在任务中发生多次,甚至在事件循环的渲染步骤中也可能发生。 - JaffaTheCake
5
很久以前就移除了 process.maxTickDepth:https://github.com/nodejs/node/blob/d896f03578f2312aaae347de3b5a0b26882effc8/doc/changelogs/CHANGELOG_ARCHIVE.md#20130626-version-0113-unstable - RidgeA
2
你也可以使用queueMicrotask()方法来添加一个新的微任务。 - ZoomAll
1
@RonInbar说:“微任务的重要性在于它们能够异步执行任务,但按特定顺序执行。”来自MDN。您安排的任务将在循环后执行,但它们将按照您安排它们的顺序执行。 - David Harkness
1
这篇文章由Jake Archibald撰写,帮助我理解任务、微任务、队列和调度之间的区别:https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/ - Andrii Lukianenko
显示剩余4条评论

153

规范中的基本概念:

  • 事件循环有一个或多个任务队列。(任务队列是宏任务队列)
  • 每个事件循环都有一个微任务队列。
  • 任务队列=宏任务队列!=微任务队列
  • 任务可以被推入宏任务队列或微任务队列。
  • 当一个任务被推入队列(微/宏)时,我们意味着准备工作已经完成,因此该任务现在可以执行。

事件循环进程模型如下:

调用栈为空时,执行以下步骤-

  1. 选择任务队列中最老的任务(任务A)
  2. 如果任务A为空(表示任务队列为空),跳转到步骤6
  3. 将“当前正在运行的任务”设置为“任务A”
  4. 运行“任务A”(即运行回调函数)
  5. 将“当前正在运行的任务”设置为null,移除“任务A”
  6. 执行微任务队列
    • (a).在微任务队列中选择最旧的任务(任务x)
    • (b).如果任务x为空(表示微任务队列为空),跳转到步骤(g)
    • (c).将“当前正在运行的任务”设置为“任务x”
    • (d).运行“任务x”
    • (e).将“当前正在运行的任务”设置为null,移除“任务x”
    • (f).在微任务队列中选择下一个最旧的任务,跳转到步骤(b)
    • (g).完成微任务队列;
  7. 跳转到步骤1。

简化的流程模型如下:

  1. 运行宏任务队列中最旧的任务,然后将其删除。
  2. 运行微任务队列中所有可用的任务,然后将它们删除。
  3. 下一轮:运行宏任务队列中的下一个任务(跳转到步骤2)。

需要记住的事情:

  1. 当一个任务(在宏任务队列中)正在运行时,可能会注册新事件。因此可能会创建新的任务。下面是两个新创建的任务:
    • promiseA.then()的回调函数是一个任务
      • promiseA被解析/拒绝:该任务将被推入当前事件循环的微任务队列。
      • promiseA处于挂起状态:该任务将在未来的事件循环(可能是下一轮)中被推入微任务队列。
    • setTimeout(callback,n)的回调函数是一个任务,并将被推入宏任务队列,即使n为0;
  2. 微任务队列中的任务将在当前轮次中运行,而宏任务队列中的任务必须等待下一轮事件循环。
  3. 我们都知道“click”、“scroll”、“ajax”、“setTimeout”等回调函数是任务,但我们也应该记住script标签中的js代码作为整体也是一个任务(一个宏任务)。

5
这是很好的解释!感谢分享!还有一件事要提一下,就是在Node.js中,setImmediate()是宏/任务,而process.nextTick()是微/作业。 - LeOn - Han Li
7
浏览器中的“paint”任务属于哪个类别? - Legends
5
我不确定自己是否错了,但我不太同意这个答案,微任务在宏任务之前运行。 https://codepen.io/walox/pen/yLYjNRq? - walox
7
目前的脚本执行也是一个宏任务。一旦所有同步代码完成,事件循环将优先处理微任务而不是宏任务。就像您的示例一样,在脚本执行后,超时回调位于宏任务/回调队列中,而Promise回调位于微任务队列中。由于已经完成了一个宏任务(主脚本执行),因此事件循环将优先处理Promise任务而不是超时任务。因此得出结果。 - Aakash Thakur
setTimeout 的最小毫秒数为 4,只有在时间到达后才会被推入任务队列。如果你有一个 setTimeout 和一个 Promise 回调... Promise 回调将先运行,然后 setTimeout 将在其后运行。 - nullspace
显示剩余2条评论

103

我认为我们无法将事件循环讨论与堆栈分开,所以:

JS有三个“堆栈”:

  • 标准堆栈用于所有同步调用(一个函数调用另一个函数等)
  • 微任务队列(或作业队列或微任务堆栈)用于所有具有更高优先级的异步操作(process.nextTick,Promises,Object.observe,MutationObserver)
  • 宏任务队列(或事件队列、任务队列、宏任务队列)用于所有具有较低优先级的异步操作(setTimeout,setInterval,setImmediate,requestAnimationFrame,I/O,UI渲染)
|=======|
| macro |
| [...] |
|       |
|=======|
| micro |
| [...] |
|       |
|=======|
| stack |
| [...] |
|       |
|=======|

事件循环的工作方式如下:

  • 从栈底到栈顶执行所有任务,仅当栈为空时,才检查上面的队列中有什么需要执行
  • 检查微任务队列并使用栈帮助执行其中的所有任务(如果需要),一个接一个地执行直到微任务队列为空或不需要执行,然后再检查宏任务队列
  • 检查宏任务队列并使用栈帮助执行其中的所有任务(如果需要)

只有当栈为空时,才会处理微任务队列。只有当微任务队列为空或不需要执行时,才会处理宏任务队列。

总结一下:微任务队列与宏任务队列几乎相同,但是那些任务(process.nextTick, Promises, Object.observe, MutationObserver)比宏任务具有更高的优先级。

微任务与宏任务类似,但优先级更高。

这里有一段“终极”代码可帮助您理解所有内容。


<div data-lang="js" data-hide="false" data-console="true" data-babel="false" class="snippet">
<div class="snippet-code">
<pre class="snippet-code-js lang-js prettyprint-override"><code>console.log('stack [1]');
setTimeout(() => console.log("macro [2]"), 0);
setTimeout(() => console.log("macro [3]"), 1);

const p = Promise.resolve();
for(let i = 0; i < 3; i++) p.then(() => {
    setTimeout(() => {
        console.log('stack [4]')
        setTimeout(() => console.log("macro [5]"), 0);
        p.then(() => console.log('micro [6]'));
    }, 0);
    console.log("stack [7]");
});

console.log("macro [8]");

/* Result: stack [1] macro [8]
stack [7], stack [7], stack [7]
macro [2] macro [3]
stack [4] micro [6] stack [4] micro [6] stack [4] micro [6]
macro [5], macro [5], macro [5] -------------------- but in node in versions < 11 (older versions) you will get something different
stack [1] macro [8]
stack [7], stack [7], stack [7]
macro [2] macro [3]
stack [4], stack [4], stack [4] micro [6], micro [6], micro [6]
macro [5], macro [5], macro [5]
more info: https://blog.insiderattack.net/new-changes-to-timers-and-microtasks-from-node-v11-0-0-and-above-68d112743eb3 */

事件循环首先检查宏任务队列,然后检查微任务队列,所以您的答案是不正确的。 - Piliponful
2
但是为什么存在这两个队列?微任务和宏任务之间的一般区别是什么? - Minh Nghĩa
微任务使我们能够在UI重新渲染之前执行某些操作,从而避免不必要的UI渲染,可能会显示不一致的应用程序状态。 - sonishubham65

13
宏任务包括键盘事件、鼠标事件、定时器事件(setTimeout)、网络事件、Html解析、Url变更等。宏任务代表一些离散且独立的工作。微任务队列具有更高的优先级,因此宏任务将等待所有微任务执行完毕后再执行。
微任务是更新应用程序状态并应在浏览器继续执行其他任务(如重新渲染 UI)之前执行的较小任务。微任务包括 Promise 回调和 DOM 变化等。微任务使我们能够在 UI 重新渲染之前执行某些操作,从而避免显示不一致的应用程序状态而导致不必要的 UI 渲染。
宏任务和微任务的分离使事件循环可以优先处理某些类型的任务;例如,优先考虑性能敏感型任务。
在单个循环迭代中,最多处理一个宏任务(其他宏任务将在队列中等待),而所有微任务都会被处理。
两个任务队列都放置在事件循环之外,以表示将任务添加到它们相应队列的操作发生在事件循环之外。否则,在执行 JavaScript 代码时出现的任何事件将被忽略。检测和添加任务的操作与事件循环分开进行。
  • 两种类型的任务都是逐个执行的。当一个任务开始执行时,它会一直执行到完成。只有浏览器可以停止任务的执行;例如,如果该任务占用了太多时间或内存。

  • 所有微任务应该在下一次渲染之前执行,因为它们的目标是在渲染发生之前更新应用程序状态。

  • 浏览器通常尝试每秒呈现页面60次,因为认为每秒60帧是动画看起来流畅的速率。如果我们想要实现流畅运行的应用程序,则单个任务及其生成的所有微任务理想情况下应在16毫秒内完成。如果一个任务执行时间超过几秒钟,浏览器将显示“脚本无响应”消息。

    参考来源:John Resig-《JavaScript忍者秘籍》


    1
    我对“为什么”很好奇,Promise使用微任务队列而不是宏任务队列,这个答案解释了一切!非常好的答案。 - Katie

    3

    我编写了一个遵循四个概念的事件循环伪代码:

    setTimeout、setInterval、setImmediate、requestAnimationFrame、I/O、UI 渲染都是宏任务队列的一部分。一个宏任务项将首先被处理。 process.nextTick、Promises、Object.observe、MutationObserver 都是微任务队列的一部分。事件循环将处理该队列中的所有项目,包括在当前迭代期间处理的项目。
    还有另一个称为动画队列的队列,其中包含将要处理的动画更改任务项。存在于此队列中的所有任务都将被处理(不包括在当前迭代期间添加的新任务)。如果到了渲染时间,它将被调用。
    渲染管道将尝试每秒渲染 60 次(每 16 毫秒一次)
    while (true){ // 1. 获取一个宏任务(最旧的)任务项 task = macroTaskQueue.pop(); execute(task);
    // 2. 在它们的队列中执行微任务,同时它们有项目(包括在此迭代期间添加的项目) while (microtaskQueue.hasTasks()){ const microTask = microtaskQueue.pop(); execute(microTask); }
    // 3. 如果自上次满足此条件以来已经过去了 16 毫秒 if (isPaintTime()){ // 4. 在它们的队列中执行 animationTasks,同时它们有项目(不包括在此迭代期间添加的项目) const animationTasks = animationQueue.getTasks(); for (task in animationTasks){ execute(task); }
    repaint(); // 通过渲染管道呈现页面更改 } }

    1
    这是我最喜欢的解释。简洁明了,重点突出。一旦你有了大体上的理解,就更容易理解其他答案中更精细的细节。 - oligofren
    1
    除了通过这种过度简化会忽略一些重要的事情,比如说微任务队列实际上是在每次调用execute()后都清空的。 - Kaiido

    -1

    你的问题在 MDN 的《深入:Microtasks 和 JavaScript 运行时环境》的任务与微任务部分中直接得到了回答:

    任务队列和微任务队列之间的区别很简单但非常重要:

    • 当从任务队列执行任务时,运行时会在事件循环的新迭代开始时执行队列中的每个任务。在迭代开始后添加到队列中的任务将等待下一次迭代才会运行。
    • 每当一个任务退出且执行上下文栈为空时,微任务队列中的每个微任务都会依次执行。不同之处在于,微任务的执行会一直持续到队列为空,即使在期间有新的微任务被调度。换句话说,在下一个任务开始运行之前以及当前事件循环迭代结束之前,微任务可以入队新的微任务,并且这些新的微任务将在之前执行。

    "执行上下文"与 user1660210 的上面的回答相关。


    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接