JavaScript事件循环
JavaScript 事件循环基础,解释同步代码、宏任务与微任务的执行顺序。
[!info] related notes
- 所属 MOC: ecmascript异步, ecmascript-moc
- 前置概念: js的定时器和事件监听, 同步与异步
- 并列概念: Promise, async / await
- 相关概念: event-emitter, ecmascript-closures
- 易混淆概念: js-event-loop
- 面试问法: Event Loop、宏任务和微任务怎么理解
JavaScript事件循环
这一部分是整个异步章节最核心的底层理解。
你只要吃透事件循环,后面的 Promise、async/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");
执行过程:
console.log("A")进栈执行setTimeout(...)注册定时器,回调先不执行console.log("C")执行- 同步代码跑完,调用栈清空
- 定时器回调进入任务队列
- 事件循环把它拿出来执行
输出:
A
C
B
宏任务和微任务
这是事件循环里最关键、最容易面试的点。
JS 里的异步任务通常分两类:
宏任务(macrotask)
常见有:
setTimeoutsetInterval- DOM 事件
- I/O
- script 整体执行
微任务(microtask)
常见有:
Promise.then/catch/finallyqueueMicrotaskMutationObserver
微任务优先级更高
每轮事件循环大致是这样:
- 执行一个宏任务
- 然后把当前产生的所有微任务清空
- 再进入下一轮宏任务
所以:
微任务总是在当前宏任务结束后、下一个宏任务开始前执行。
看一个经典例子
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已经变成5setTimeout的回调后面才执行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.then 或 await 会怎样
换成 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 次的节奏串行输出。
一条稳定判断线
遇到循环 + 异步输出题,可以固定按这条线分析:
- 先看循环变量是
var还是let - 再看回调是宏任务还是微任务
- 最后看代码是在“并发注册多个回调”还是“用
await暂停循环本身”
一句话理解事件循环
JS 先跑完当前同步代码,再清空微任务,最后再处理宏任务,一轮一轮循环下去。
不负责什么
- 浏览器和 Node.js 的差异比较,交给 js-event-loop
面试要点
来自 event-loop-interview-question 的面试视角整理。
一句话回答
JavaScript 主线程先执行同步代码;异步任务会被调度到任务队列里,而事件循环会在每轮宏任务结束后先清空当前微任务,再进入下一轮宏任务。
最稳的回答顺序
- JavaScript 主线程是单线程执行同步任务
- 异步任务不会立刻执行,而是等待调度
- 常见异步任务分为宏任务和微任务
- 一轮宏任务结束后,会先把微任务清空
常见例子
- 宏任务:
setTimeout、DOM 事件、I/O - 微任务:
Promise.then、queueMicrotask
一个最容易记住的结论
微任务优先级高于下一轮宏任务。
面试常见追问
await 后面的代码什么时候执行
通常会被放到微任务阶段继续执行。
Promise.then 和 setTimeout 谁先执行
如果都已就绪,通常先清空微任务,所以 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 4 7 - 再清空微任务,所以输出
5 - 再执行第一个宏任务,输出
2 - 这个宏任务里又塞了微任务,所以接着输出
3 - 最后执行后面的宏任务,输出
6
最短记忆方式
每轮先跑宏任务,结束后清空微任务。