事件循环(Event Loop)一文吃透:宏任务/微任务/渲染时机

为什么 Promise.then 比 setTimeout 先执行?为什么 await 之后的代码像“异步”又像“同步”?这篇文章用可运行的例子把浏览器事件循环、任务队列、微任务、渲染与 Node.js 差异讲清楚,顺带给你一套面试可复述的答案。

20 分钟阅读
小明

事件循环(Event Loop)一文吃透:宏任务/微任务/渲染时机

你可以把 JS 运行时想成一个“单线程的工位”。

它一次只能做一件事,但它能把其他事情交给“外部系统”(浏览器/Node)去处理,等准备好了再回来排队。

事件循环就是这个排队与调度系统。

这篇文章目标是:

  • 让你从“背顺序”变成“会推演”
  • 能解释 Promise / async/await / setTimeout / 渲染更新的先后
  • 面试遇到题能稳定输出

1. 基础模型:调用栈、任务队列、事件循环

你需要先把 3 个概念装进脑子:

  • 调用栈(Call Stack):当前正在执行的函数栈
  • 任务队列(Task Queue):等待执行的任务(宏任务队列、微任务队列)
  • 事件循环(Event Loop):不停检查“栈空了吗?队列里有任务吗?”然后把任务推进栈执行

一句话:

栈在跑代码,队列在排任务,循环在做调度。


2. 宏任务 vs 微任务:为什么 Promise.then 更“急”

2.1 宏任务(Macrotask / Task)

常见来源:

  • setTimeout / setInterval
  • MessageChannel
  • setImmediate(Node)
  • I/O 回调(Node)
  • UI 事件(点击、输入等)

2.2 微任务(Microtask)

常见来源:

  • Promise.then/catch/finally
  • queueMicrotask
  • MutationObserver(浏览器)

核心规则:

每执行完一个宏任务后,会清空微任务队列(一直执行到微任务队列为空)。

这就是为什么 Promise.then 通常会比下一个 setTimeout 先执行。


3. 先跑一段能“背下来”的经典例子

把下面这段放到浏览器控制台(或任意 JS 运行环境)里跑:

console.log('script start')

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

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

console.log('script end')

输出顺序通常是:

  1. script start
  2. script end
  3. then 1
  4. then 2
  5. timeout

推演方式:

  • 整个脚本本身就是一个宏任务
  • 脚本执行结束(宏任务结束)
  • 清空微任务队列:执行 then 1then 2
  • 执行下一轮宏任务:timeout

4. async/await 不是“更神秘的异步”

很多人误以为 await 会“开线程”。不会。

await 的本质可以近似理解为:

  • await 后面的代码拆成一个 continuation
  • 这个 continuation 会在 Promise resolve 后,作为微任务继续执行

看例子:

async function main() {
  console.log('A')
  await 0
  console.log('B')
}

console.log('start')
main()
console.log('end')

通常输出:

  • start
  • A
  • end
  • B

原因:

  • main() 在同步阶段先执行到 await
  • await 把后续 console.log('B') 放进微任务
  • 当前宏任务(脚本)结束后,清空微任务,打印 B

结论:

await 后面“像异步”,是因为它把后续逻辑交给微任务队列。


5. 渲染(render)在什么时候发生?

这是一个常见盲区。

在浏览器里,大致可以记住一个经验规则:

  • 一轮宏任务执行完
  • 清空微任务队列
  • 浏览器可能进行一次渲染(布局/绘制),然后进入下一轮宏任务

这解释了两个现象:

  1. 如果你在一个宏任务里连续做大量同步计算,即使你修改了 DOM,页面也不会立刻更新。
  2. 如果你在 DOM 更新后立刻读取布局信息(如 offsetHeight),可能触发强制同步布局(layout thrash)。

你可以用这个小实验感受“渲染不是你想的那样即时”:

const el = document.createElement('div')
el.textContent = 'before'
document.body.appendChild(el)

setTimeout(() => {
  el.textContent = 'timeout update'
}, 0)

Promise.resolve().then(() => {
  el.textContent = 'microtask update'
})

通常你会看到最终显示的是 microtask update,因为它在同一轮宏任务结束后的“清空微任务阶段”执行得更早。


6. Node.js 的事件循环:为什么和浏览器不完全一样

面试中你可以这么说:

  • 浏览器的事件循环与“渲染”强绑定
  • Node.js 的事件循环分多个 phase(timers、poll、check…)
  • 微任务(Promise)在 Node 中也很重要,但调度点与浏览器有细微差异

如果你想写 Node 面试回答版本:

  • 宏观:每轮 loop 按 phase 执行队列
  • 微观:每个 phase 结束后也会清空 microtask queue

注意:Node 还有 process.nextTick,它比 Promise 微任务更“优先”,容易导致饥饿(starvation)。


7. 你要掌握的“推演模板”

碰到输出顺序题,不要靠背。

按这个模板推:

  1. 把同步代码按顺序执行完(都在当前宏任务里)
  2. 同步执行过程中:
    • setTimeout → 放宏任务队列
    • then/await → 放微任务队列
  3. 当前宏任务结束
  4. 清空微任务队列(循环执行,直到为空)
  5. 进入下一轮宏任务
  6. 浏览器环境:宏任务与微任务之间可能插入渲染

8. 面试常见追问(直接可背)

Q1:Promise.then 和 setTimeout 谁先?

  • 一般 then 先,因为它是微任务,会在当前宏任务结束后立即清空。

Q2:await 后的代码算微任务吗?

  • 可以近似理解为:await 把后续逻辑放进微任务队列。

Q3:为什么页面更新不及时?

  • 因为渲染通常发生在“宏任务结束 + 清空微任务”之后;同步长任务会阻塞渲染。

总结

  • JS 单线程靠事件循环做调度。
  • 宏任务一轮一轮执行;每轮宏任务结束后清空微任务。
  • Promise.thenawait 的后续执行通常属于微任务。
  • 浏览器渲染一般夹在“宏任务与下一轮宏任务之间”。

如果你愿意,我可以把这篇文章再补一节:专门用 6~8 道“输出顺序题”带你做推演训练,并配上答案与解析。