1.引言
- 事件循环机制就是
JavaScript
中处理异步操作的核心机制,它确保了代码的执行顺序符合预期的顺序。 - 众所周知,
JavaScript
是一个单线程的语言,这就意味着它一次只会执行一个任务,那这样的话就会造成一个问题就是如果有一个线程阻塞的话,整个程序都会被阻塞。 - 为了解决这个问题,
JavaScript
引入了事件循环机制,它允许JavaScript
在执行任务的同时,处理异步操作。 这样我们提高了程序的性能,同时也确保了代码的执行顺序符合预期的顺序。 - 循环就体现了这个过程它是往复的,直到没有任务需要处理。 4 事件循环机制是我们异步编程的基础,
Promise,Generator,Async/Await
等都是基于事件循环机制的。
2.基础理论
2.1 事件循环机制的基本原理
- 事件循环机制的基本原理就是
JavaScript
它会去维护一个执行栈和一个任务队列,每一次执行任务的时候,都会将任务放到执行栈中去执行。 - JS任务分为同步任务和异步任务,同步任务会直接进入执行栈中执行,而异步任务则会先被放到任务队列中等待执行。
- 执行栈中的任务执行完毕后,JS引擎会去任务队列中读取一个待执行的任务,将其放到执行栈中执行。
- 如此往复,直到任务队列为空,事件循环机制结束。
2.2 这里我们来举个例子讲述一下setTimeout/setInterval
(指定定时任务)以及XHR/fetch
(发送网络请求)它们到底做了什么事情
在setTimeout/setInterval
以及XHR/fetch
这些代码执行时,本身是一个同步任务,但是它们的回调函数是异步任务,
- 当遇到
setTimeout/setInterval
代码时,JS引擎会先通知定时触发器线程,告诉它有一个定时任务需要执行,
然后继续执行后面的同步任务,定时触发器线程会等待到指定的时间后,将回调函数放到任务队列中等待执行。 - 当遇到
XHR/fetch
代码时,JS引擎会先通知异步http
请求线程,告诉它有一个网络请求需要发送,
然后继续执行后面的同步任务,异步http
请求线程会等待网络请求的响应,在请求成功之后,异步http
请求线程将回调函数放到任务队列中等待执行。
- 当我们同步任务执行完之后,JS引擎会询问事件触发线程,是否有待执行的回调函数,
- 如果有,则将回调函数放到执行栈中执行,如果没有,JS引擎线程将保持空闲状态,等待新的任务到来。
- 这样就实现了异步任务和同步任务的交替执行。
3.宏任务与微任务
** 3.1 宏任务与微任务的概念**
- 宏任务(
macrotask
)和微任务(microtask
)是事件循环机制中的两个重要概念。 - 宏任务通常包括:
setTimeout、setInterval、I/O、UI渲染
等。 - 微任务通常包括:
Promise、MutationObserver、process.nextTick
等。
3.2 宏任务与微任务的执行顺序
- 宏任务是在事件循环的每个迭代中按顺序执行,每次迭代从宏任务队列中取出一个任务来执行。
- 微任务在当前宏任务执行完毕后、下一次宏任务开始前执行,且会立即在当前执行栈中连续执行直到微任务队列为空。
4.实践技巧
4.1 下面我们玩几个例子,来熟悉一下事件循环机制
例1:
console.log('Start');
setTimeout(() => {
console.log('setTimeout Callback');
}, 0);
Promise.resolve().then(() => {
console.log('Promise then');
});
console.log('End');
来来来,让我们一起来狠狠地分析一波
- 首先肯定是执行同步代码,所以先打印出
Start
,然后遇到了setTimeout
, - 它是一个宏任务,所以将其放入宏任务队列中等待执行,然后遇到了
Promise
, - 它是一个微任务,所以将其放入微任务队列中等待执行,然后继续执行同步代码,打印出
End
, - 然后就要去执行微任务,此时微任务队列中有一个微任务,
- 于是将其取出并执行,打印出
Promise then
, - 然后再去执行宏任务,此时宏任务队列中有一个宏任务,
- 于是将其取出并执行,打印出
setTimeout Callback
, - 最后事件循环机制结束。
所以打印结果应为
Start
End
Promise then
setTimeout Callback
例2:
console.log('Start');
new Promise((resolve) => {
console.log('Promise Executor');
resolve();
}).then(() => {
console.log('Promise then');
});
console.log('End');
来呗又满上,
-
首先肯定是执行同步代码,所以先打印出
Start
, -
然后遇到了**
Promise
,Promise
构造函数内的代码立即执行(作为当前宏任务的一部分),其then
回调**作为微任务在所有同步代码执行完后执行, -
然后继续执行同步代码,打印出
End
, -
然后就要去执行微任务,此时微任务队列中有一个微任务,
-
于是将其取出并执行,打印出
Promise then
, -
最后事件循环机制结束。
所以打印结果应为
Start
Promise Executor
End
Promise then
例3:
console.log('Start');
async function asyncFunction() {
await new Promise((resolve) => {
console.log('Promise');
setTimeout(resolve, 0)
});
console.log('asyncawait');
}
asyncFunction();
console.log('End');
两个怎么够呢,再来一个
-
同步代码执行:首先确实会打印出
"Start"
,因为这是最先遇到的同步代码。 -
进入
asyncFunction
:接着执行asyncFunction。在asyncFunction内部,首先打印出"Promise"
,这是因为在Promise
构造函数内的同步代码会立即执行。 -
遇到
await
:当执行到await new Promise(...)
时,asyncFunction
会在此暂停,等待Promise
解决。 -
继续执行全局脚本:在
await
等待期间,控制权返回到调用者,因此console.log('End')
被执行,打印出"End"
。 -
事件循环与微任务:当
setTimeout
设定的0毫秒延迟到达后,其回调函数(即resolve
)被加入到宏任务队列(而非微任务队列,这是一个常见的误解,因为setTimeout
是典型的宏任务源)。当当前执行栈为空,且微任务队列处理完毕后,事件循环会检查宏任务队列并执行setTimeout
的回调,从而解决之前的Promise
。 -
Promise
解决后的微任务:Promise
被解决后,await
后面的代码(console.log('asyncawait')
)被加入到微任务队列。在下一次事件循环检查微任务队列时,这部分代码会被执行,因此打印出 “asyncawait
”。 -
最后事件循环机制结束。
所以打印结果应为
Start
Promise
End
asyncawait
相信你通过上面三个例子,对这个事件循环机制能够有很好的理解
4.2 性能优化:利用事件循环机制
- 减少
UI
阻塞:将耗时操作放入微任务或宏任务队列末尾,确保UI线程可以及时响应用户交互。例如,使用requestAnimationFrame
进行动画渲染,确保与浏览器的绘制周期同步,减少页面重绘的开销。 - 拆分长任务:如果某个任务执行时间过长,考虑将其拆分为多个小任务,利用事件循环机制插入其他任务,比如UI更新,这样可以保持应用的响应性。例如,将大数据量的处理分割成多次处理,每处理一部分就
yield
出控制权。 - 优先使用
Promise
和async/await
:相比传统的回调函数,Promise
和async/await
提供了更清晰的代码结构和更好的错误处理机制,同时它们对事件循环的管理更加高效,特别是async/await
使得异步代码看起来更像同步代码,易于理解和维护。 - 避免过度使用微任务:虽然微任务有较高的优先级,但过度依赖微任务会导致它们堆积,特别是在递归调用或复杂逻辑中,可能无意中造成性能瓶颈。合理安排宏任务与微任务的使用,平衡执行效率和响应性。
- 利用
nextTick
:nextTick
是Vue.js
中用于在DOM
更新后执行某些操作的API
,它利用了事件循环机制,确保在DOM
更新完成后执行。利用nextTick
可以避免在DOM
更新期间进行DOM
操作,提高性能。
5.深入理解
5.1 Node.js
事件循环模型
- 关于
Node.js
事件循环模型,可以参考Node.js
官方文档, - 这里简单介绍一下,
Node.js
事件循环模型分为6个阶段, - 每个阶段都有一个
FIFO
的宏任务队列和一个FIFO
的微任务队列, - 每个阶段执行完毕后都会去检查微任务队,
- 只有当微任务队为空时才会去执行下一个阶段。
- 每个阶段的具体任务如下:
1.timers
(定时器):执行setTimeout
和setInterval
的回调函数。
2.I/O callbacks
(I/O轮询):执行除了close
事件、定时器和setImmediate
的回调函数,比如IO回调,比如文件操作、网络请求等。这个阶段会不断轮询检查是否有已完成的I/O操作,如果有,则执行相应的回调。
3.idle, prepare
(闲置、准备):Node.js
内部使用,与用户代码关系不大。
4.poll
(轮询):获取新的I/O
事件,适当的条件下node
将阻塞在这里。
5.check
(检查):执行setImmediate
的回调函数。
6.close callbacks
(关闭回调):执行close
事件的回调函数。
Node.js
事件循环模型是Node.js
平台的核心组件,它确保了事件驱动的异步编程模型能够高效地运行。
5.2 浏览器事件循环模型即上面的分析方式就是浏览器事件循环模型
5.3 边缘情况分析:
- 1.微任务嵌套
微任务可以嵌套,即在一个微任务的执行过程中可以继续添加新的微任务到队列中。
这可能导致大量的微任务累积,若不谨慎处理,可能会引起事件循环的“饿死”现象,即长时间无法执行宏任务。 - 2.宏任务嵌套
虽然直接的宏任务嵌套(如在一个setTimeout
回调中立即调用另一个setTimeout
)不会改变执行顺序,
但复杂的宏任务嵌套逻辑可能会影响事件循环的流畅性,尤其是当它们涉及I/O
操作或大量计算时。 - 3.定时器的不准确性
无论是setTimeout
还是setInterval
,它们的执行时间都只能保证在指定时间后至少执行一次,实际执行时间可能会晚于预期,原因包括但不限于:- 当前执行栈未清空,必须等待当前任务完成。
- 宏任务队列中有其他任务等待执行。
- 系统资源限制或高
CPU
占用导致的延迟。
虽然这两种环境在细节上面有所差异,但是都遵守宏任务和微任务的区分原则。
5.4 怎么理解nextTick
nextTick
是Vue.js
提供的一种方法,用于在Vue
的异步更新队列清空后执行的一个回调函数。Vue.js
为了提高性能,采用了异步更新DOM
的策略,这意味着数据变化后,并不会立即去更新视图,- 而是当同步代码执行完成之后,进行批量更新。这样减少了对
DOM
的操作,提高应用性能。 nextTick
的原理也是基于JavaScript
的事件循环机制的。nextTick
的使用场景:- 1.获取更新之后的
DOM
元素:可以在nextTick
中获取更新之后的DOM
元素,确保获取到的DOM
元素是最新更新的。 - 2.避免不必要的渲染:在某些情况下,可以合并多次数据修改,通过
nextTick
来确保DOM
元素只更新一次,减少渲染次数,提高性能。
- 1.获取更新之后的
6.最佳实践
6.1 Web Workers
与Service Workers
关于Web Workers
与Service Workers
,到时候专门玩一下
7.总结
7.1 关键概念
- 1.事件循环基础:事件循环基础是
JavaScript
实现异步执行的重要机制。它基于任务队列的概念,分为宏任务和微任务 - 2.宏任务:主要包括
DOM
事件,setTimeout/setInterval
,I/O
,UI
渲染等,这些任务在每次事件循环结束之后再执行。 - 3.微任务:主要包括
Promise
回调,process.nextTick(Node.js特有)
等,在JS引擎将执行栈的任务处理完之后,会在执行在同一事件循环的微任务,优先级会高于宏任务 - 4.执行流程:先执行当前宏任务,然后清空所有微任务,接着检查是否有新的宏任务需要执行,如此循环往复。
7.2 实践技巧
- 1.避免过度嵌套,因为微任务或宏任务嵌套过于复杂之后,会发现性能非常的低
- 2.尽量使用
async/await
的组合,这回提高效率以及对异步代码会非常清晰,因为使用这个组合,会让异步代码看起来像是同步的代码。
(查阅资料发现)还有下面这一点 - 3.控制微任务执行时机:在某些场景下,比如数据更新后立即读取
DOM
,使用Promise.resolve().then(...)
或queueMicrotask(...)
来精确控制执行时机。