「Event Loop」到底怎么跑?一篇搞懂 JS 宏任务、微任务、执行顺序!

229 阅读6分钟

你是否也听过这些问题:

  • setTimeoutPromise 谁先执行?
  • 为什么 MutationObserversetTimeout 快?
  • process.nextTick 是什么,和 Promise 有啥区别?

如果你还在背「先执行同步,再执行微任务,再执行宏任务」,那这篇一定要收藏:
咱不讲大话,直接用一堆真代码 + 控制台输出,看一次记一辈子!


Event Loop 是啥?先记一个结论

先记死

JS 是单线程的:

  • 同一时刻只能干一件事
  • 遇到异步,不是真的并行,而是排队等下次执行

这套机制就叫 Event Loop(事件循环)

执行顺序:

一个宏任务开始 -> 执行所有同步任务 -> 执行所有微任务 -> 执行下一个宏任务...

下面我给你一张图片,可能你现在还看不懂,但是你别急,待我慢慢道来,保你看完后心情舒畅。

0c096ade76bd5e085540349be903141c.png


关键概念:宏任务 & 微任务

先分清:

  • 宏任务(MacroTask)

    • script 整体执行
    • setTimeout
    • setInterval
    • setImmediate(Node)
  • 微任务(MicroTask)

    • Promise.then
    • process.nextTick(Node)
    • MutationObserver
    • queueMicrotask

执行顺序:
同步任务 > 微任务 > 宏任务队列里的下一个宏任务


最经典示例:setTimeout vs Promise

先看

<script>
console.log('script start');

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

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

console.log('script end');
</script>

输出:

script start
script end
promise
setTimeout

解析:

  1. script 标签加载时,浏览器把整段 <script> 看作一个宏任务(MacroTask)

  2. 开始执行宏任务:

    • JS 引擎从上往下执行,进入调用栈。
  3. 遇到 console.log('script start')

    • 属于同步任务,立即输出:script start
  4. 遇到 setTimeout

    • setTimeout 的回调函数注册到宏任务队列中,等待当前宏任务(整个 <script>)执行完后,排队执行。
  5. 遇到 Promise.resolve().then(...)

    • Promise.resolve() 立即返回一个已完成(fulfilled)的 Promise。
    • .then() 把回调函数注册到微任务队列中,等待本次宏任务(整个 <script>)的同步任务执行完后,立刻执行。
  6. 执行 console.log('script end')

    • 也是同步任务,立即输出:script end
  7. 当前宏任务(整个 <script>)的同步任务执行完毕,JS 引擎检查微任务队列:

    • 发现一个 Promise.then 回调,立即执行:

      • 输出:promise
  8. 微任务清空后,Event Loop 检查宏任务队列:

    • 发现之前的 setTimeout 回调在队列里,拿出来执行:

      • 输出:setTimeout
  9. 这次执行循环结束,调用栈清空,等待下一个 Event Loop 周期。


再去看看我一开始给的那张图,是不是一目了然啦? 但是光到这还远远不够,接下来我们再来一个复杂点的


多个 setTimeout + Promise 混搭

再加点料,一定要掌握

console.log('同步 Start');

const promise1 = Promise.resolve('First Promise');
const promise2 = Promise.resolve('Second Promise');

const promise3 = new Promise(resolve => {
  console.log('Promise3 同步');
  resolve('Third Promise');
});

setTimeout(() => {
  setTimeout(() => {
    console.log('下一把再相见');
  }, 0);

  const promise4 = Promise.resolve('Fourth Promise');
  promise4.then(v => console.log(v));
}, 0);

setTimeout(() => {
  console.log('下下一把再相见');
}, 0);

promise1.then(v => console.log(v));
promise2.then(v => console.log(v));
promise3.then(v => console.log(v));

console.log('同步 End');

输出顺序:

同步 Start
Promise3 同步
同步 End
First Promise
Second Promise
Third Promise
Fourth Promise
下下一把再相见
下一把再相见

