Skip to content

JavaScript 事件循环总结

本文总结了 JavaScript 事件循环(Event Loop)机制,涵盖了同步任务、微任务、宏任务的执行顺序,以及与 setTimeoutPromise相关的实际行为分析。

一、JavaScript 是单线程语言

  • JavaScript 的运行环境(浏览器 / Node)中,JS 引擎是单线程的,所有同步代码、异步回调最终都在这个主线程中执行。
  • setTimeout 注册后,会被浏览器交给定时器线程计时;
  • 计时完成后,如果主线程空闲,才会将回调推入宏任务队列
  • 回调函数最终仍由 JavaScript 的单线程事件循环来执行。

二、事件循环的执行流程

每一轮事件循环(Event Loop)执行顺序如下:

1. 执行主线程同步代码

  • 所有同步代码进入执行栈,逐行执行。
  • 遇到异步任务(如定时器、事件监听、Promise 等)时,根据任务类型分别处理:

2. 微任务(Microtasks)

  • 遇到如 Promise.thenqueueMicrotaskMutationObserver 等会立即将任务推入微任务队列
  • 微任务的推送是同步行为,但执行是异步(事件循环的下一阶段)。

3. 宏任务(Macrotasks)

  • 宏任务包括:
    • setTimeout, setInterval
    • DOM 事件(click、input)
    • MessageChannel, postMessage
    • 网络请求(XHR、Fetch 的 onload/onreadystatechange)
  • 宏任务的推送是异步的,不会立刻进入队列,而是由宿主(浏览器)根据条件决定:

宏任务推入队列的条件:

  • 当前事件循环中的同步任务与微任务清空(主线程空闲);
  • 对于定时器类宏任务(如 setTimeout(fn, 0)):
    • 需要浏览器的 计时器子系统计时结束
    • 然后等待主线程空闲,才推入宏队列。
  • 其它非定时器类宏任务(如 DOM 事件、postMessage):
    • 触发后等待主线程空闲即可推入队列。

4. 清空微任务队列

  • 同步代码执行完毕后,事件循环会:
    • 依次从微任务队列取出所有任务并执行,直到从微队列取值为空。
    • 这是一个完整的“微任务阶段”。

5. 浏览器执行必要的 DOM 渲染(如有)

  • 若在同步或微任务阶段 DOM 有更新,此时浏览器会渲染更新。

6. 本轮事件循环结束

  • 若有已满足条件(如计时完成或事件触发)的宏任务,此时将其推入宏任务队列

7. 从宏任务队列中取出一个任务,开始下一轮事件循环

  • 执行宏任务回调,回到步骤 1。

微任务 vs 宏任务

类型示例特点
微任务Promise.then, queueMicrotask一轮事件循环会清空所有微任务
宏任务setTimeout, setInterval, requestAnimationFrame, I/O一轮事件循环只执行一个宏任务

三、定时器 setTimeout 的行为

setTimeout 是怎么工作的?

  1. JS 执行 setTimeout(fn, delay),将 fn 和延迟时间交给浏览器的 定时器线程
  2. 浏览器计时,到达 delay 时间后,尝试将 fn 推入宏任务队列(主线程空闲)。
  3. 等当前一轮事件循环执行完同步代码 + 微任务后,JS 主线程从宏任务队列中取出 fn 执行。

注意:delay 只是“最小等待时间”,不是“准确延迟”时间。实际执行时间可能更晚,取决于主线程是否空闲。

delay = 0 的情况

js
let start = Date.now()
setTimeout(() => {
  console.log('delay =', Date.now() - start)
}, 0)
  • 在主线程不繁忙的时候,输出往往也是 >= 1
  • 因为推到宏队列需要等当前同步任务全部执行完,系统调度也会有一定的时间开销

防抖 debounce 延迟为 0 的情况

js
function debounce(fn, delay) {
  let timer = null
  return (...args) => {
    clearTimeout(timer)
    timer = setTimeout(() => fn.apply(this, args), delay)
  }
}

