JavaScript同步、异步、事件轮询的理解

1,350 阅读8分钟

JavaScript同步、异步、事件轮询的理解

1.前言引入:

本文是摘自前端小智的学习笔记哈:https://juejin.cn/post/6844903943093354503

文章后面增加了宏任务和微任务的部分,希望能够做一份“傻瓜”式笔记,搞懂js的同步异步,事件轮询,宏任务微任务的知识点吧。

JS 是一门单线程的编程语言,这就意味着一个时间里只能处理一件事,也就是说JS引擎一次只能在一个线程里处理一条语句。

单线程简化了编程代码,这样不必太担心并发引出的问题,这也意味着在阻塞主线程的情况下执行长时间的操作,如网络请求。

想象一下从API请求一些数据,根据具体的情况,服务器需要一些时间来处理请求,同时阻塞主线程,使网页长时间处于无响应的状态。 这就是引入异步 JS 的原因。使用异步 (如 回调函数、promise、async/await),可以不用阻塞主线程的情况下长时间执行网络请求。

2.同步 JS 是如何工作的?

下面来看一个同步的例子:

 const second = () => {
      console.log('Hello there!');
    }
    
    const first = () => {
      console.log('Hi there!');
      second();
      console.log('The End');
    }
    
    first();

要理解js的执行,就必须明白两个事:

  • 执行上下文
  • 调用栈(执行堆栈或者叫执行栈)

2.1调用栈:

调用堆栈顾名思义是一个具有LIFO(后进先出 last in first out)结构的堆栈,用于存储在代码执行期间创建的所有执行上下文。 这个后进先出可以理解为PS里面的图层或者我们看书📚翻页,一层压一层,最上面的就是最顶层,也最先被看到。

JS 只有一个调用栈,因为它是一种单线程编程语言。调用堆栈具有 LIFO (last in first out)结构,这意味着项目只能从堆栈顶部添加或删除。

回到上面的代码,尝试理解代该码是如何在JS引擎中执行。

const second = () => {
  console.log('Hello there!');
}
const first = () => {
  console.log('Hi there!');
  second();
  console.log('The End');
}
first();

//结果
//Hi there!
//Hello there!
//The End

发生了什么呢?🤔 看下面的具体执行流程:

  • 当执行此代码时,将创建一个全局执行上下文(由main()表示)并将其推到调用堆栈的顶部。当遇到对first()的调用时,它会被推送到堆栈的顶部。
  • 接下来,console.log('Hi there!')被推送到堆栈的顶部,当它完成时,它会从堆栈中弹出。之后,我们调用second(),因此second()函数被推到堆栈的顶部。
  • console.log('Hello there!')被推送到堆栈顶部,并在完成时弹出堆栈。second() 函数结束,因此它从堆栈中弹出。
  • console.log(“the End”)被推到堆栈的顶部,并在完成时删除。之后,first()函数完成,因此从堆栈中删除它。
  • 程序在这一点上完成了它的执行,所以全局执行上下文(main())从堆栈中弹出。

3.异步是如何执行的呢?

3.1·阻塞是什么?

假设正在以同步的方式进行图像处理或网络请求。例如:

const processImage = (image) => {
  
  console.log('Image processed');
  
}
const networkRequest = (url) => {

  return someData;
  
}
const greeting = () => {
  
  console.log('Hello World');
  
}

processImage(logo.jpg);

networkRequest('www.somerandomurl.com');

greeting();

做图像处理和网络请求需要时间,当processImage()函数被调用时,它会根据图像的大小花费一些时间。

processImage() 函数完成后,将从堆栈中删除它。然后调用 networkRequest() 函数并将其推入堆栈。同样,它也需要一些时间来完成执行。

最后,当networkRequest()函数完成时,调用greeting()函数。

因此,咱们必须等待函数如processImage()networkRequest()完成。这意味着这些函数阻塞了调用堆栈或主线程。因此,在执行上述代码时,咱们不能执行任何其他操作,这是不理想的。

3.2 解决办法是什么?

最简单的解决方案是异步回调,使用异步回调使代码非阻塞。例如:

const networkRequest = () => {
  setTimeout(() => {
    console.log('Async Code');
  }, 2000);
};
console.log('Hello World');
networkRequest();

这里使用了setTimeout方法来模拟网络请求。请记住setTimeout不是JS引擎的一部分,它是WebApi的一部分。

请记住setTimeout不是JS引擎的一部分,它是Web Api的一部分。

请记住setTimeout不是JS引擎的一部分,它是Web Api的一部分。

请记住setTimeout不是JS引擎的一部分,它是Web Api的一部分。

