前端知库

宏任务和微任务分别有哪些?setTimeout、Promise、requestAnimationFrame 谁先执行?

常说的宏任务就是 HTML 规范里的任务,比如整段脚本、setTimeout 回调、点击事件、postMessage;微任务有 Promise 回调、queueMicrotask、MutationObserver。同一段脚本里安排的 Promise 回调最先执行,setTimeout 和 requestAnimationFrame 的先后取决于下一次渲染什么时候到,requestAnimationFrame 回调既不是任务也不是微任务,而是渲染更新步骤的一部分。

参考答案

简答

一段脚本里同时写了 setTimeout(fn, 0)、Promise.resolve().then(fn) 和 requestAnimationFrame(fn),Promise 的回调一定最先执行,另外两个谁先谁后不固定。

面试里说的“宏任务”,在 HTML 规范里就叫任务(task),规范里并没有“宏任务”这个词。整段脚本的执行、setTimeout 和 setInterval 到点后的回调、点击这类事件的派发、postMessage 的消息都是任务。微任务(microtask)主要有 Promise 的回调、await 之后的代码、queueMicrotask 和 MutationObserver 的回调。每个任务跑完,浏览器都会把微任务队列清空,所以同步代码之后最先轮到微任务。

requestAnimationFrame 的回调两种都不算。它被存进文档的动画帧回调表里,等浏览器决定更新渲染时,在样式计算和布局之前统一调用。下一次渲染时机由屏幕刷新率等因素决定,可能在定时器之前,也可能在之后。

要注意,背“宏任务列表”时别把不同来源的任务排出固定先后。规范允许浏览器在不同的任务队列之间自行挑选,能保证的只有同一个任务源里先进先出。

规范里有哪些队列

先把名词理清楚。HTML 规范的事件循环里有两类队列:若干个任务队列,和一个微任务队列。规范明确写了“微任务队列不是任务队列”。所以平时说的“宏任务队列”,对应的是规范里的任务队列,而且不止一个。

任务按来源分成不同的任务源(task source)。规范列出的通用任务源有 DOM 操作、用户交互(键盘、鼠标输入,比如 click 事件)、网络、导航与历史、渲染;setTimeout 用的是定时器任务源,postMessage 用的是 posted message 任务源。MDN 对任务的定义也一样:脚本开始执行、异步派发事件、定时器到点,都会往任务队列里加任务。

微任务这边,ECMAScript 把 Promise 相关的工作交给宿主通过 HostEnqueuePromiseJob 安排,HTML 规范写明“把这些操作放进微任务队列”。MDN 也写了 Promise 和 Mutation Observer API 都用微任务队列执行回调,queueMicrotask() 则让开发者自己往里加。

一轮循环里谁先谁后

规范的处理模型,简化一下是这样的:

  1. 如果有可运行的任务,从某个任务队列里取出最早的一个执行。挑哪个队列由浏览器决定。
  2. 执行微任务检查点:一直执行微任务,直到队列为空,中途新加的也算。
  3. 如果到了渲染时机,就更新渲染:处理 resize、scroll 等事件,执行 requestAnimationFrame 回调,然后计算样式、布局,画到屏幕上。

在 Node.js 里先看任务和微任务的顺序。下面的数字就是实际打印顺序:

JavaScript
const log = [];
log.push('1 同步');
setTimeout(() => {
  log.push('5 setTimeout');
}, 0);
Promise.resolve().then(() => {
  log.push('3 then');
  setTimeout(() => {
    log.push('6 then 里的 setTimeout');
    console.log(log.join(' → ')); // 1 同步 → 2 同步 → 3 then → 4 then 里的微任务 → 5 setTimeout → 6 then 里的 setTimeout
  }, 0);
  Promise.resolve().then(() => log.push('4 then 里的微任务'));
});
log.push('2 同步');

为什么“4”能插到“5”前面?“4”是在微任务执行时才加进去的,但微任务检查点会一直跑到队列空为止,所以它还在这一轮里。“6”是微任务里新安排的定时器,只能排到已有的“5”后面。

requestAnimationFrame 排在哪里

requestAnimationFrame(callback) 做的事情很简单:按规范的步骤,它只是把回调存进当前文档的动画帧回调表,返回一个编号。它不往任何任务队列或微任务队列里放东西。MDN 的描述是“在下一次重绘之前调用指定的回调”。

真正调用回调的地方是“更新渲染”。规范的做法是,事件循环发现有文档到了渲染时机,就在渲染任务源上排一个“更新渲染”的任务,这个任务里依次处理 resize、scroll 等步骤,然后对每个文档“运行动画帧回调”,再去算样式和布局。渲染时机多久来一次,规范没有规定固定模型,只举例说 60Hz 时最多约 16.7 毫秒一次,页面不可见时可能更少。浏览器判断渲染不会有可见变化时,还可以跳过这一步。

所以 setTimeout(fn, 0) 和 requestAnimationFrame 谁先,取决于定时器到点和渲染时机哪个先来。用 Chrome 156 的无头模式跑下面这段,三次都得到 sync, micro, qm, timeout, raf, raf-micro:

JavaScript
const log = [];
log.push('sync');
setTimeout(() => log.push('timeout'), 0);
requestAnimationFrame(() => {
  log.push('raf');
  Promise.resolve().then(() => log.push('raf-micro'));
});
Promise.resolve().then(() => log.push('micro'));
queueMicrotask(() => log.push('qm'));

这只能说明这次环境里定时器先到。无头模式没有真实屏幕,渲染不按真实的帧节奏走,换成有界面的浏览器、换一个页面状态,raf 跑到 timeout 前面也是规范允许的。面试时能确定的说法只有:微任务先于两者;两者之间没有固定顺序。

另一个结果可以确定:raf-micro 紧跟在 raf 后面。在同一次渲染里注册两个 rAF 回调,第一个回调里加一个微任务,Chrome 156 的结果是 raf1, raf1-micro, raf2。原因是每个回调都由“调用回调”的算法执行,回调结束、执行栈为空时会做一次微任务检查点,所以回调之间会清空微任务。

常见追问

为什么 setTimeout(fn, 0) 不是马上执行?

它只是让定时器到点后往任务队列里加一个任务,要等当前任务和所有微任务执行完才可能轮到。另外 HTML 规范规定,定时器嵌套超过 5 层且延时小于 4 毫秒时,延时会被提到 4 毫秒。

动画为什么推荐用 requestAnimationFrame 而不是 setTimeout?

rAF 回调在浏览器下一次重绘之前执行,频率跟着渲染时机走,回调里改的样式正好赶上这一帧;定时器的到点时间和屏幕刷新没有关系。另外 MDN 提到,大多数浏览器会在后台标签页和隐藏的 iframe 里暂停 rAF 调用,以节省性能和电量。

Node.js 里也有宏任务和微任务吗?

Node.js 没有渲染步骤,也没有 requestAnimationFrame,它的事件循环分阶段执行,还多了 process.nextTick。细节见《Node.js 事件循环和浏览器有什么不同?nextTick 一定先于 Promise 吗?》。