1.背景
简单来说,JS 的事件循环(Event Loop)机制,决定了什么时候该执行哪一行代码。虽然 JS 是单线程的,但浏览器是多线程的,所以 JS 借助浏览器提供的能力,实现了 JS 的异步编程(比如 setTimeout 方法)。如果没有 JS 事件循环,则当浏览器执行一段耗时的 JS 同步代码时,就没有办法及时响应用户的其他操作,从而给用户带来不好的体验。
2.事件循环的简单解释
事件循环负责收集事件(如mousemove、setTimeout等),并对事件触发的回调任务进行排队以便在合适的时候执行。先执行宏任务,然后是微任务,然后在开始下一次循环之前执行一些必要的渲染,如此循环往复。
3.事件循环的详细解释
JS 的事件循环主要由这几部分组成:调用栈、web API、微任务队列、宏任务队列
事件循环机制
当 JS 主线程执行到同步方法时,会将该方法的执行上下文推入调用栈的栈顶。当执行到异步方法时(如 setTimeout),会将该方法交给其他线程(如定时器线程)来处理,待处理完后,将处理结果作为该异步方法的回调的参数,并和回调函数一起作为一个任务,放入任务队列等待 JS 主线程执行。若是微任务则放入微任务队列,若是宏任务则放入宏任务队列。当首轮循环中,调用栈中的任务都已执行完毕,JS 主线程会执行微任务队列中的任务,直到微任务队列为空,即使在执行微任务的过程中,有新的微任务产生,也会在本轮执行。然后再执行宏任务队列中的任务,执行宏任务的过程中,新产生的宏任务则会放到下一轮循环中执行。在每轮事件循环的末尾则可能会做一些渲染动作,执行宏任务、微任务的时候不会进行渲染。当所有代码都已执行完毕,JS 主线程则进入“休眠状态”等待新的任务产生,如此循环往复,从而形成事件循环。
调用栈
JS 调用栈(Call Stack)是一个用于记录函数调用的数据结构,它遵循先进后出的原则(LIFO)。当一个函数被调用时,它的执行上下文(包括函数参数、局部变量等)被添加到调用栈的顶部。当函数执行完毕时,它的执行上下文被从调用栈中弹出,控制权回到调用该函数的上一个执行上下文。
web API
JS 的 Web API 是浏览器提供的一组 API,用于与浏览器环境交互,包括 DOM、XMLHttpRequest、Fetch、setTimeout 等。这些 API 不是 JS 语言本身的一部分,而是浏览器提供的扩展,通过这些 API 可以让 JS 与浏览器环境进行交互。
微任务队列
微任务队列用于存放微任务,它遵循先进先出的原则(FIFO)。常见的微任务有:Promise.then 的回调、MutationObserver 的回调、queueMicrotask 的回调。当一个微任务被调度时,它被添加到微任务队列的末端,排队等待 JS 主线程的执行。
宏任务队列
宏任务队列用于存放宏任务,它遵循先进先出的原则(FIFO)。常见的宏任务有:setTimeout 的回调、 setInterval 的回调、requestAnimationFrame 的回调、IO事件。当一个宏任务被调度时,它被添加到宏任务队列的末端,排队等待 JS 主线程的执行。
4.例子
1.简单的例子
-
执行
console.log('同步代码1')
,为同步代码,直接将其执行上下文放入执行栈栈顶执行,控制台输出"同步代码1"
后,将其执行上下文出栈销毁 -
执行
setTImeout(() => { console.log('setTimeout') },0)
,为异步代码,交给定时器线程处理,定时结束后,将回调()=>{ console.log('setTimeout') }
作为宏任务放入宏任务队列等待 JS 主线程执行 -
执行
new Promise((resolve) => { console.log('同步代码2'); resolve(); })
,为同步代码,直接将其执行上下文放入执行栈栈顶执行,先执行console.log('同步代码2')
,控制台输出"同步代码2"
,再执行resolve()
,将 then 方法的回调() => { console.log('promise.then') }
放入微任务队列等待 JS 主线程执行,然后将该段代码的执行上下文出栈销毁 -
执行
console.log('同步代码3')
,为同步代码,直接将其执行上下文放入执行栈栈顶执行,控制台输出"同步代码3"
后,将其执行上下文出栈销毁 -
第一轮循环中的宏任务执行完毕,此时执行栈为空,去微任务队列按顺序取出微任务放入执行栈执行,此时微任务队列中只有
() => { console.log('promise.then') }
,所以将其执行上下文放入执行栈栈顶执行 ,控制台输出"promise.then"
后,将其执行上下文出栈销毁 -
第一轮循环中的微任务执行完毕,此时执行栈为空,进入第二轮事件循环,去宏任务队列按顺序取出宏任务放入执行栈执行。此时宏任务队列中只有
()=>{ console.log('setTimeout') }
,所以将其执行上下文放入执行栈栈顶执行 ,控制台输出"setTimeout"
后,将其执行上下文出栈销毁 -
至此所有代码执行完毕,控制台最终结果为:
同步代码1 同步代码2 同步代码3 promise.then setTimeout
,JS主线程进入“休眠状态”并等待新的任务出现...
2.复杂的例子
console.log(1);
setTimeout(() => { //setTimeout-01
console.log(2);
queueMicrotask(() => { console.log(3); })
new Promise((resolve) => {
console.log(4);
resolve();
}).then(() => { console.log(5); })
})
queueMicrotask(() => { console.log(6); })
new Promise((resolve) => { //Promise-01
console.log(7);
resolve();
}).then(() => { console.log(8); })
setTimeout(() => { //setTimeout-02
console.log(9);
queueMicrotask(() => { console.log(10); })
new Promise((resolve) => {
console.log(11);
resolve();
}).then(() => { console.log(12); })
})
- 执行
console.log(1);
,控制台输出1
- 执行
setTimeout-01
,将其回调放入宏任务队列尾部 - 执行
queueMicrotask(() => { console.log(6); })
,将其回调放入微任务队列尾部 - 执行
Promise-01
,控制台输出7
,并将() => { console.log(8); }
放入微任务队列尾部 - 执行
setTimeout-02
,将其回调放入宏任务队列尾部 - 首轮的宏任务执行完毕,开始执行微任务队中的微任务:控制台输出
6
,控制台输出8
- 首轮的微任务执行完毕,开始第二轮的事件循环,从宏任务队列头中取出
setTimeout-01
并执行 - 控制台输出
2
,并将() => { console.log(3); }
放入微任务队列尾部 - 控制台输出
4
,并将() => { console.log(5); }
放入微任务队列尾部 - 第二轮的宏任务执行完毕,开始执行微任务队中的微任务:控制台输出
3
,控制台输出5
- 第二轮的微任务执行完毕,开始第三轮的事件循环,从宏任务队列头中取出
setTimeout-02
并执行 - 控制台输出
9
,并将() => { console.log(10); }
放入微任务队列尾部 - 控制台输出
11
,并将() => { console.log(12); }
放入微任务队列尾部 - 第三轮的宏任务执行完毕,开始执行微任务队中的微任务,控制台输出
10
,控制台输出12
- 第三轮的微任务执行完毕, 至此,所有的代码执行完毕,JS主线程进入“休眠状态”并等待新的任务出现...
所以,控制台最终的输出结果顺序为:1 7 6 8 2 4 3 5 9 11 10 12
5.小技巧
- 提高渲染性能:对于动画或需要高频率更新的场景,使用 requestAnimationFrame 能提高性能。
- 避免耗时的同步任务:可以将耗时较长的同步任务,拆分成多个较小的异步任务(比如作为 setTimeout 的回调),以避免阻塞主线程。
6.参考资料
zhuanlan.zhihu.com/p/580956436
zh.javascript.info/microtask-q…
developer.mozilla.org/zh-CN/docs/…