发布于 2026-01-06 9 阅读
0

🧠 揭秘 JavaScript 中的宏任务和微任务:每个 Web 开发者都应该知道的知识💯

🧠 揭秘 JavaScript 中的宏任务和微任务:每个 Web 开发者都应该知道的知识💯

事件循环

让我们回顾一下网页浏览器事件循环的工作原理。事件循环由三个主要部分组成:

  1. JavaScript 引擎——一个执行 JavaScript 代码并提供对 Web API 访问的组件
  2. Web API——浏览器接口,例如 fetch、计时器和其他功能
  3. 事件队列——一种数据结构,用于累积 UI 事件和 Web API 发起的事件,例如获取执行结果。图片描述

事情是这样的:

  1. 代码执行:首先,执行调用栈上的同步代码,直到调用栈被清空为止。
  2. 事件队列检查:调用栈清空后,事件循环会检查事件队列。例如,获取操作已结束,或者我们订阅了某个 UI 事件(例如点击事件),但该事件已被丢弃。
  3. **将事件压入堆栈:**如果事件队列中有待处理的任务,事件循环会将它们压入调用堆栈以执行。
  4. 重复:这个过程会循环进行,处理事件并执行异步任务。为了更好地理解它的工作原理,让我们看一下下面的图表:

图片描述

  1. 首先,我们执行fetch请求
  2. 此任务已从堆栈移至 Web API
  3. 接下来,浏览器执行请求,并将执行结果以其内部某种形式返回到队列中。
  4. 接下来,事件循环将fetch结果放入堆栈,由 JS 引擎进行处理。

💡请注意,事件循环并非 JavaScript 引擎的一部分。

微任务和宏任务