拆开解释:

  1. 同步部分先跑完

    • console.log('同步 Start') 立即执行,输出:同步 Start
    • 执行 Promise.resolve('First Promise')Promise.resolve('Second Promise') 只是创建已完成状态的 Promise,不输出内容。
    • 遇到 new Promise,构造函数中的 console.log('Promise3 同步') 是同步执行,立即输出:Promise3 同步
    • setTimeout 注册到宏任务队列中,不立即执行。
    • 再遇到第二个 setTimeout,同样注册到宏任务队列中,等后面执行。
    • .then 回调全部注册到微任务队列,暂时不执行。
    • 最后 console.log('同步 End') 是同步,立即输出:同步 End

  1. 同步结束,立刻执行微任务:promise1 promise2 promise3

    • 当前宏任务(整个 <script>)的同步任务跑完后,JS 引擎检查微任务队列。

    • 按顺序执行:

      • promise1.then 回调执行,输出:First Promise
      • promise2.then 回调执行,输出:Second Promise
      • promise3.then 回调执行,输出:Third Promise
    • 当前微任务队列清空。


  1. 下一个宏任务是第一个 setTimeout,先跑外层,内部又注册了一个 setTimeout(宏任务)和一个 Promise(微任务)

    • 进入第一个 setTimeout 的回调:

      • 里面又注册了一个新的 setTimeout,加入宏任务队列末尾。
      • 创建 Promise.resolve('Fourth Promise'),立刻变成已完成状态。
      • .then 回调注册到当前微任务队列中。

  1. 微任务 Fourth Promise 先执行

    • 第一个 setTimeout 的同步回调结束后,检查有没有微任务。

    • 发现有刚才 .then 注册的微任务 Fourth Promise

      • 立刻执行,输出:Fourth Promise
    • 微任务队列清空后,继续执行下一个宏任务。


  1. 最后跑第二个 setTimeout 和第一个 setTimeout 内嵌的 setTimeout

    • 按照注册顺序执行:

      • 先执行脚本最初注册的第二个 setTimeout,输出:下下一把再相见
      • 再执行第一个 setTimeout 里嵌套的那个 setTimeout,输出:下一把再相见

还有哪些微任务?微任务再来一个:MutationObserver

MutationObserver 常用于监听 DOM 变化,它的回调是微任务

来看一段示例:

<script>
const target = document.createElement('div');
document.body.appendChild(target);

const observer = new MutationObserver(() => {
  console.log('微任务: MutationObserver');
});

observer.observe(target, {
  attributes: true,
  childList: true,
});

// 触发 DOM 变化
target.setAttribute('data-id', '123');
target.appendChild(document.createElement('span'));
target.setAttribute('style', 'color: red;');

console.log('DOM 修改同步结束');
</script>

输出:

DOM 修改同步结束
微任务: MutationObserver

流程:

  • MutationObserver 监听了 target 节点
  • 同步里改变属性、插入子元素
  • 同步结束后,MutationObserver 回调执行(微任务阶段)

Node.js 里多了谁?process.nextTick

在 Node.js 里,process.nextTick 也属于微任务,优先级比 Promise 还高!

看这个示例:

console.log('Start');

process.nextTick(() => {
  console.log('Process Next Tick');
});

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

setTimeout(() => {
  console.log('Timeout MacroTask');
}, 0);

console.log('End');

输出:

Start
End
Process Next Tick
Promise Resolve
Timeout MacroTask

执行顺序:

  • 同步先跑完,输出 StartEnd
  • process.nextTick 优先跑(比 Promise 还快)
  • 再跑 Promise 的微任务
  • 最后跑 setTimeout 的宏任务

queueMicrotask 是干嘛的?

queueMicrotask 用来手动插入一个微任务

<script>
console.log('同步开始');

queueMicrotask(() => {
  console.log('微任务: queueMicrotask');
});

console.log('同步结束');
</script>

输出:

同步开始
同步结束
微任务: queueMicrotask

Promise.then 一样,都是「同步结束后补执行」。


总结!背这套口诀

JS 是单线程,所有执行顺序:

同步任务 > 微任务 > 宏任务

常见微任务:

  • Promise.then
  • process.nextTick(Node)
  • MutationObserver
  • queueMicrotask

常见宏任务:

  • 整个 <script> 文件本身
  • setTimeout / setInterval
  • setImmediate(Node)
  • I/OMessageChannelrequestAnimationFrame(浏览器)

执行顺序永远是:

一个宏任务开始 -> 同步 -> 微任务 -> 下一个宏任务...

🪄 什么时候容易出 bug?

前端很多诡异的执行顺序

  • DOM 改了还没渲染完就测宽高,出错
  • 定时器套 Promise,Promise 里套定时器,执行顺序全乱
  • 多个 Promise + MutationObserver 混用,队列顺序一不注意就懵

所以:

  • 多用 console.log 跑 demo
  • 看懂 Event Loop 执行阶段,调试心里不慌

一句话总结

Event Loop = JS 的执行机制核心,搞懂了它,你就是异步的王者!


有用就点个赞!我是小阳,前端小菜鸟,谢谢大家支持!!!