重要的事情说三遍~!!!

为了理解上面代码的执行,还需要了解---🐳事件轮询和回调队列(消息队列)

🌵事件轮询、web api和消息队列不是JavaScript引擎的一部分,而是浏览器的JavaScript运行时环境或Nodejs JavaScript运行时环境的一部分(对于Nodejs)。在Nodejs中,web api被c/c++ api所替代。

下面回到代码部分,看看他们是如何执行的:

const networkRequest = () => {
  setTimeout(() => {
    console.log('Async Code');
  }, 2000);
};

console.log('Hello World');

networkRequest();

console.log('The End');


//结果
//Hello World
//The End
//Async Code
  • 当上述代码在浏览器中加载时,console.log(' Hello World ') 被推送到堆栈中,并在完成后弹出堆栈。接下来,将遇到对 networkRequest() 的调用,因此将它推到堆栈的顶部。
  • 下一个 setTimeout() 函数被调用,因此它被推到堆栈的顶部。setTimeout()有两个参数:
    • 回调函数
    • 以毫秒为单位的时间
  • setTimeout() 方法在web api环境中启动一个2s的计时器。此时,setTimeout()已经完成,并从堆栈中弹出。 console.log(“the end”) 被推送到堆栈中,在完成后执行并从堆栈中删除。
  • 同时,计时器已经过期,现在定时器里面的回调函数被推送到消息队列。但是它不会立即执行,这就是事件轮询开始的地方。

3.3 事件轮询:

事件轮询的工作是监听调用堆栈,并确定调用堆栈是否为空。

  • 调用堆栈不为空,就执行里面的内容

  • 如果调用堆栈是空的,它将检查消息队列,看看是否有任何挂起的回调等待执行。

在这种情况下,消息队列包含一个回调,此时调用堆栈为空。因此,事件轮询将回调推到堆栈的顶部

然后是 console.log(“Async Code”) 被推送到堆栈顶部,执行并从堆栈中弹出。此时,回调已经完成,因此从堆栈中删除它,程序最终完成。

🍊消息队列还包含来自DOM事件(如单击事件和键盘事件)的回调。 例如:

document.querySelector('.btn').addEventListener('click',(event) => {
  console.log('Button Clicked');
});

🌵对于DOM事件,事件侦听器位于web api环境中,等待某个事件(在本例中单击event)发生,当该事件发生时,回调函数被放置在等待执行的消息队列中。

🌵同样,事件轮询检查调用堆栈是否为空,并在调用堆栈为空并执行回调时将事件回调推送到堆栈

4.延时函数执行:

咱们还可以使用setTimeout来延迟函数的执行,直到堆栈清空为止。例如:

const bar = () => {
  console.log('bar');
}
const baz = () => {
  console.log('baz');
}
const foo = () => {
  console.log('foo');
  setTimeout(bar, 0);
  baz();
}
foo();

打印结果:

foo
baz
bar

🐳 执行过程:

  • 当这段代码运行时,第一个函数foo()被调用,在foo内部我们调用console.log('foo'),然后setTimeout()被调用,bar()作为回调函数和时0秒计时器。
  • 现在,如果咱们没有使用 setTimeout,bar() 函数将立即执行,但是使用 setTimeout0秒计时器,将bar的执行延迟到堆栈为空的时候。
  • 0秒后,bar()回调被放入等待执行的消息队列中,但是它只会在堆栈完全空的时候执行,也就是在bazfoo函数完成之后。

5.ES6 任务队列:

通过上面的讲解,已经了解了异步回调和DOM事件是如何执行的,它们使用消息队列存储等待执行所有回调

🍊ES6引入了任务队列的概念,任务队列是 JS 中的 promise 所使用的。

消息队列任务队列的区别在于:

  • 🍊优先级:任务队列 > 消息队列,这意味着:✏️任务队列中的promise 作业将在消息队列中的回调之前执行。
const bar = () => {
  console.log('bar');
};

const baz = () => {
  console.log('baz');
};

const foo = () => {
  
  console.log('foo');
  
  setTimeout(bar, 0);
  
  new Promise((resolve, reject) => {
    console.log("promise回调是同步啦")
    resolve('Promise resolved');
    
  }).then(res => 
          console.log(res,"执行了哈")).catch(err => console.log(err));
  baz();
};
console.log("这个是啥")

foo();

打印结果:

foo
promise回调是同步啦
baz
Promised resolved 执行了哈
bar

可以看到 promisesetTimeout 之前执行,因为 promise 响应存储在任务队列中,任务队列的优先级高于消息队列。

🌵promise 中是同步任务,promise 的 .then 中是异步任务