什么什么,竟然还有人没搞懂JavaScript的事件循环机制吧

JS的运行机制

大家都知道JavaScript是一门单线程的语言,在一个时间下只做一件事。

至于为什么是单线程呢,其实是与用途又关系的。因为JavaScript作为游览器脚本语言,它的主要用途是与用户进行交互,以及操作DOM。如果,它是一个多线程,那一个线程删除了一个DOM,另一个线程在这个DOM上增加内容或修改内容。那这时候该怎么渲染?

因此,从一诞生,JavaScript就是单线程的,是这个语言的核心特征。

当然,HTML5提出了Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程受主线程控制且不可操作DOM,这个标准并没有更改单线程的本质。

同步与异步

单线程的特征,会造成一些问题,比如:当我们请求服务器接口时,那在等待数据返回之前,页面就无法进行任何操作,会出现假死的状态。但是实际上,我们并没有遇见这种情况。

这是因为我们通过异步(非阻塞)执行模式解决单线程造成的问题。

同步(阻塞)

说到异步,我们就能自然其然地想到同步(阻塞)。

什么叫同步?就好像我们去做核酸排队,大白按照排队顺序依次给我们检测,后面的人只能等前面的人检测好了才能检测。如果大白突然走了,那么整个队伍后续的人都不能检测了。 我们可以看如下代码:

let a = 1;
let b = 2;
let c = a + b;
console.log(c); // 3
let t1 = new Date().getTime();
let t2 = new Date().getTime();
while (t2 - t1 < 2000) {
    t2 = new Date().getTime();
}
console.log('大约2秒后打印')
复制代码

上述代码会从上依次执行,到while时会进入循环,等待大约2秒后,才会跳出循环再执行最后一行的打印。这就导致了阻塞的出现。 阻塞的特点就是:遇到消耗时间的代码片段,必须要等耗时的代码执行完毕,才能继续执行后面的代码。

异步(非阻塞)

异步与同步对立,它不会阻塞程序的运行。当程序在运行的时候,遇到了异步模式的代码,引擎会把异步的任务挂起来并先略过,然后继续执行非异步的代码。当同步代码执行完了,再把刚刚挂起来的异步代码按照特定顺序进行执行。 我们可以看看如下代码:

let a = 1
let b = 2
setTimeout(function(){
    console.log('2秒后输出')
},2000)
console.log(a+b)
复制代码

程序会首先输出3,然后等待2秒后输出2秒后输出。因为在程序执行的时候,碰到了setTimeout时不会直接执行内部的回调函数,而是先将内部的函数挂起来,继续执行下面的同步代码,同步代码执行完毕后,等待大概2秒再回过头来执行刚刚挂起来的函数。

异步可以想象成,你要去超市买菜,你不可能想到了做一盘菜去超市买一次菜。你会先把需要做的菜列出来,然后去超市买菜。从货架拿菜的时候,不一定是列出来的第一个菜,但是都是列出来的那些菜。

同步异步总结

JavaScript的运行顺序就是严格的单线程的异步模式:同步在前,异步在后。异步任务需要等待当前的同步任务执行完成后才开始执行。

JS线程组成

虽然游览器是单线程执行JavaScript代码的,但是游览器有多个线程协助操作来实现单线程异步模型。

  1. GUI渲染线程
  2. JavaScript引擎线程
  3. 事件触发线程
  4. 定时器触发线程
  5. http请求线程
  6. 其他线程

在JavaScript代码运行的过程中实际执行程序时同时只存在一个活动线程,实现同步异步就是靠多线程切换的形式来实现。

上面的细分线程可以归纳为两条线程:

  1. 【主线程】:执行页面的渲染,js代码的运行,事件的触发等
  2. 【工作线程】:在幕后工作,用来处理异步任务的执行来实现非阻塞的运行模式

JavaScript事件循环机制

上述中,我们将线程分为了主线程和工作线程,主线程的代码在运行的时候,碰到了同步代码,就放入执行栈中去执行,碰到了异步代码,则放入工作线程中暂时挂起。

执行栈中负责执行代码,遵从先进后出的原则,执行函数时,会按照从外到内的顺序依次运行,可能会涉及到对象的数据存储在堆内存中。

工作线程中挂起的任务都是一些异步的任务,比如网络请求、定时函数、交互事件等等。

当执行栈中的任务全部执行完毕后,执行栈清空了,事件循环就会开始工作,它会检测消息队列中是否有需要执行的任务,这个任务来源工作线程中挂起的任务,工作线程会把到期的异步任务按照顺序插入到消息队列里。如果有需要执行的任务,则会按照先进先出的顺序防盗执行栈中执行,直到消息队列也被清空。

