- 原文地址:Understanding Asynchronous JavaScript
- 原文做者:Sukhjinder Arora
- 譯文出自:掘金翻譯計劃
- 本文永久連接:github.com/xitu/gold-m…
- 譯者:H246802
- 校對者:ElizurHz, Yangfan2016
封面照片來自 Unsplash 的做者 Sean Limjavascript
JavaScript 是一種單線程編程語言,這意味着同一時間只能完成一件事情。也就是說,JavaScript 引擎只能在單一線程中處理一次語句。前端
單線程語言簡化了代碼編寫,由於你沒必要擔憂併發問題,但這也意味着你沒法在不阻塞主線程的狀況下執行網絡請求等長時間操做。java
想象一下從 API 中請求一些數據。根據狀況,服務器可能須要一些時間來處理請求,同時阻塞主線程,讓網頁沒法響應。node
這也就是異步 JavaScript 的美妙之處了。使用異步 JavaScript(例如回調,Promise 或者 async/await),你能夠執行長時間網絡請求同時不會阻塞主線程。android
雖然您沒有必要將全部這些概念都學會成爲一名出色的 JavaScript 開發人員,但瞭解這些對你會頗有幫助 :)ios
因此不用多說了,讓咱們開始吧!git
在深刻研究異步 JavaScript 以前,讓咱們首先了解同步 JavaScript 代碼在 JavaScript 引擎中的執行狀況。例如:github
const second = () => {
console.log('Hello there!');
}
const first = () => {
console.log('Hi there!');
second();
console.log('The End');
}
first();
複製代碼
要理解上述代碼在 JavaScript 引擎中的執行方式,咱們必須理解執行上下文和調用棧(也稱爲執行棧)的概念。編程
執行上下文是評估和執行 JavaScript 代碼的環境的抽象概念。每當在 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。
假設咱們正在以同步方式進行圖像處理或網絡請求。例如:
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()
函數,由於它只包含 console.log
語句,而 console.log
語句一般很快,因此 greeting()
函數會當即執行並返回。
因此你能夠看到,咱們必須等到函數(例如 processImage()
或 networkRequest()
)完成。這也就意味着這些函數阻塞了調用棧或主線程。所以,在執行上述代碼時,咱們沒法執行任何其餘操做,這是不理想的。
最簡單的解決辦法是異步回調,咱們一般使用異步回調來讓代碼無阻塞。例如:
const networkRequest = () => {
setTimeout(() => {
console.log('Async Code');
}, 2000);
};
console.log('Hello World');
networkRequest();
複製代碼
這裏我使用了 setTimeout
方法來模擬網絡請求。請記住,setTimeout
不是 JavaScript 引擎的一部分,它是 Web APIs(在瀏覽器中)和 C/C++ APIs(在 node.js 中)的一部分。
要了解如何執行此代碼,咱們必須瞭解一些其餘概念,例如事件循環和回調隊列(也稱爲任務隊列或消息隊列)。
JavaScript 運行時環境概述
事件循環,Web APIs 和 消息隊列/任務隊列 不是 JavaScript 引擎的一部分,它是瀏覽器的 JavaScript 運行所處環境或 Nodejs JavaScript 運行所處環境中的一部分(在 Nodejs 的環境下)。在 Nodejs 中,Web APIs 被 C/C++ APIs 取代。
如今讓咱們回過頭看看上面的代碼,看看它是如何以異步方式執行的。
const networkRequest = () => {
setTimeout(() => {
console.log('Async Code');
}, 2000);
};
console.log('Hello World');
networkRequest();
console.log('The End');
複製代碼
Event Loop(事件循環)
當上面的代碼在瀏覽器中運行時,console.log('Hello World')
被推送到棧,在執行完成後從棧中彈出。緊接着,遇到 networkRequest()
的執行,所以將其推送到棧頂部。
接下來調用 setTimeout()
函數,所以將其推送到棧頂部。setTimeout()
有兩個參數:1) 回調和 2) 以毫秒(ms)爲單位的時間。
setTimeout()
方法在 Web APIs 環境中啓動 2s
的計時器。此時,setTimeout()
已完成,並從調用棧中彈出。在它以後,console.log('The End')
被推送到棧,在執行完成後從調用棧中刪除。
同時,計時器已到期,如今回調函數被推送到消息隊列。但回調函數並無當即執行,而這就是造成了一個事件循環(Event Loop)。
事件循環的做用是查看調用棧並肯定調用棧是否爲空。若是調用棧爲空,它會查看消息隊列以查看是否有任何掛起的回調等待執行。
在這個例子中,消息隊列包含一個回調,此時調用棧爲空。所以,事件循環(Event Loop)將回調推送到調用棧頂部。
再以後,console.log('Async Code')
被推到棧頂部,執行並從調用棧中彈出。此時,回調函數已完成,所以將其從調用棧中刪除,程序最終完成。
消息隊列還包含來自 DOM 事件的回調,例如點擊事件和鍵盤事件。
例如:
document.querySelector('.btn').addEventListener('click',(event) => {
console.log('Button Clicked');
});
複製代碼
在DOM事件的狀況下,事件監聽器位於 Web APIs 環境中等待某個事件(在這種狀況下是點擊事件)發生,而且當該事件發生時,則回調函數被放置在等待執行的消息隊列中。
事件循環再次檢查調用棧是否爲空,若是它爲空而且執行了回調,則將事件回調推送到調用棧。
咱們已經知道了如何執行異步回調和 DOM 事件,它們使用消息隊列來存儲等待執行的全部回調。
ES6 引入了 Promises 在 JavaScript 中使用的工做隊列/微任務隊列的概念。消息隊列和微任務隊列之間的區別在於微任務隊列的優先級高於消息隊列,這意味着 工做隊列/微任務隊列中的 promise 工做將在消息隊列內的回調以前執行。
例如:
console.log('Script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
new Promise((resolve, reject) => {
resolve('Promise resolved');
}).then(res => console.log(res))
.catch(err => console.log(err));
console.log('Script End');
複製代碼
輸出:
Script start
Script End
Promise resolved
setTimeout
複製代碼
咱們能夠看到 promise 在 setTimeout
以前執行,由於 promise 響應存儲在微任務隊列中,其優先級高於消息隊列。
讓咱們再看一個例子,此次有兩個 promise 和兩個 setTimeout。例如:
console.log('Script start');
setTimeout(() => {
console.log('setTimeout 1');
}, 0);
setTimeout(() => {
console.log('setTimeout 2');
}, 0);
new Promise((resolve, reject) => {
resolve('Promise 1 resolved');
}).then(res => console.log(res))
.catch(err => console.log(err));
new Promise((resolve, reject) => {
resolve('Promise 2 resolved');
}).then(res => console.log(res))
.catch(err => console.log(err));
console.log('Script End');
複製代碼
輸出:
Script start
Script End
Promise 1 resolved
Promise 2 resolved
setTimeout 1
setTimeout 2
複製代碼
咱們能夠看到兩個 promise 都在 setTimeout
中的回調以前執行,由於事件循環將微任務隊列中的任務優先於消息隊列中的任務。
當事件循環正在執行微任務隊列中的任務時,若是另外一個 promise 執行 resolve 方法,那麼它將被添加到同一個微任務隊列的末尾,而且它將在消息隊列的全部回調以前執行,不管消息隊列回調等待執行花費了多少時間。
例如:
console.log('Script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
new Promise((resolve, reject) => {
resolve('Promise 1 resolved');
}).then(res => console.log(res));
new Promise((resolve, reject) => {
resolve('Promise 2 resolved');
}).then(res => {
console.log(res);
return new Promise((resolve, reject) => {
resolve('Promise 3 resolved');
})
}).then(res => console.log(res));
console.log('Script End');
複製代碼
輸出:
Script start
Script End
Promise 1 resolved
Promise 2 resolved
Promise 3 resolved
setTimeout
複製代碼
所以,微任務隊列中的全部任務都將在消息隊列中的任務以前執行。也就是說,事件循環將首先在執行消息隊列中的任何回調以前清空微任務隊列。
所以,咱們已經瞭解了異步 JavaScript 如何工做以及其餘概念,例如調用棧,事件循環,消息隊列/任務隊列和工做隊列/微任務隊列,它們共同構成了 JavaScript 運行時環境。雖然您沒有必要將全部這些概念都學習成爲一名出色的 JavaScript 開發人員,但瞭解這些概念會頗有幫助 :)
譯者注:
文中工做隊列(Job Queue)也就是微任務隊列,而消息隊列則是指咱們一般聊得宏任務隊列。
若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。