浏览器宏任务与微任务:执行顺序、嵌套与性能陷阱

社区来稿 · batype由用户发布,不代表 batype 本人观点

·1445 字 · 约 4 分钟#JavaScript#浏览器#Event Loop#性能优化

浏览器中的宏任务与微任务:从执行顺序到嵌套任务

理解 Event Loop,重点不在于死记哪些 API 属于“宏任务”或“微任务”,而在于掌握一条稳定的执行规则:同步代码运行完后,先清空微任务队列,再进入后续任务。

微任务最初是为了补足传统消息队列的粗粒度调度,在实时性和效率之间取得平衡;PromiseMutationObserver 等机制都建立在它之上【1】。

先说结论

  • 一段普通的 `` 中的同步代码,会先连续执行完;可以把这轮执行理解为当前任务。
  • 当前任务结束时,浏览器会执行微任务检查点,一直清空微任务队列
  • 微任务执行中继续创建的微任务,仍会在本轮检查点继续执行。
  • 微任务清空后,Event Loop 才会继续处理后续任务,例如定时器回调、用户交互回调等。
  • 因此,在同一轮代码里分别创建微任务和后续任务时,微任务一定更早执行【2】。

需要避免把这个模型理解得过于绝对:浏览器规范中存在不同任务来源、渲染时机和宿主环境差异。但对 PromisequeueMicrotasksetTimeout 的执行顺序分析,这个模型足够实用。

常见来源

微任务

浏览器中常见的微任务来源包括:

  • Promise.then()catch()finally()
  • queueMicrotask()
  • MutationObserver
  • async / awaitawait 之后的续执行,通常可按 Promise 微任务理解
Promise.resolve().then(() => {
  console.log('Promise microtask')
})

queueMicrotask(() => {
  console.log('queueMicrotask')
})

后续任务(常被称为宏任务)

常见来源包括:

  • 整段 script 的初始执行
  • setTimeoutsetInterval
  • 用户交互事件回调
  • 网络、I/O 等由浏览器调度的回调

不同 API 的任务来源和调度细节可能不同,所以工程中更应该关注“何时入队、何时执行”,而不是给所有回调贴上简单标签。

最基础的顺序

console.log('start')

setTimeout(() => {
  console.log('timeout')
}, 0)

Promise.resolve().then(() => {
  console.log('promise')
})

console.log('end')

输出:

start
end
promise
timeout

执行过程:

  1. 当前 script 先输出 start
  2. setTimeout 注册回调,等待后续调度。
  3. Promise.then 注册微任务。
  4. 当前同步代码继续输出 end 并结束。
  5. 浏览器进入微任务检查点,执行 promise
  6. 微任务队列清空后,才执行 timeout

浏览器在执行 HTML 解析任务时遇到 JavaScript 脚本,会暂停解析并进入 JavaScript 执行环境;脚本结束后会检查并清空微任务列表【2】。

嵌套例子:微任务会清空到队列为空

console.log('script start')

setTimeout(() => {
  console.log('timeout 1')

  Promise.resolve().then(() => {
    console.log('promise in timeout 1')
  })

  setTimeout(() => {
    console.log('timeout 2')
  }, 0)
}, 0)

Promise.resolve().then(() => {
  console.log('promise 1')

  Promise.resolve().then(() => {
    console.log('promise 2')
  })

  setTimeout(() => {
    console.log('timeout in promise 1')
  }, 0)
})

console.log('script end')

典型输出:

script start
script end
promise 1
promise 2
timeout 1
promise in timeout 1
timeout in promise 1
timeout 2

关键是 promise 1 执行时,又追加了 promise 2。浏览器不会因为原本的微任务执行完就立刻转去执行定时器,而是会继续取出新加入的微任务,直到队列为空。

接着执行 timeout 1 时,其中新建的 promise in timeout 1 也会在该定时器回调结束后、下一个定时器之前执行。

复杂例子:Promise、queueMicrotask 和 async/await 混合

console.log('A: script start')

setTimeout(() => {
  console.log('B: timeout1 start')

  Promise.resolve().then(() => {
    console.log('C: promise in timeout1')

    queueMicrotask(() => {
      console.log('D: queueMicrotask in promise in timeout1')
    })
  })

  queueMicrotask(() => {
    console.log('E: queueMicrotask in timeout1')
  })

  setTimeout(() => {
    console.log('F: timeout2')
  }, 0)

  console.log('G: timeout1 end')
}, 0)

