skip to content
月与羽

宏任务和微任务

/ 19 min read

1. 事件循环的基本过程

  • JavaScript 是单线程的,所有代码都在一个主线程上运行。

  • 执行模型是:

    同步代码 → 微任务 → 宏任务 → 微任务 → 宏任务 → …

也就是说:

  1. 执行主线程上的同步代码。

  2. 同步代码执行完毕后,先清空所有 微任务队列(microtask queue)。

  3. 再去取出一个 宏任务(macro task) 执行。

  4. 宏任务执行完,再清空本轮产生的微任务。

  5. 不断循环。


2.  宏任务 vs 微任务的区别

  • 宏任务(Macro Task):整块的大任务,执行时间可能较长。

    • 例如:setTimeout、setInterval、setImmediate(Node)、requestAnimationFrame、I/O 事件等。
  • 微任务(Micro Task):轻量级、需要尽快执行的小任务。

    • 例如:Promise.then、process.nextTick(Node)、queueMicrotask、MutationObserver。

3.  为什么要区分?

如果只用一个统一的任务队列,会出现以下问题:

  1. 保证响应性

    • 如果所有任务都排队到宏任务队列中,某些轻量级的异步操作(比如 Promise.then)就可能被延迟到很久之后,用户交互可能卡顿。

    • 微任务可以在当前宏任务结束后立刻执行,保证小任务的快速响应。

  2. 避免过度阻塞

    • 如果微任务无限多,主线程可能一直不进入宏任务(比如渲染任务),页面就会卡死。

    • 所以设计上规定:微任务只能在一个宏任务执行完后批量清空,而不是无限插队。

  3. 保持一致性

    • 许多 API 的规范依赖这种调度模型。例如:Promise 的回调必须异步,但又要尽快执行,这就自然放进了微任务队列。

    • 而像 setTimeout 是基于定时器和事件循环的调度,所以归类到宏任务。


4. 一个例子

console.log('start');
setTimeout(() => {
console.log('macro');
}, 0);
Promise.resolve().then(() => {
console.log('micro');
});
console.log('end');

执行顺序:

start
end
micro // 微任务优先
macro // 宏任务后执行

✅ 总结:

区分 宏任务 和 微任务 是为了:

  • 提高程序的响应性(微任务优先处理短小任务);

  • 避免阻塞事件循环(宏任务确保渲染/UI 更新能被调度);

  • 保证规范一致性(Promise 等 API 行为明确)。


1. 宏任务:由 JavaScript 的宿主环境发起的任务

JavaScript 本身是一门语言,它只定义了语法和核心对象(如 Object, Array, Promise 等)。但这门语言需要一个运行环境才能执行,这个环境就是宿主环境。最常见的宿主环境是 浏览器 和 Node.js。

这些宿主环境提供了 JavaScript 语言本身不具备的功能,例如:

  • 浏览器环境提供:DOM 操作、BOM (浏览器对象模型)、网络请求 (fetch)、定时器 (setTimeout)、用户交互事件(如 click, mouseover)。
  • Node.js 环境提供:文件系统操作 (fs 模块)、HTTP 服务器 (http 模块)、进程管理 (process)、定时器 (setTimeout)。

” 由宿主环境发起 ” 意味着这些任务的触发和管理是由宿主环境的特定模块或线程来完成的。JavaScript 引擎本身并不知道如何直接操作 DOM 或发起一个真正的网络请求。

执行流程是这样的:

  1. JS 调用 API:你的 JavaScript 代码调用了一个宿主环境提供的 API,比如 setTimeout(() => { ... }, 1000)。
  2. 移交控制权:JavaScript 引擎将这个任务(例如“在 1000 毫秒后执行这个回调函数”)移交给宿主环境的相关模块(例如浏览器的定时器模块)。
  3. 宿主环境处理:宿主环境在后台独立处理这个任务(例如,一个专门的定时器线程开始计时)。JavaScript 引擎此时可以继续执行后续的同步代码,不会被阻塞。
  4. 任务完成,加入队列:当任务完成时(例如,1000 毫秒已到,或者网络请求收到了响应),宿主环境会将预设的回调函数作为一个 宏任务 放入宏任务队列(Task Queue)中,等待事件循环的调度。

总结来说,宏任务是 JavaScript 与外部世界(浏览器、操作系统)交互的桥梁。它们通常与 I/O、定时、用户交互等异步操作相关,其调度和执行由宿主环境主导,JavaScript 引擎只是在事件循环的特定时机从队列中取出并执行它。


2. 微任务:通常由 JavaScript 自身发起的任务

微任务通常不是为了与宿主环境的底层功能(如 I/O)直接交互,而是为了在 当前代码执行序列的末尾、下一次事件循环开始之前,提供一个高优先级的异步调度方式。 它更多是 语言层面 的一种异步机制。

” 由 JavaScript 自身发起 ” 意味着这些任务的创建和管理主要在 JavaScript 引擎内部进行。最典型的例子就是 Promise。

