主题
JavaScript 事件循环总结
本文总结了 JavaScript 事件循环(Event Loop)机制,涵盖了同步任务、微任务、宏任务的执行顺序,以及与
setTimeout
、Promise
相关的实际行为分析。
一、JavaScript 是单线程语言
- JavaScript 的运行环境(浏览器 / Node)中,JS 引擎是单线程的,所有同步代码、异步回调最终都在这个主线程中执行。
setTimeout
注册后,会被浏览器交给定时器线程计时;- 计时完成后,如果主线程空闲,才会将回调推入宏任务队列;
- 回调函数最终仍由 JavaScript 的单线程事件循环来执行。
二、事件循环的执行流程
每一轮事件循环(Event Loop)执行顺序如下:
1. 执行主线程同步代码
- 所有同步代码进入执行栈,逐行执行。
- 遇到异步任务(如定时器、事件监听、Promise 等)时,根据任务类型分别处理:
2. 微任务(Microtasks)
- 遇到如
Promise.then
、queueMicrotask
、MutationObserver
等会立即将任务推入微任务队列。 - 微任务的推送是同步行为,但执行是异步(事件循环的下一阶段)。
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 是怎么工作的?
- JS 执行
setTimeout(fn, delay)
,将fn
和延迟时间交给浏览器的 定时器线程。 - 浏览器计时,到达 delay 时间后,尝试将
fn
推入宏任务队列(主线程空闲)。 - 等当前一轮事件循环执行完同步代码 + 微任务后,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)
执行步骤
- 同步代码执行,将两个链的第一个then回调推入微任务队列
- 执行第一个微任务(A链第一个then)
- 输出 A: 0
- 返回 Promise.resolve('A: 4')
- 由于返回的是 Promise,需要两个额外的微任务来处理
- 第一个额外微任务:处理返回的 Promise
- 第二个额外微任务:将值传递给下一个 then
- 执行第二个微任务(B链第一个then)
- 输出 B: 1
- 将下一个 then 回调推入微任务队列
- 执行额外任务1(处理返回的Promise)
- 创建第二个额外微任务来传递值
- 执行B链第二个then
- 输出 B: 2
- 将下一个 then 回调推入微任务队列
- 执行额外任务2(传递值)
- 将值 'A: 4' 传递给A链的下一个 then
- A链的第二个 then 回调推入微任务队列
- 执行B链第三个then
- 输出 B: 3
- 将下一个 then 回调推入微任务队列
- 执行A链第二个then
- 输出 A: 4
- 执行B链第四个then
- 输出 B: 5
- 最后一个 then 回调推入微任务队列
- 执行B链第五个then
- 输出 B: 6
最终输出
js
A:0
B:1
B:2
B:3
A:4
B:5
B:6