JavaScript事件循环

JavaScript 事件循环基础,解释同步代码、宏任务与微任务的执行顺序。

#type / concept #status / growing #resource / javascript

[!info] related notes

JavaScript事件循环

这一部分是整个异步章节最核心的底层理解。

你只要吃透事件循环,后面的 Promiseasync/await 就不会悬空。

为什么要有事件循环

JavaScript 主线程一次只能做一件事。

那如果:

  • 定时器到了
  • 用户点击了
  • 请求返回了
  • Promise resolve 了

这些事情同时发生,JS 怎么决定先做谁?

答案就是:

通过事件循环(Event Loop)机制调度任务。

先建立一个最简模型

你可以先把 JS 运行时想成三部分:

1. 调用栈(Call Stack)

当前正在执行的同步代码都在这里。

2. 任务队列(Task Queue)

异步回调准备好了,但还没轮到执行,先排队。

3. 事件循环(Event Loop)

不断检查:

  • 调用栈空了吗?
  • 空了就从队列里拿任务出来执行

最基本的执行流程

console.log("A");

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

console.log("C");

执行过程:

  1. console.log("A") 进栈执行
  2. setTimeout(...) 注册定时器,回调先不执行
  3. console.log("C") 执行
  4. 同步代码跑完,调用栈清空
  5. 定时器回调进入任务队列
  6. 事件循环把它拿出来执行

输出:

A
C
B

宏任务和微任务

这是事件循环里最关键、最容易面试的点。

JS 里的异步任务通常分两类:

宏任务(macrotask)

常见有:

  • setTimeout
  • setInterval
  • DOM 事件
  • I/O
  • script 整体执行

微任务(microtask)

常见有:

  • Promise.then/catch/finally
  • queueMicrotask
  • MutationObserver

微任务优先级更高

每轮事件循环大致是这样:

  1. 执行一个宏任务
  2. 然后把当前产生的所有微任务清空
  3. 再进入下一轮宏任务

所以:

微任务总是在当前宏任务结束后、下一个宏任务开始前执行。

看一个经典例子

console.log("1");

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

Promise.resolve().then(() => {
  console.log("3");
});

console.log("4");

输出:

1
4
3
2

为什么?

第一步:执行同步代码

  • 输出 1
  • 注册 setTimeout
  • 注册 Promise.then
  • 输出 4

第二步:同步代码结束

现在开始清空微任务:

  • 输出 3

第三步:再执行宏任务

  • 输出 2

所以是 1 4 3 2

你必须建立的稳定规则

遇到题目时,按这个顺序看:

先看同步代码

全部先执行完。

再看微任务

把当前轮次的所有微任务清空。

再看宏任务

比如定时器回调。

这个规则极其重要。

再看一个例子

console.log("start");

setTimeout(() => {
  console.log("timeout1");
  Promise.resolve().then(() => {
    console.log("promise in timeout1");
  });
}, 0);

Promise.resolve().then(() => {
  console.log("promise1");
});

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

console.log("end");

执行顺序:

同步阶段

输出:

start
end

微任务阶段

输出:

promise1

宏任务阶段:第一个 timeout

输出:

timeout1

然后这个 timeout 里面又产生了微任务,所以该宏任务结束后立刻清微任务:

promise in timeout1

再到下一个宏任务

输出:

timeout2

最终顺序:

start
end
promise1
timeout1
promise in timeout1
timeout2

for 循环里为什么常配合闭包题一起考

因为这类题同时在考三件事:

  • 循环变量的作用域
  • 回调什么时候真正执行
  • 回调读取到的是哪个变量绑定

var 的经典坑

for (var i = 0; i < 5; i++) {
  setTimeout(function () {
    console.log(i)
  }, i)
}

最终通常输出 5 次 5

原因不是定时器坏了,而是:

  • for 循环同步跑完时,i 已经变成 5
  • setTimeout 的回调后面才执行
  • var 只有函数作用域,5 个回调共享同一个 i

IIFE 为什么能修这个题

for (var i = 0; i < 5; i++) {
  ;(function (j) {
    setTimeout(function () {
      console.log(j)
    }, j)
  })(i)
}

IIFE 每轮都会创建一个新的函数作用域,把当时的 i 作为参数保存成 j。这本质上是在借闭包制造“每轮一个独立绑定”。

let 出现后,IIFE 往往就多余了

for (let i = 0; i < 5; i++) {
  setTimeout(function () {
    console.log(i)
  }, i)
}