当一个 Promise 的状态从 pending 变为 fulfilled 或 rejected 时,其 .then(), .catch(), .finally() 中注册的回调函数就会被放入微任务队列(Microtask Queue)。这个过程是由 JavaScript 引擎直接处理的,而不需要宿主环境的介入。

执行流程是这样的:

  1. JS 创建微任务:你的代码执行了 Promise.resolve().then(() => { ... }) 或 await 关键字。
  2. 引擎内部处理:JavaScript 引擎识别到这个操作,并将回调函数放入与当前执行上下文关联的 微任务队列 中。
  3. 高优先级执行:当当前的宏任务(比如你正在执行的 <script> 脚本)中的所有同步代码执行完毕后,事件循环 不会 立即去取下一个宏任务。相反,它会立刻检查微任务队列。
  4. 清空队列:事件循环会循环执行微任务队列中的所有任务,直到队列被完全清空。如果在执行微任务的过程中又产生了新的微任务,它们也会被加入队列并在同一周期内执行完毕。

总结来说,微任务提供了一种“插队”的能力。它保证了一些逻辑可以在当前宏任务结束后、任何其他宏任务或 UI 渲染开始前,以最高优先级立即执行。这对于确保状态更新的一致性和执行顺序至关重要,async/await 的实现就深度依赖于微任务。

核心对比

特性宏任务微任务
任务来源宿主环境(浏览器、Node.js)
如:定时器模块、I/O 线程、用户事件分发器
JavaScript 引擎
如:Promise 状态改变、MutationObserver
主要目的处理与外部世界的非阻塞异步交互(如 I/O、定时)。在当前任务之后,提供一个高优先级的延迟执行机制,用于状态同步和逻辑排序。
执行模型由宿主环境将任务放入队列,等待事件循环调度。由 JS 引擎将任务放入队列,在当前宏任务执行完毕后立即清空队列。
与 JS 引擎关系JS 引擎是被动执行者,由事件循环“喂给”它任务。JS 引擎是主动创建者和执行者。
任务类型任务来源(代码层面)常见环境
宏任务setTimeout, setInterval通用
用户交互事件 (click, input 等)浏览器
网络 I/O (XMLHttpRequest, fetch 的完成事件)浏览器
UI 渲染, requestAnimationFrame浏览器
setImmediate, fs 模块等 I/O 回调Node.js
MessageChannel, postMessage浏览器
微任务Promise.then/.catch/.finally通用
async/await 关键字之后的部分通用
MutationObserver 的回调浏览器
queueMicrotask()浏览器/新版Node.js
process.nextTick() (最高优先级)Node.js

场景一:高效的 DOM 更新 (例如在 Vue/React 等框架中)

问题背景: 假设我们有一个列表,用户可以快速地、连续地点击一个按钮来向列表中添加多项内容。

// HTML: <button id="addBtn">Add Item</button><ul id="list"></ul>
const btn = document.getElementById('addBtn');
const list = document.getElementById('list');
let count = 0;
function addItem() {
count++;
const newItem = document.createElement('li');
newItem.textContent = `Item ${count}`;
list.appendChild(newItem);
}
// 假设用户在10毫秒内快速点击了5次按钮
btn.click(); // 模拟点击
btn.click();
btn.click();
btn.click();
btn.click();

如果没有微任务,只有宏任务:

  1. 每一次 btn.click() 都会触发一个宏任务(用户交互事件)。
  2. 事件循环处理第一个点击事件,执行 addItem(),向 DOM 中添加一个 <li>。浏览器可能会立即进行一次 UI 重绘(repaint/reflow)。
  3. 事件循环处理第二个点击事件,再次执行 addItem(),又添加一个 <li>。浏览器又进行一次重绘。
  4. …如此重复 5 次。

结果: 5 次独立的 DOM 修改,可能导致 5 次昂贵的 UI 重绘。这在性能上是极大的浪费,尤其当 DOM 结构复杂时,可能导致页面卡顿。

引入微任务后的解决方案 (Vue/React 等框架的思路): 现代前端框架不会在数据变化时立即去操作 DOM。它们利用微任务来“批量处理”和“合并”更新。

// 简化的Vue更新逻辑伪代码
let pendingUpdate = false;
let dataQueue = []; // 用来收集数据变化
function onDataChange(newData) {
dataQueue.push(newData);
if (!pendingUpdate) {
pendingUpdate = true;
// 将真正的DOM更新操作调度为一个微任务
Promise.resolve().then(flushUpdates);
}
}
function flushUpdates() {
// 在微任务中,一次性处理所有的数据变更
// 例如,计算出最终的DOM差异(Virtual DOM diff)
// 然后只进行一次真实的DOM操作
updateDOM(dataQueue);
dataQueue = []; // 清空队列
pendingUpdate = false;
}
// 模拟快速的数据变更
onDataChange({ item: 1 });
onDataChange({ item: 2 });
onDataChange({ item: 3 });

