搞懂 JavsScript 异步 — 事件轮询
JavsScript
是一门单线程的编程语言,这就意味着一个时间里只能处理一件事,也就是说 JavaScript
引擎一次只能在一个线程里处理一条语句。
虽然单线程简化了编程代码,因为你不必太担心并发引出的问题,这也意味着你将在阻塞主线程的情况下执行长时间的操作,如网络请求。
想象一下从API请求一些数据,根据具体的情况,服务器需要一些时间来处理请求,同时阻塞主线程,使网页长时间处于无响应的状态。
这就是引入异步 JavaScript
的原因。使用异步 JavaScript
(如 回调函数、promise
、async/await
),可以不用阻塞主线程的情况下长时间执行网络请求 :)
可能你知道不知道 异步 JavsScript
是如何工作,并不要紧,但知道它是如何工作,对 JavaScript
异步更深入的了解是有帮助的。
所以不在啰嗦了,我们开始吧 :)
同步JavaScript是如何工作的?
在深入研究异步JavaScript
之前,让我们首先了解同步 JavaScript
代码如何在 JavaScript
引擎中执行。例如:
const second = () => { console.log('Hello there!'); } const first = () => { console.log('Hi there!'); second(); console.log('The End'); } first();
要理解上述代码如何在 JavaScript
引擎中执行,我们必须理解执行上下文和调用堆栈(也称为执行堆栈)的概念。
函数代码在函数执行上下文中执行,全局代码在全局执行上下文中执行。每个函数都有自己的执行上下文。
调用栈
调用堆栈顾名思义是一个具有LIFO
(后进先出)结构的堆栈,用于存储在代码执行期间创建的所有执行上下文。
JavaScript
只有一个调用栈,因为它是一种单线程编程语言。调用堆栈具有 LIFO
结构,这意味着项目只能从堆栈顶部添加或删除。
让我们回到上面的代码片段,并尝试理解代码如何在JavaScript
引擎中执行。
const second = () => { console.log('Hello there!'); } const first = () => { console.log('Hi there!'); second(); console.log('The End'); } first();
这里发生了什么?
当执行此代码时,将创建一个全局执行上下文(由main()表示)并将其推到调用堆栈的顶部。当遇到对first()
的调用时,它会被推送到堆栈的顶部。
接下来,console.log('Hi there!')
被推送到堆栈的顶部,当它完成时,它会从堆栈中弹出。之后,我们调用second()
,因此second()
函数被推到堆栈的顶部。
console.log('Hello there!')
被推送到堆栈顶部,并在完成时弹出堆栈。second()
函数结束,因此它从堆栈中弹出。
console.log(“the End”)
被推到堆栈的顶部,并在完成时删除。之后,first()
函数完成,因此从堆栈中删除它。
程序在这一点上完成了它的执行,所以全局执行上下文(main())从堆栈中弹出。
异步JavaScript是如何工作的?
现在我们已经对调用堆栈和同步JavaScript
的工作原理有了基本的了解,让我们回到异步JavaScript
。
阻塞是什么?
让我们假设我们正在以同步的方式进行图像处理或网络请求。例如:
const processImage = (image) => { /** * doing some operations on image **/ console.log('Image processed'); } const networkRequest = (url) => { /** * requesting network resource **/ return someData; } const greeting = () => { console.log('Hello World'); } processImage(logo.jpg); networkRequest('www.somerandomurl.com'); greeting();
做图像处理和网络请求需要时间,当processImage()
函数被调用时,它会根据图像的大小花费一些时间。
processImage()
函数完成后,将从堆栈中删除它。然后调用 networkRequest()
函数并将其推入堆栈。同样,它也需要一些时间来完成执行。
最后,当networkRequest()
函数完成时,调用greeting()
函数,因为它只包含一个控制台。日志语句和控制台。日志语句通常很快,因此greeting()
函数立即执行并返回。
因此,我们必须等待函数(如processImage()
或networkRequest()
)完成。这意味着这些函数阻塞了调用堆栈或主线程。因此,在执行上述代码时,我们不能执行任何其他操作,这是不理想的。
那么解决办法是什么呢?
最简单的解决方案是异步回调。我们使用异步回调使代码非阻塞。例如:
const networkRequest = () => { setTimeout(() => { console.log('Async Code'); }, 2000); }; console.log('Hello World'); networkRequest();
这里我使用了setTimeout
方法来模拟网络请求。请记住setTimeout
不是JavaScript
引擎的一部分,它是web api(在浏览器中)和C/ c++ api(在node.js中)的一部分。
为了理解这段代码是如何执行的,我们必须理解更多的概念,比如事件轮询和回调队列(或消息队列)。
事件轮询、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');
当上述代码在浏览器中加载时,console.log(' Hello World ')
被推送到堆栈中,并在完成后弹出堆栈。接下来,将遇到对 networkRequest()
的调用,因此将它推到堆栈的顶部。
下一个 setTimeout()
函数被调用,因此它被推到堆栈的顶部。setTimeout()
有两个参数:
- 1) 回调和
- 2) 以毫秒(ms)为单位的时间。
setTimeout()
方法在web api环境中启动一个2s的计时器。此时,setTimeout()
已经完成,并从堆栈中弹出。cosole.log(“the end”)
被推送到堆栈中,在完成后执行并从堆栈中删除。
同时,计时器已经过期,现在回调被推送到消息队列。但是回调不会立即执行,这就是事件轮询开始的地方。
事件轮询
事件轮询的工作是监听调用堆栈,并确定调用堆栈是否为空。如果调用堆栈是空的,它将检查消息队列,看看是否有任何挂起的回调等待执行。
在这种情况下,消息队列包含一个回调,此时调用堆栈为空。因此,事件轮询将回调推到堆栈的顶部。
然后是 console.log(“Async Code”)
被推送到堆栈顶部,执行并从堆栈中弹出。此时,回调已经完成,因此从堆栈中删除它,程序最终完成。
消息队列还包含来自DOM事件(如单击事件和键盘事件)的回调。例如:
document.querySelector('.btn').addEventListener('click',(event) => { console.log('Button Clicked'); });
对于DOM事件,事件侦听器位于web api环境中,等待某个事件(在本例中单击event)发生,当该事件发生时,回调函数被放置在等待执行的消息队列中。
同样,事件轮询检查调用堆栈是否为空,并在调用堆栈为空并执行回调时将事件回调推送到堆栈。
延迟函数执行
我们还可以使用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()
函数将立即执行,但是使用 setTimeout
和0秒计时器,将bar的执行延迟到堆栈为空的时候。
0秒后,bar()
回调被放入等待执行的消息队列中。但是它只会在堆栈完全空的时候执行,也就是在baz和foo函数完成之后。
ES6 任务队列
我们已经了解了异步回调和DOM事件是如何执行的,它们使用消息队列存储等待执行所有回调。
ES6引入了任务队列的概念,任务队列是 JavaScript
中的 promise
所使用的。消息队列和任务队列的区别在于,任务队列的优先级高于消息队列,这意味着任务队列中的promise
作业将在消息队列中的回调之前执行,例如:
const bar = () => { console.log('bar'); }; const baz = () => { console.log('baz'); }; const foo = () => { console.log('foo'); setTimeout(bar, 0); new Promise((resolve, reject) => { resolve('Promise resolved'); }).then(res => console.log(res)) .catch(err => console.log(err)); baz(); }; foo();
打印结果:
foo baz Promised resolved bar
我们可以看到 promise
在 setTimeout
之前执行,因为 promise
响应存储在任务队列中,任务队列的优先级高于消息队列。
小结
因此,我们了解了异步 JavaScript
是如何工作的,以及调用堆栈、事件循环、消息队列和任务队列等概念,这些概念共同构成了 JavaScript
运行时环境。虽然成为一名出色的JavaScript
开发人员并不需要学习所有这些概念,但是了解这些概念是有帮助的:)
参考:
Understanding Asynchronous JavaScript — the Event Loop
你的点赞是我持续分享好东西的动力,欢迎点赞!
欢迎加入前端大家庭,里面会经常分享一些技术资源。