在查看代码示例之前,让我们先明确定义一下微任务和宏任务的含义:

  • 微任务是指优先级高于宏任务的延迟任务。微任务的例子包括:
    • 使用 Promise 的操作(例如Promise.then(),,,Promise.catch()等等fetch
    • 变异队列操作(例如,用于观察 DOM 更改的操作MutationObserver API
    • 与 queueMicrotask() 相关的操作——该函数用于显式添加微任务
  • 宏任务是优先级低于微任务的延迟任务,例如:
    • 定时器处理(setTimeout,setInterval)
    • 用户输入事件处理(例如,点击、滚动)
    • AJAX 请求执行

Web 浏览器中的异步性

我们来看看网页浏览器中可用的两种异步行为:

  1. Web API 异步机制。我们来看看 fetch 请求是如何工作的。我们执行一个 fetch 请求,更准确地说,是由 JS 引擎执行;然后,该请求被传递给 Web API,而主浏览器线程则继续执行其他任务。fetch 请求完成后,Web API 会将执行结果返回到队列中,然后这些执行结果会被 EventLoop 取出,并由 JS 引擎进行处理。
  2. JS 代码的异步性(伪异步)。如果我们把一个从 0 循环到 1,000,000,000 的函数包装在一个 Promise 中,你觉得会发生什么?没错,这会导致浏览器卡死。为什么呢?原因在于,通过将代码包装在 Promise 中,我们只是要求浏览器延迟执行这段代码,直到浏览器的队列处理完毕。而将同步代码包装在 Promise 中Promise或直接运行,queueMicrotask只会让我们的代码几乎排在队列的末尾。为什么是“几乎”呢?我们将在下文中进一步探讨。> 💡有没有办法异步地在单独的浏览器线程中执行 JS 代码?答案是肯定的,可以使用 Web Worker API。你可以在这里了解更多信息:

宏任务示例

宏任务是类似定时器(setTimeout、setInterval)的任务,它们被安排连续运行。



console.log('Start');

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

console.log('End');


Enter fullscreen mode Exit fullscreen mode

在这个例子中,输出顺序如下:

  1. Start
  2. End
  3. Macrotask completed即使setTimeout延迟为 0 毫秒,其回调也不会立即执行,而是会被安排为宏任务,并在执行优先级的最后执行。

微任务示例

微任务包括 Promise 及其关联的处理程序等。



console.log('Start');

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

console.log('End');


Enter fullscreen mode Exit fullscreen mode

在这种情况下,执行顺序将如下所示:

  1. Start
  2. End
  3. Microtask completed

由于微任务的优先级高于宏任务,因此 Promise 回调会在当前执行循环完成后立即执行,但在下一个事件循环之前执行,即使事件循环中已经有宏任务。

组合示例:微任务 + 宏任务

现在让我们来看一个包含微任务和宏任务的组合示例:



console.log('Start');

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

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

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

console.log('End');


Enter fullscreen mode Exit fullscreen mode

执行顺序如下:

  1. Start
  2. End
  3. Microtask completed
  4. Microtask 2 completed
  5. Macrotask completed

这个例子表明,即使微任务(这里是 Promise)在代码中的调度位置靠后,它们也会在宏任务(这里是定时器)之前执行。这突显了它们在事件循环中的优先级。

💡 注意优先级顺序:首先,所有微任务都会被执行,然后才会执行宏任务。

页面渲染示例

当我写到宏任务会在所有微任务执行完毕后才执行时,我做了一些简化。实际上,页面渲染可能发生在微任务执行requestAnimationFrame完毕之后。如果您使用 `get_meta()`,这些调用可以在宏任务执行之前执行。浏览器会尝试将 `get_meta()` 插入requestAnimationFrame到最近的渲染中。所有 `get_meta()` 调用requestAnimationFrame大约每 16.67 毫秒发生一次,但严格来说,是在所有微任务执行完毕之后。让我们来看下面的例子:



console.log('Synchronized start');

//A macrotask with setTimeout
setTimeout(() => {
  console.log('Macrotask 1: setTimeout');
}, 0);

//A macrotask with setTimeout
setTimeout(() => {
  console.log('Macrotask 2: setTimeout');
}, 0);

//A microtask with Promise
Promise.resolve()
   .then(() => {
     console.log('Microtask 1: first promise completed');
   });
   .then(() => {
     console.log('Microtask 2: second promise completed');
   });

//First requestAnimationFrame to update animation
requestAnimationFrame(() => {
  console.log('First requestAnimationFrame: animation update');
});

//Second requestAnimationFrame to update animation
requestAnimationFrame(() => {
  console.log('Second requestAnimationFrame: animation update');
});

console.log('Synchronized end');


Enter fullscreen mode Exit fullscreen mode

输出顺序如下:

  1. Synchronized start
  2. Synchronized end
  3. Microtask 1: first promise completed
  4. Microtask 2: second promise completed
  5. First requestAnimationFrame: animation update
  6. Second requestAnimationFrame: animation update
  7. Macrotask 1: setTimeout
  8. Macrotask 2: setTimeout

我们很幸运,rAF 调用在宏任务之前就完成了。如果我们再次运行这段代码,rAF 和微任务的顺序可能会互换。

宏任务调用微任务的示例



console.log('Script start');

setTimeout(() => {
  console.log('Macrotask: setTimeout');

  //A microtask created inside a macrotask
  Promise.resolve().then(() => {
       console.log('Microtask: processing a promise inside setTimeout');
  });
}, 0);

Promise.resolve().then(() => {
  console.log('Microtask: processing the first promise');
});

console.log('End of script');


Enter fullscreen mode Exit fullscreen mode

输出顺序如下:

  1. Script start
  2. End of script
  3. Microtask: processing the first promise
  4. Macrotask: setTimeout
  5. Microtask: processing a promise inside setTimeout

这里需要注意的是,当微任务创建在宏任务内部时,浏览器会遇到这样的情况:在宏任务执行其回调函数之前,浏览器并不知道其中存在微任务。

事件循环上下文

在这一部分,我想让大家关注具有自身事件循环的独立上下文:

  • Web 浏览器主上下文
  • iframe
  • Web Worker 的每个元素都有其自身独立的事件循环,彼此互不重叠。使用 Web Worker,一切都非常简单明了:任务被发送到 Worker,并在独立于主浏览器线程的环境下进行处理。

使用 Iframe 时,情况稍微复杂一些。虽然 Iframe 有自己的事件循环,但它并没有独立的执行线程。但这并不意味着 Iframe 中执行耗时的任务会导致主浏览器标签页卡死;只有 Iframe 会卡死,主标签页仍会继续工作,即使浏览器会通知用户某些内容卡住了。

微任务和宏任务的哲学

直到 2015 年,该标准才包含微任务;直到 2014 年,才包含 Web Workers。而 Web WorkersrequestAnimationFrame直到 2011 年才出现。开发者只能使用基于宏任务或 Timer API 的异步方法。考虑到这些,宏任务过去是、现在仍然是一个强大的工具。

整体架构如下:没有宏任务,没有 rAF,没有 Web Worker,也没有 party。想象一下,你需要客户端进行一些复杂的计算,或者你想实现一些无法通过 CSS 实现的自定义动画,并且需要在不阻塞浏览器窗口的情况下完成任务。浏览器不支持多线程,我们只有一个单线程的 EventLoop。我们应该如何处理这种情况?我们将任务分解成多个子任务,并逐个执行,逐步释放进程资源,以便进行渲染、处理事件和查询。

我们来看一个例子:



function processArrayInChunks(array, chunkProcessingTime) {
    let index = 0;
    const startTime = Date.now();

    function processChunk() {
        const chunkStartTime = Date.now();

        while (index < array.length && (Date.now() - chunkStartTime < chunkProcessingTime)) {
            // A processing example: increase each element of the array by 1
            array[index] += 1;
            index++;
        }

        if (index < array.length) {
            console.log(`Processed ${index} elements so far...`);
            setTimeout(processChunk, 1000); // Schedule the next chunk immediately after the current one
        } else {
            console.log(`Completed processing ${array.length} elements in ${Date.now() - startTime} ms`);
        }
    }

    processChunk();
}

// Creating a large array
const largeArray = new Array(1000000).fill(0);

// We start processing the array, limiting the execution time of the subtask to 17 milliseconds
processArrayInChunks(largeArray, 17);


Enter fullscreen mode Exit fullscreen mode

这里有什么?

  • processArrayInChunks获取数组以及每个子任务的处理时间。它启动将任务分解成多个部分的过程。
  • processChunk通过递归调用setTimeout。它一次处理数组的一部分,将执行时间限制在指定的持续时间内(chunkProcessingTime)。
  • 当当前索引到达数组末尾时,循环结束。完成后,显示总处理时间。

💡 为了减慢任务的执行速度,我们必须以秒为间隔运行 setTimeout。

由于每次只处理一个宏任务,我们实现了后台任务执行,而不会阻塞页面。

同样的逻辑也适用于浏览器事件。开发者订阅某个事件,显然这会改变 DOM 状态;改变之后,浏览器进程会被释放(例如,渲染结束),然后它会执行下一个宏任务。

💡 顺便说一下,是的,浏览器 UI 事件也是宏任务。

综上所述:宏任务是一种强大的浏览器机制,旨在“手动”非阻塞地执行大型任务。所谓“大型”,指的是“将一个大型任务分解成若干个小的子任务”。

请求动画帧

该标准正在不断发展,现在它包含了 requestAnimationFrame,旨在优化渲染并避免开发者使用 Timer API 执行复杂的自定义动画。
其核心思想是请求浏览器在最近的帧上安排视觉变化。而且不仅仅是一次变化,而是所有可以在下一帧执行的 rAF。让我们来看一个例子:



let lastTimestamp = Date.now();

function heavyTask() {
  const start = Date.now();

  const workloadPeriod = Math.random() * 10;

  while (Date.now() - start < workloadPeriod) {
    // Artificial loading
  }
  console.log("Heavy task is finished!");
}

let frame = 0;
const runFrame = () => {
  frame++;
  console.log("frame", frame, Date.now() - lastTimestamp);

  lastTimestamp = Date.now();

  if (frame < 10) {
    requestAnimationFrame(runFrame);
  }
};

requestAnimationFrame(runFrame);

for (let i = 0; i < 50; i++) {
  setTimeout(() => {
    requestAnimationFrame(() => {
      console.log("rAF from a macrotask", Date.now() - lastTimestamp);
    });
    heavyTask();
  }, 0);
}


Enter fullscreen mode Exit fullscreen mode

让我们来分析一下这段代码的功能。首先,我们启动 rAF,它不会以任何方式加载帧日志。理想情况下,它应该每 17 毫秒运行一次。接下来,通过循环,我们创建 50 个宏任务,每个宏任务会暂停事件循环 0 到 10 毫秒。同时,我们会在控制台中显示帧号,并跟踪与上一帧的差异。这个例子演示了浏览器如何调度 rAF 的执行。

首先,我们来明确一下这段代码的作用。我们运行 rAF,它并未以任何方式加载到帧日志中;理想情况下,它应该每 17 毫秒运行一次。在循环的后续部分,我们创建了 50 个宏任务,用于在 0 到 10 毫秒之间暂停事件循环。我们还在控制台中显示帧号,并跟踪与上一帧的差异。我们需要这个示例来演示浏览器如何调度 rAF 的执行。

在浏览器控制台中,我们会看到类似这样的信息:

  • frame 1- rAF 在我们的代码执行开始后 2 毫秒被处理。

  • Heavy task is finished!- 已完成 15 个宏任务

  • frame 2自上次 rAF 以来已过去 104 毫秒

  • rAF from a macrotask- 由 15 次 rAF 引起,间隔为 0 毫秒

  • Heavy task is finished!- 已完成 18 个宏任务

  • frame 3自上次 rAF 以来已过去 105 毫秒

  • rAF来自宏任务 - 由 18 个 rAF 引起,间隔为 0 毫秒

  • Heavy task is finished!- 已完成 16 个宏任务

  • frame 4自上次 rAF 以来已过去 102 毫秒

  • rAF from a macrotask- 由 16 次 rAF 引起,间隔为 0 毫秒

  • Heavy task is finished!- 已完成 19 个宏任务

  • frame 5自上次 rAF 以来已过去 102 毫秒

  • rAF from a macrotask- 由 19 次 rAF 引起,间隔为 0 毫秒

  • Heavy task is finished!- 已完成 20 个宏任务

  • frame 6自上次 rAF 以来已过去 104 毫秒

  • rAF from a macrotask- 由 20 次 rAF 引起,间隔为 0 毫秒

  • Heavy task is finished!- 已完成 12 个宏任务

  • frame 7自上次 rAF 以来已过去 64 毫秒

  • rAF来自宏任务 - 由 12 个间隔为 0 毫秒的 rAF 引起

  • frame 8自上次 rAF 以来已过去 0 毫秒

  • frame 9自上次 rAF 以来已过去 13 毫秒

  • frame 10自上次 rAF 以来已过去 17 毫秒

让我稍微解释一下控制台输出。每次setTimeout调用rAF都会触发一个占用大量资源的任务,阻塞事件循环。一旦浏览器意识到rAFs已经积累了足够的渲染数据,它就会尝试应用通过 `setTimeout` 请求的更改rAF。从日志来看,浏览器在执行请求之前能够执行 15-20 个微任务rAF,并且平均rAF每 100 毫秒调度一次累积的调用。

由此我们可以得出什么结论?首先,浏览器力求rAFs在下一帧(即每 17 毫秒)中执行;其次,我们可以清楚地看到,浏览器试图在下一次rAF调用之前调度尽可能多的任务,并试图将rAF尽可能多的由宏任务发起的调用放在一帧中。

💡 这里我要强调的是,这个例子展示了浏览器与 `<div>` 交互的基本方面requestAnimationFrame。在实际应用中,`<div>` 调用requestAnimationFrame涉及影响 DOM 并消耗 CPU 资源的操作。这可能导致浏览器尝试将多个更改合并到单个渲染周期中,但也可能为了优化性能而将某些 DOM 更改延迟到下一帧。

微任务

随着时间的推移,该标准纳入了旨在优化浏览器渲染过程的微任务,并收集随后会影响 DOM 的最大数量的应用程序状态更改,然后才执行渲染

💡 问题在于,浏览器渲染过程是一个非常耗时且复杂的操作。浏览器开发者会尽可能地对其进行优化;为此,标准中引入了额外的事件循环参与者,例如 rAF 和微任务的概念。

为了证明渲染会在所有宏任务执行完毕后完成,让我们运行以下代码:



Promise.resolve("Data loaded").then((message) => {
    console.log(message);
  requestAnimationFrame((timestamp) => {
    console.log('Animation for:',message, timestamp);
  });
});

Promise.resolve("Settings loaded").then((message) => {
    console.log(message);
  requestAnimationFrame((timestamp) => {
    console.log('Animation for:',message, timestamp);
  });
});

Promise.resolve("User data loaded").then((message) => {
  console.log(message);
  requestAnimationFrame((timestamp) => {
    console.log('Animation for:' ,message, timestamp);
  });
});


Enter fullscreen mode Exit fullscreen mode

输出顺序如下:

  1. Data loaded
  2. Settings loaded
  3. User data loaded
  4. Animation for: Data loaded 9516312.2
  5. Animation for: Settings loaded 9516312.2
  6. Animation for: User data loaded 9516312.2

如您所见,所有rAF调用均在微任务之后执行,且时间戳相同。这表明,在本例中,浏览器已将动画(以及相应的 DOM 更改)安排在下一次页面刷新周期中执行。

在本模块的最后,我想再次强调微任务和宏任务之间的区别:

  • 浏览器依次执行所有微任务,然后进行渲染和宏任务。
  • 微任务的执行没有任何延迟,例如,使用 Timer API 时可能会出现这种情况(最小延迟为 4 毫秒)。
  • 宏任务的执行遵循以下原则:一个宏任务占用进程后,必须先释放事件循环以允许其他任务执行,然后才能执行下一个宏任务——当然,前提是存在下一个宏任务。
  • 微任务的执行顺序是有保证的。也就是说,如果您决定使用微任务来延迟某些任务,它们将严格按照创建顺序执行。您可能会问,这是什么意思fetch API?Fetch 是一个 Promise,这意味着它是一个微任务,也意味着它必须按照微任务的调用顺序执行!当您使用fetchFetch 发起 HTTP 请求时,它会异步执行并返回一个 Promise。这个 Promise 本身并不是一个微任务,但它会在请求完成后(收到结果或请求失败)被添加到微任务队列中。

console.log('脚本开始');

// 通过 fetch 发送异步请求
fetch(' https://jsonplaceholder.typicode.com/todos/1' )
.then(response => response.json())
.then(data => {
console.log('接收到的数据:', data);
})
.catch(error => {
console.error('请求错误:', error);
});

// 添加微任务
队列Microtask(() => {
console.log('正在执行微任务');
});

// 设置一个延迟为 0 毫秒的定时器
setTimeout(() => {
console.log('执行宏任务 (setTimeout)');
}, 0);

console.log('脚本结束');


1. `Script start`
2. `End of script`
3. `Fetch call`
4. `Executing microtask (queueMicrotask)`
5. `Executing microtask (setTimeout)`
6. `Microtask then(response => response.json())`
7. `Microtask then(data => ...)`
8. `Miscotask catch(error => ...)`

Here we can see that, even though fetch was called before the macrotask, the result of the request was processed after the macrotask was executed

##Conclusion
In this article, we explored the differences between macrotasks and microtasks, and also touched a little on page rendering. Understanding the differences in tasks and their priorities helps developers create more performant, responsive, and reliable web apps, as well as effectively manage asynchronous operations and events.
Enter fullscreen mode Exit fullscreen mode
文章来源:https://dev.to/bymarsel/unraveling-macrotasks-and-microtasks-in-javascript-what-every-developer-should-know-53mc