执行流程分析:

  1. 第一次调用 onDataChange,将更新推入队列,并注册一个微任务 flushUpdates。pendingUpdate 设为 true。
  2. 第二次、第三次调用 onDataChange,因为 pendingUpdate 是 true,所以只是将数据推入队列,不会再注册新的微任务。
  3. 当前宏任务(执行 onDataChange 的脚本)结束。
  4. 事件循环开始执行微任务队列,执行 flushUpdates。
  5. flushUpdates 函数遍历 dataQueue 中收集的所有变更,进行一次计算,然后只对 DOM 进行一次最终的、批量的更新。
  6. 微任务执行完毕,浏览器进行一次 UI 渲染,完美地展示最终结果。

设计之妙: 微任务提供了一个“缓冲期”。它允许我们在当前宏任务的所有同步代码执行完毕后,但在浏览器渲染或处理任何其他宏任务(如新的用户点击)之前,执行一个“收尾”和“合并”的操作。这极大地提高了渲染性能。


场景二:可靠的 Promise 链式调用

问题背景: 我们需要从服务器获取用户信息,然后根据用户信息获取其对应的订单列表。

fetch('/api/user/1')
.then(response => response.json()) // 微任务1
.then(user => { // 微任务2
console.log(`获取到用户: ${user.name}`);
return fetch(`/api/orders/${user.id}`);
})
.then(response => response.json()) // 微任务3
.then(orders => { // 微任务4
console.log(`获取到 ${orders.length} 个订单`);
})
.catch(error => {
console.error('出错了:', error);
});
console.log('请求已发送');

如果没有微任务,用 setTimeout (宏任务) 模拟 then: 每个 .then 的回调都会被 setTimeout(callback, 0) 放到宏任务队列的末尾。

  1. '请求已发送' 被打印。
  2. 第一个 fetch 完成后,其第一个 .then 的回调(解析 json)被放入宏任务队列。
  3. 此时,如果用户恰好点击了一个按钮,这个点击事件的宏任务也进入了队列,并且可能排在了解析 json 的任务前面。
  4. 事件循环可能会先处理点击事件,然后再回来处理 fetch 的回调。
  5. 整个链条的执行被不断地被其他不相关的宏任务打断,执行顺序变得不可预测,延迟大大增加。

引入微任务后的解决方案: Promise.then 的回调是微任务。

  1. '请求已发送' 被打印。
  2. 第一个 fetch(这是一个宏任务,由浏览器网络模块处理)完成后,其 .then 回调(微任务 1)被放入微任务队列。
  3. 当前宏任务(若有)结束后,事件循环立即清空微任务队列。
  4. 执行微任务 1(解析 json),它返回一个新的 Promise。
  5. 微任务 1 执行完毕后,微任务 2(打印用户名并发出第二个 fetch)被放入微任务队列。
  6. 事件循环继续清空微任务队列,执行微任务 2。第二个 fetch 被发起。
  7. …以此类推。

设计之妙: 微任务确保了 Promise 链的 执行连续性 和 高优先级。一个 then 的输出可以几乎无缝地传递给下一个 then,中间不会被任何其他宏任务(如 setTimeout、用户点击)打断。这使得基于 Promise 的异步流控制变得极为可靠和高效,是构建复杂异步逻辑的基石。


场景三:响应用户输入(例如输入框的实时校验)

问题背景: 一个输入框,要求用户输入时立即进行格式校验,如果格式错误,则显示提示信息。

// HTML: <input id="username"><div id="errorMsg"></div>
input.addEventListener('input', (event) => {
const value = event.target.value;
// 立即进行同步校验
if (value.length < 5) {
errorMsg.textContent = '用户名长度不能小于5位!';
} else {
errorMsg.textContent = '';
}
// 假设我们还想在校验后立即做点别的事情,比如更新一个状态
// Promise.resolve().then(() => updateState(isValid));
});

分析:

  1. 用户输入字符,触发 input 事件,这是一个宏任务。
  2. 事件回调函数开始执行。同步代码(if/else 判断和修改 errorMsg.textContent)被立即执行。
  3. 这时,DOM 的变化并没有立即渲染到屏幕上。它只是被暂存了。
  4. 如果代码中包含一个微任务(如 Promise.resolve().then(...)),它会在这个宏任务执行的最后阶段被执行。
  5. 整个 input 事件回调宏任务执行完毕,所有微任务也被清空。
  6. 事件循环进入渲染阶段,浏览器将 errorMsg 的文本变化和其他可能的 DOM 变化一次性地渲染到屏幕上。

设计之妙: 这个模型保证了 响应的及时性 和 渲染的聚合性。

  • 用户的操作(宏任务)得到立即响应(回调函数执行)。
  • 所有在这次操作中引发的逻辑(同步代码 + 微任务)都在一个“原子”操作内完成。
  • 最后,所有视觉上的变化被合并到一次渲染中,避免了不必要的界面闪烁和性能开销。

如果将同步校验也设计为宏任务(例如用 setTimeout),那么用户输入后,错误提示的显示将会延迟一个事件循环周期,响应会明显变慢。