深入理解JavaScript事件循环:异步与同步的和谐共舞

577 阅读7分钟

前言

JavaScript的奇幻世界里,事件循环机制如同一位不知疲倦的指挥家,巧妙地协调着代码的演奏。它像一位智慧的调度员,在同步任务与异步任务之间穿梭,确保每一个音符(代码块)在正确的时间被奏响。无论是即时的操作指令,还是延迟的回调请求,事件循环都能有条不紊地处理,让程序流畅运行。这一机制赋予了JavaScript处理并发任务的能力,使其在单线程中也能舞出多线程的优雅。跟随这位指挥家的节奏,让我们一起探索JavaScript事件循环的奥秘,聆听代码和谐共鸣的美妙乐章。

进程和线程

进程

进程:CPU在运行指令和保存上下文所需要的时间

而进程各个领域代表的东西不同,拿我们手机举例,你打开微信或者哪一个游戏就是打开了一个进程,在浏览器层面,浏览器每开一个tab页面,就是新开一个进程

线程

线程:执行一段指令所需要的的时间

它是进程的子集,比如你使用了微信上的某个功能,那么就会给后台发送指令,实现这个指令的就是线程,一个进程里可以有多个线程进行工作,比如1. 渲染线程 2. js引擎线程 3. http线程 渲染线程就是前端htmlcss,一般线程可以同时工作的,但是js引擎线程和渲染线程做了特殊处理,它们两个是互斥的,因为js能够控制html,如果它们同时工作,遇到相同的东西,但是定义的值不同,那么就是无法执行,比如容器设定,如果同时执行js设定200,html设置为100,就无法执行了

所以js是单线程指的是v8引擎在运行js代码时,这个进程只有一个线程会被开启 但是可以特殊额外写代码开启

event-loop

let a =1
console.log(a);
setTimeout(function(){
    let b =2
    console.log(b);
    a++
    setTimeout(function(){
        b++
    },2000)
    console.log(b);
    
},1000)
console.log(a);

在这段代码里面我们写了一个定时器并在定时器里面有加了一个定时器,我们知道异步编程这一原理的宝子们都知道,遇到需要耗时的代码,v8引擎会先将它挂起,等不耗时的代码执行完毕才会执行耗时的代码,如果有完全不懂的宝子可以先看看这篇文章(juejin.cn/post/744262…) 然后这段代码怎么输出的呢,我们来分析一下,首先定义完a后直接执行第二行,输出a为1,然后碰到定时器,需要先将它整个挂起来,所以就直接执行第13行,a1,,不耗时的代码执行完毕开始执行耗时的代码,定义b=2,然后输出b,又碰到耗时代码将它挂起,执行第10行,b为2最后执行被挂起的代码。最后我们分析出来的打印结果是1,1,2,2。让我们来运行验证一下

95.png 可以看到我们分析的没错,而这种v8 按照先执行同步,在执行异步的策略,反复重复的执行方式就叫事件循环(event-loop),而决定代码的执行顺序的就是看代码的类型

同步代码

除了下面的异步任务,其它均归为同步任务

异步代码

微任务

  • 微任务:promise.then(), process.nextTick() , MutationObserver()

宏任务

script,setTimeout,setInterval,setImmediate,I/O,UI渲染/UI-rendering

知道有哪些同步代码,异步代码,以及异步代码中的宏任务和微任务,我们来看看事件循环的步骤是怎么样的吧

事件循环(event-loop)

event-loop步骤:

  1. 执行同步代码(这属于宏任务)
  2. 执行完同步后,检查是否有异步代码需要执行
  3. 执行所有的微任务
  4. 如果有需要,就渲染画面
  5. 执行宏任务,可能里面也有同步代码,也可能有微任务,宏任务

我们知道了执行步骤,那让我们来梳理一下下面代码的执行步骤

console.log(1);
new Promise(function(resolve, reject) {
  console.log(2);
  resolve()
})
.then(() => {
  console.log(3);
  setTimeout(() => {
    console.log(4);
  }, 0)
})
setTimeout(() => {
  console.log(5);
  setTimeout(() => {
    console.log(6);
  }, 0)
}, 0)
console.log(7);  

首先第一行是同步代码,所以直接输出1,然后碰到Promise,注意我们提到异步代码时,说的是的是promise.then(),而不是promise本身,所以这个也是同步代码,直接执行输出2,执行完promise后碰到.then,所以我们先将它放到微任务队列里面,接着有碰到定时器,将它放入宏任务队列里面,然后执行最后一行的同步代码,输出7,然后开始执行挂起的微任务,在微任务里面直接输出同步代码,输出3,然后碰到定时器,将它挂起,发现微任务执行完毕,开始执行宏任务,按照队列先进先出的规则,我们执行先进入队列的第12行,输出5,然后碰到定时器,将它放入队列,开始执行第二歌放入队列的第八行输出4,最后执行第三个放入队列的第14行定时器6。

我们可以看到这里定时器所写的时间都为0,那这也算异步代码吗,是的,异步代码不看你的设定,只看你是否使用了它,那根据我们的分析,最后的结为1,2,7,3,5,4,6

我们运行一下来验证我们的分析

96.png

可以看到结果和我们分析的是一样的。

那我们开始执行宏任务的时候,里面又可能会有宏任务,微任务,同步代码,那我们可不可以说我们又开启了一个事件循环呢。其实我们开始执行全局的时候就是开始执行一个宏任务了。

如果面试官提到事件循环这个概念,它可能会问到你,是不是一定先执行同步代码再执行异步代码呢,其实不是的,因为我们开始执行这段代码的时候,就是执行宏任务,这个宏任务可能是别的代码最后执行的队列中的宏任务,就好比一辆火车,你这节的车厢的尾巴是后面那节车厢的车头,你的车头也是另一辆车厢的车尾。

await

async function async1() {
    await async2()
    console.log('async1 end'); //去到了微任务队列
  }
  async function async2() {
    console.log('async2 end');
  }
  async1()
  console.log('script end');

我们了解异步编程后知道,await相当于.then,那我们分析这段代码,我们先执行同步代码,将第二段代码先挂起到微任务队列里面,输出第三段代码async1 end,然后输出最后一段代码script end,最后输出微任务队列里的第二行代码,输出async2 end,我们看看输出结果验证一下

97.png

可以到与我们的分析结果完全不同输出顺序为async2 end,async1 end,script end,其实是因为浏览器对await的执行提前了 (await 后面的代码当成同步来执行), 会将后续(下面)代码挤入微任务队列,因此第二行是同步代码,第三行放入了微任务队列,所以先执行第二行,最后执行第三行。

最后,来看看这段代码,如果你能直接分析出来,那么你就理解JavaScript事件循环的机制了

console.log('script start');
async function async1() {
  await async2()
  console.log('async1 end'); //去到了微任务队列
}
async function async2() {
  console.log('async2 end');
}
async1()
setTimeout(() => {
  console.log('setTimeout');
}, 0) 
new Promise((resolve, reject) => {
  console.log('promise');
  resolve()
})
.then(() => {
  console.log('then1');
})
.then(() => {
  console.log('then2');
});
console.log('script end');

结语

经过此番深入探索,我们仿佛揭开了JavaScript事件循环机制的神秘面纱。这一机制不仅是JavaScript处理异步任务的强大引擎,更是其灵活性和高效性的源泉。从同步代码的直接执行,到异步代码的精妙调度,事件循环以其独特的智慧,确保了代码的流畅运行。通过理解微任务与宏任务的执行顺序,以及await的特别行为,我们更加深刻地掌握了JavaScript的执行模型。