1

1.引言

1.1 事件循环机制就是JavaScript中处理异步操作的核心机制,它确保了代码的执行顺序符合预期的顺序。
1.2 众所周知,JavaScript是一个单线程的语言,这就意味着它一次只会执行一个任务,那这样的话就会造成一个问题就是如果有一个线程阻塞的话,
整个程序都会被阻塞。为了解决这个问题,JavaScript引入了事件循环机制,它允许JavaScript在执行任务的同时,处理异步操作。
这样我们提高了程序的性能,同时也确保了代码的执行顺序符合预期的顺序。
1.3 循环就体现了这个过程它是往复的,直到没有任务需要处理。
1.4 事件循环机制是我们异步编程的基础,Promise,Generator,Async/Await等都是基于事件循环机制的。

2.基础理论

2.1 事件循环机制的基本原理

  • 事件循环机制的基本原理就是JavaScript它会去维护一个执行栈和一个任务队列,每一次执行任务的时候,都会将任务放到执行栈中去执行。
  • JS任务分为同步任务和异步任务,同步任务会直接进入执行栈中执行,而异步任务则会先被放到任务队列中等待执行。
  • 执行栈中的任务执行完毕后,JS引擎会去任务队列中读取一个待执行的任务,将其放到执行栈中执行。
  • 如此往复,直到任务队列为空,事件循环机制结束。

2.2 这里我们来举个例子讲述一下setTimeout/setInterval(指定定时任务)以及XHR/fetch(发送网络请求)它们到底做了什么事情

setTimeout/setInterval以及XHR/fetch这些代码执行时,本身是一个同步任务,但是它们的回调函数是异步任务,
(1)当遇到setTimeout/setInterval代码时,JS引擎会先通知定时触发器线程,告诉它有一个定时任务需要执行,
然后继续执行后面的同步任务,定时触发器线程会等待到指定的时间后,将回调函数放到任务队列中等待执行。
(2)当遇到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
  • 然后遇到了PromisePromise构造函数内的代码立即执行(作为当前宏任务的一部分),其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 性能优化:利用事件循环机制

1.减少UI阻塞:将耗时操作放入微任务或宏任务队列末尾,确保UI线程可以及时响应用户交互。例如,使用requestAnimationFrame进行动画渲染,确保与浏览器的绘制周期同步,减少页面重绘的开销。
2.拆分长任务:如果某个任务执行时间过长,考虑将其拆分为多个小任务,利用事件循环机制插入其他任务,比如UI更新,这样可以保持应用的响应性。例如,将大数据量的处理分割成多次处理,每处理一部分就yield出控制权。
3.优先使用Promiseasync/await:相比传统的回调函数,Promiseasync/await提供了更清晰的代码结构和更好的错误处理机制,同时它们对事件循环的管理更加高效,特别是async/await使得异步代码看起来更像同步代码,易于理解和维护。
4.避免过度使用微任务:虽然微任务有较高的优先级,但过度依赖微任务会导致它们堆积,特别是在递归调用或复杂逻辑中,可能无意中造成性能瓶颈。合理安排宏任务与微任务的使用,平衡执行效率和响应性。
5.利用nextTicknextTickVue.js中用于在DOM更新后执行某些操作的API,它利用了事件循环机制,确保在DOM更新完成后执行。利用nextTick可以避免在DOM更新期间进行DOM操作,提高性能。

5.深入理解

5.1 Node.js事件循环模型

关于Node.js事件循环模型,可以参考Node.js官方文档,
这里简单介绍一下,Node.js事件循环模型分为6个阶段,
每个阶段都有一个FIFO的宏任务队列和一个FIFO的微任务队列,
每个阶段执行完毕后都会去检查微任务队,
只有当微任务队为空时才会去执行下一个阶段。
每个阶段的具体任务如下:
1.timers(定时器):执行setTimeoutsetInterval的回调函数。
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

  • nextTickVue.js提供的一种方法,用于在Vue的异步更新队列清空后执行的一个回调函数。Vue.js为了提高性能,采用了异步更新DOM的策略,这意味着数据变化后,并不会立即去更新视图,
  • 而是当同步代码执行完成之后,进行批量更新。这样减少了对DOM的操作,提高应用性能。
  • nextTick的原理也是基于JavaScript的事件循环机制的。
  • nextTick的使用场景:
    1.获取更新之后的DOM元素:可以在nextTick中获取更新之后的DOM元素,确保获取到的DOM元素是最新更新的。
    2.避免不必要的渲染:在某些情况下,可以合并多次数据修改,通过nextTick来确保DOM元素只更新一次,减少渲染次数,提高性能。

6.最佳实践

6.1 Web WorkersService Workers
关于Web WorkersService Workers,到时候专门玩一下

7.总结

7.1 关键概念

1.事件循环基础:事件循环基础是JavaScript实现异步执行的重要机制。它基于任务队列的概念,分为宏任务和微任务
2.宏任务:主要包括DOM事件,setTimeout/setIntervalI/OUI渲染等,这些任务在每次事件循环结束之后再执行。
3.微任务:主要包括Promise回调,process.nextTick(Node.js特有)等,在JS引擎将执行栈的任务处理完之后,会在执行在同一事件循环的微任务,优先级会高于宏任务
4.执行流程:先执行当前宏任务,然后清空所有微任务,接着检查是否有新的宏任务需要执行,如此循环往复。

7.2 实践技巧

1.避免过度嵌套,因为微任务或宏任务嵌套过于复杂之后,会发现性能非常的低
2.尽量使用async/await的组合,这回提高效率以及对异步代码会非常清晰,因为使用这个组合,会让异步代码看起来像是同步的代码。
(查阅资料发现)还有下面这一点
3.控制微任务执行时机:在某些场景下,比如数据更新后立即读取DOM,使用Promise.resolve().then(...)queueMicrotask(...)来精确控制执行时机。


肉夹馍
4 声望2 粉丝

努力学习,想要做全栈工程师的前端从事人员😁