当 delay 为 0 时:

  • 即便 delay 为 0,由于是异步执行,只要主线程还在繁忙,倒计时结束 setTimeout回调也不会被推到宏队列,所以在这个过程中是可以使用clearTimeout清除定时器
  • 所以多次触发 debounce 返回的函数,会不断清除上一个定时器
  • 最终,只有最后一次触发留下的 setTimeout 被执行

四、容易有误解的问题

1. setTimeout 的回调能被 clearTimeout 清除吗?

  • 只要宿主还没把 setTimeout 的回调推到宏队列,就可以被 clearTimeout 清除
  • 如果已经推到宏队列,就清除不了

2. 回调是何时被推进宏任务队列的?

  • 浏览器计时器线程等待 delay 到达后,将回调 推入宏任务队列(等待主线程空闲)

3. 将任务推到队列属于 JavaScript 的行为吗?

  • 不是,事件循环(Event Loop)由两部分共同协作完成
  • JavaScript 负责执行宏任务回调和执行并管理微队列
  • JavaScript 不负责创建或调度宏任务,遇到宏任务通知宿主环境,
  • setTimeout等宏任务API也不是由 JavaScript 引擎提供
    • 清空微队列的消息由宿主通知
    • 微队列由解释器管理
    • 微任务执行期间可以添加新微任务,引擎会持续清空
    • 微任务推到微队列这个操作也是由JavaScript来完成 宿主环境控制整个事件循环的调度(生成宏任务、取宏任务、任务交给 JavaScript、继续下一轮、通知 JavaScript 清空微任务队列等)

五、事件循环运行图

基本执行模型

事件循环运行过程图

六、案例

js
Promise.resolve()
  .then(() => {
    console.log('A: 0');
    return Promise.resolve('A: 4'); 
  })
  .then((res) => {
    console.log(res);
  });

Promise.resolve()
  .then(() => {
    console.log('B: 1');
  })
  .then(() => {
    console.log('B: 2');
  })
  .then(() => {
    console.log('B: 3');
  })
  .then(() => {
    console.log('B: 5');
  })
  .then(() => {
    console.log('B: 6');
  });

混淆点

我刚开始看到这段代码,认为是会按照正常的异步顺序进出队列、每次消耗一个微任务处理时间来顺序执行输出

js
// 错误的答案
// A: 0
// B: 1
// A: 4
// B: 2
// B: 3
// B: 5
// B: 6

执行后发现这个答案是不对的

  • 根据Promises/A+当在then中返回一个Promise,需要经过特殊的处理,但没有明确规定要怎么特殊处理
  • 不过根据核心规定then回调必须异步执行
  • 经过作者尝试,后面没有明确链式调用then,这个返回的Promise也和带then的任务处理时间相同
    • return Promise.resolve('A: 4').then(res=>res)

执行步骤

  1. 同步代码执行,将两个链的第一个then回调推入微任务队列
  2. 执行第一个微任务(A链第一个then)
    • 输出 A: 0
    • 返回 Promise.resolve('A: 4')
    • 由于返回的是 Promise,需要两个额外的微任务来处理
      • 第一个额外微任务:处理返回的 Promise
      • 第二个额外微任务:将值传递给下一个 then
  3. 执行第二个微任务(B链第一个then)
    • 输出 B: 1
    • 将下一个 then 回调推入微任务队列
  4. 执行额外任务1(处理返回的Promise)
    • 创建第二个额外微任务来传递值
  5. 执行B链第二个then
    • 输出 B: 2
    • 将下一个 then 回调推入微任务队列
  6. 执行额外任务2(传递值)
    • 将值 'A: 4' 传递给A链的下一个 then
    • A链的第二个 then 回调推入微任务队列
  7. 执行B链第三个then
    • 输出 B: 3
    • 将下一个 then 回调推入微任务队列
  8. 执行A链第二个then
    • 输出 A: 4
  9. 执行B链第四个then
    • 输出 B: 5
    • 最后一个 then 回调推入微任务队列
  10. 执行B链第五个then
  • 输出 B: 6

最终输出

js
A:0 
B:1 
B:2 
B:3 
A:4 
B:5 
B:6