这里通常输出 0 1 2 3 4

因为 let 在循环的每次迭代里都会形成新的绑定,所以不需要再额外包一层 IIFE。

setTimeout 换成 Promise.thenawait 会怎样

换成 Promise.then

for (let i = 0; i < 5; i++) {
  Promise.resolve().then(() => {
    console.log(i)
  })
}

console.log('loop end')

输出顺序通常是:

loop end
0
1
2
3
4

因为 then 回调属于微任务。循环会先同步跑完,随后当前宏任务结束,再一次性清空这些微任务。

换成 await

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

async function run() {
  for (let i = 0; i < 5; i++) {
    await sleep(1000)
    console.log(i)
  }
}

这时不是“瞬间派发 5 个回调”,而是每轮真的等上一次完成后再继续下一轮,所以会按 1 秒 1 次的节奏串行输出。

一条稳定判断线

遇到循环 + 异步输出题,可以固定按这条线分析:

  1. 先看循环变量是 var 还是 let
  2. 再看回调是宏任务还是微任务
  3. 最后看代码是在“并发注册多个回调”还是“用 await 暂停循环本身”

一句话理解事件循环

JS 先跑完当前同步代码,再清空微任务,最后再处理宏任务,一轮一轮循环下去。

不负责什么

面试要点

来自 event-loop-interview-question 的面试视角整理。

一句话回答

JavaScript 主线程先执行同步代码;异步任务会被调度到任务队列里,而事件循环会在每轮宏任务结束后先清空当前微任务,再进入下一轮宏任务。

最稳的回答顺序

  1. JavaScript 主线程是单线程执行同步任务
  2. 异步任务不会立刻执行,而是等待调度
  3. 常见异步任务分为宏任务和微任务
  4. 一轮宏任务结束后,会先把微任务清空

常见例子

  • 宏任务:setTimeout、DOM 事件、I/O
  • 微任务:Promise.thenqueueMicrotask

一个最容易记住的结论

微任务优先级高于下一轮宏任务。

面试常见追问

await 后面的代码什么时候执行

通常会被放到微任务阶段继续执行。

Promise.thensetTimeout 谁先执行

如果都已就绪,通常先清空微任务,所以 Promise.then 先执行。

for + setTimeout + var 为什么常常输出同一个值

因为 for 先同步跑完,回调后执行,而 var 让这些回调共享同一个变量绑定。

为什么以前要用 IIFE,现在常说没必要

以前是为了给 var 循环变量制造独立作用域;如果已经是 let,每轮都有自己的绑定,IIFE 往往只是冗余写法。

高频变种怎么答

var 版本

for (var i = 0; i < 5; i++) {
  setTimeout(function () {
    console.log(i)
  }, i)
}

答法:

  • 循环是同步执行
  • setTimeout 回调后执行
  • var 共用一个 i
  • 所以大概率输出 5 次 5

let 版本

for (let i = 0; i < 5; i++) {
  setTimeout(function () {
    console.log(i)
  }, i)
}

答法:

  • let 每轮循环生成新的绑定
  • 所以会输出 0 1 2 3 4

Promise.then 版本

for (let i = 0; i < 5; i++) {
  Promise.resolve().then(() => {
    console.log(i)
  })
}
console.log('end')

答法:

  • 先输出 end
  • 再一次性清空微任务,输出 0 1 2 3 4

await 版本

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

async function run() {
  for (let i = 0; i < 3; i++) {
    await sleep(1000)
    console.log(i)
  }
}

答法:

  • 这里不是把 3 个回调一起扔出去
  • await 会暂停当前 async 函数里的循环
  • 所以会串行、间隔输出 0 1 2

一道混合题

console.log('1')

setTimeout(() => {
  console.log('2')
  Promise.resolve().then(() => {
    console.log('3')
  })
}, 0)

new Promise((resolve) => {
  console.log('4')
  resolve()
}).then(() => {
  console.log('5')
  setTimeout(() => {
    console.log('6')
  }, 0)
})

console.log('7')

结果:

1
4
7
5
2
3
6

最稳分析顺序:

  1. 先跑同步代码,所以先看到 1 4 7
  2. 再清空微任务,所以输出 5
  3. 再执行第一个宏任务,输出 2
  4. 这个宏任务里又塞了微任务,所以接着输出 3
  5. 最后执行后面的宏任务,输出 6

最短记忆方式

每轮先跑宏任务,结束后清空微任务。

创建于 2026/3/15 更新于 2026/5/27