image.png

   function f1() {
    console.log("f1被执行");
  }
  
  function f2() {
    console.log("f2被执行");
  }
  
  function f3() {
    console.log("f3被执行");
    setTimeout(() => {
      console.log(123);
      setTimeout(() => {
        console.log(1234);
      }, 0);
    }, 0);
  }
  
  function f4(fn) {
    fn();
    console.log("f4被执行");
  }
  
  function f5() {
    console.log("f5被执行");
  }
  
  function f6() {
    console.log("f5被执行");
  }
  
  setTimeout(() => {
    f5();
  }, 0);
  
  setTimeout(() => {
    f6();
  }, 1000);
  f1();
  f2();
  f4(f3);
 // f1被执行->f2被执行->f3被执行->f4被执行->f5被执行->123->1234->f6被执行
复制代码

如上面代码所示,定义了5个函数,主程序在执行的时候首先碰到了2个异步的任务,分别是0秒后执行f51秒后执行f6,主程序会把这2个异步任务挂起来(即放入到工作线程中),继续执行下面代码。

然后首先f1函数进入了执行栈执行,f1函数执行完毕后出栈。

再把f2函数放入执行栈执行,f2函数执行完毕后出栈。

再把f4函数放入执行栈中执行,执行f4函数的时候发现它内部执行了f3函数,这时候再把f3函数放到执行栈中去执行,f3执行的时候发现一个打印123的定时器,然后会把定时器放入工作线程中,f3函数执行完毕后出栈了,再继续执行f4函数后面的部份。

当主线程的同步代码全部执行完毕后,这时候事件循环机制就会起来了工作了,不停的从消息队列中读取是否存在需要执行的任务,如果存在就放到执行栈中去执行。

接下来就是执行异步任务的时候,现在我们有3个异步任务要执行,f5需要0秒,f6需要1秒,打印123的任务也需要0秒

虽然f5打印123的任务时间相同,但是f5打印123的任务优先进入工作线程,所以f5会优先进入到消息队列。

这时候事件循环机制发现消息队列里有任务了,就会把f5取出来放到执行栈中执行然后出栈。

然后打印123的任务进入执行栈,执行的时候发现还有一个异步任务打印1234,会把这个异步任务放入到工作线程挂起,然后执行完出栈。

异步任务打印1234所需时间是0秒,会比f6优先进入消息队列,再被事件循环机制取出执行。

f6进入消息队列后也一样。直到消息队列清空。

执行栈:按照后进先出的顺序执行进入执行栈的任务,执行后出栈。

工作线程:存放挂起的异步任务,首先按照时间顺序,哪个任务时间到了就把该任务放到消息队列,如果有相同的时间,则按照进入工作线程的顺序,依次进入消息队列。

事件循环机制:等待同步代码执行完毕后,不断地从消息队列(先进先出)中取任务,然后放入执行栈执行。

关于执行栈(补充)

从上述内容可知,当我们运行单层的函数时,执行栈会执行函数,然后出栈销毁。然后下一个再进栈执行,然后出栈销毁,依次反复。 但是如果有嵌套调用时,执行栈中就会堆积栈帧。

function test1(fn) {
    fn()
    console.log('test1')
}
function test2() {
    test3()
    console.log('test2')
}
function test3() {
    console.log('test3')
}

test1(test2) // test3 -> test2 -> test1
复制代码

如上所示,当程序执行的时候,test1会进入到执行栈中执行,然后执行的时候发现test1调用了fnfn即传入的test2,这是执行栈会把test2放入到栈顶并执行,然后test1会停顿

执行test2的时候会发现test2中调用了test3,然后会把test3放入到栈顶,根据先进后出的原则优先执行test3;

test3执行完毕后会出栈,然后继续执行test2,test2执行完毕后再出栈,继续执行test1。

这时我们就会想到了递归。递归函数就可以看成一个函数中嵌套了N层执行,执行过程中会触发大量的栈帧堆积,但是执行栈是有深度的,过大的栈帧堆积会造成栈溢出。

栈的深度

栈的深度会根据不同的游览器和JS引擎有不同的区别。

如下所示,我们发现在递归了11390次之后就提示超过栈深度的错误了。

  let i = 0;
  function test() {
    i++;
    console.log(i);
    test();
  }
  test();
复制代码

image.png

** 如何跨越递归限制 **

这时候我们可以探究一下如何跨越递归限制呢?

之前我们说过,事件循环机制会在执行栈中的程序执行完毕后启用,不停地从消息队列读取消息,再放到执行栈中执行。执行栈在执行的时候如果碰到了异步任务,会把异步任务放到工作线程挂起,然后继续执行后续的代码。

因此我们是不是可以把递归的执行函数放入一个异步任务里?这样子执行栈就不会堆积栈帧,每次只从消息队列取任务执行,然后将递归调用的任务放入工作线程,继续执行后续的同步代码,同步代码执行完毕后,再读取消息队列中到期的异步任务放入执行栈中执行,依次反复。

  let i = 0;
  function test() {
    i++;
    console.log(i);
    setTimeout(()=>{
    test();
    },0)
  }
  test();
复制代码

image.png 针不戳呀,但是不能保证运行速度。

事已至此,至于宏任务和微任务,后面再说!

猜你喜欢

转载自juejin.im/post/7097518995620200485