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
一個笨笨的碼農,我的世界只能終身學習!