Promise.resolve().then(() => {
  console.log('H: promise1')

  setTimeout(() => {
    console.log('I: timeout in promise1')
  }, 0)

  Promise.resolve().then(() => {
    console.log('J: promise2')

    queueMicrotask(() => {
      console.log('K: queueMicrotask in promise2')
    })
  })
})

queueMicrotask(() => {
  console.log('L: queueMicrotask1')

  Promise.resolve().then(() => {
    console.log('M: promise in queueMicrotask1')
  })
})

async function fn() {
  console.log('N: async start')

  await null
  console.log('O: async after await 1')

  await null
  console.log('P: async after await 2')
}

fn()

console.log('Q: script end')

在现代浏览器中,前半段输出顺序是:

A: script start
N: async start
Q: script end
H: promise1
L: queueMicrotask1
O: async after await 1
J: promise2
M: promise in queueMicrotask1
P: async after await 2
K: queueMicrotask in promise2

之后才会执行各个 setTimeout 回调;定时器之间的先后受其注册及浏览器调度影响,但每一个定时器回调结束后,都会先清空它期间产生的微任务。

这里有两个观察点:

  1. await null 不会让函数整体同步阻塞;await 之后的代码会被安排为异步续执行。
  2. 微任务按入队顺序执行。某个微任务内部再添加的新微任务,会排在当时已经等待的微任务之后。

性能陷阱:不要递归塞满微任务

微任务优先级高,但不是“免费”。微任务的执行时间会直接拉长当前任务;如果持续产生大量微任务,渲染和用户交互都会被延后【2】。

function loop() {
  queueMicrotask(loop)
}

loop()

这段代码会不断让微任务队列保持非空,浏览器难以进入后续任务和渲染阶段,页面可能出现卡顿甚至失去响应。

如果是可切分的大量计算,更适合分批处理,并在批次之间主动让出执行机会:

function processInChunks(items, index = 0) {
  const end = Math.min(index + 100, items.length)

  for (let i = index; i < end; i++) {
    doWork(items[i])
  }

  if (end < items.length) {
    setTimeout(() => processInChunks(items, end), 0)
  }
}

现代浏览器也可以按场景考虑 requestAnimationFramerequestIdleCallback(兼容性需确认)或调度 API。原则不变:不要长时间占用主线程。

调试建议

遇到顺序问题时,我建议按这个步骤拆解:

  1. 标出所有同步代码。
  2. 标出每个 Promise.thenqueueMicrotaskawait 续执行何时进入微任务队列。
  3. 标出每个定时器或事件回调何时进入后续任务队列。
  4. 每执行完一个任务,就把微任务队列完整清空,再继续下一轮。

Chrome DevTools 的 Performance 面板可以观察主线程上的任务执行;页面加载期间的 Parse HTML 任务也会记录在 Main 线程中,解析遇到脚本时会暂停解析转而执行脚本【3】。这比只看 console.log 更适合排查真实页面中的长任务和卡顿。

总结

宏任务 / 微任务不是为了考执行顺序,而是解释浏览器主线程如何在同步代码、异步回调、渲染与交互之间做调度。

最值得记住的只有两点:

  • 同步代码结束后,先清空微任务。
  • 微任务不能无限递归,否则会阻塞后续任务、渲染和交互。

掌握这两点,大部分 Promiseasync/awaitsetTimeout 的时序问题都能自己推导出来。

参考

  • 【1】《宏任务和微任务:不是所有任务都是一个待遇》
  • 【2】《宏任务和微任务:不是所有任务都是一个待遇》
  • 【3】《消息队列和事件循环:页面是怎么“活”起来的?》

相关文章

  • 事件循环(Event Loop)

    事件循环(Event Loop)是计算机程序中的一个设计模式,用于处理异步操作,特别是在图形用户界面程序和服务器端应用程序中。事件循环模式通过等待并分派事件或回调函数来驱动程序的执行流程,而不是通过直

  • 如何对项目中的图片进行优化?

    1. 不用图片。很多时候会使用到很多修饰类图片,其实这类修饰图片 完全可以用 CSS 去代替。 2. 对于移动端来说,屏幕宽度就那么点,完全没有必要去加载原图浪 费带宽。一般图片都用 CDN 加载,可

  • 前端懒加载是什么?

    懒加载也叫做延迟加载、按需加载,指的是在长网页中延迟加载图片数据,是一种较好的网页性能优化的方式。在比较长的网页或应用中,如果图片很多,所有的图片都被加载出来,而用户只能看到可视窗口的那一部分图片数据