前言
本文2925字,閱讀大約需要10分鐘。
總括: 本文梳理了異步代碼和同步代碼執行的區別,Javascript的事件循環,任務隊列微任務隊列等概念。
- 原文地址:Understanding Asynchronous JavaScript
- 公衆號:「前端進階學習」,回覆「666」,獲取一攬子前端技術書籍
未曾失敗的人恐怕也未曾成功過。
Javascript是單線程的編程語言,單線程就是說同一時間只能幹一件事。放到編程語言上來說,就是說Javascript引擎(執行Javascript代碼的虛擬機)同一時間只能執行一條語句。
單線程語言的好處是你只管寫不用擔心併發問題。但這也意味着無法在不阻塞主線程的情況下去執行一些諸如網絡請求的長時間操作。
設想下如果我們從某個接口請求一些數據,然後服務器需要一些時間才能將數據返回,此時就會阻塞主線程頁面處於無響應的狀態。
這裏就是Javascript異步的用武之地了,我們可以通過異步操作(比如回調函數,promise和async/await)來執行長時間的網絡請求而不阻塞主線程。
雖然說了解這些所有的概念不一定讓你立刻成爲一名出色的Javascript開發者,但瞭解異步會對你很有幫助。
話不多說,正文開始:)
同步的代碼是怎麼執行的
在深入研究Javascript的異步之前,我們先來看下同步的代碼是如何在Javascript引擎中執行的。看例子:
const second = () => {
console.log('Hello there!');
}
const first = () => {
console.log('Hi there!');
second();
console.log('The End');
}
first();
要想理解上面的代碼是如何在Javascript引擎中被執行的,我們必須要去理解Javascript的執行上下文和執行棧。
執行上下文
所謂的執行上下文是Javascript代碼執行環境中的一個抽象的概念。Javascript任何代碼都是在執行上下文中執行的。
函數內部的代碼會在函數執行上下文中執行,全局的代碼會在全局執行上下文中執行,每一個函數都有自己的執行上下文。
執行棧
顧名思義執行棧是一種後進先出(LIFO)的棧結構,它用來存儲在代碼執行階段創建的所有的執行上下文。
基於單線程的原因,Javascript只有一個執行棧,因爲是基於棧結構所以只能從棧的頂層添加或是刪除執行上下文。
讓我們回到上面的代碼,嘗試理解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()
函數,該函數的執行上下文被壓到執行棧的頂端。
然後執行console.log('Hello there!');
,對應的函數執行上下文被壓入執行棧,執行結束被彈出,然後second()
函數執行結束,執行上下文被彈出。
console.log(‘The End’)
執行,函數執行上下文被壓入執行棧,執行結束被彈出,此時first()
函數執行結束,對應執行上下文被彈出。
整個程序執行結束,全局執行上下文(main())被彈出。
異步代碼是怎麼執行的
現在我們已經對同步代碼的執行有了一個基本的認知,下面讓我們看下異步代碼是如何執行的:
阻塞
假設我們用同步的方式去發起一個圖片請求或是一個普通的網絡請求,例子如下:
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()
,然後裏面只有一行console.log('Hello World')
,``console.log()函數通常執行會很快,因此
greeting()`會很快執行完然後返回結果。
可以發現,我們必須等函數(比如processImage,networkRequest函數)執行結束才能調用下一個函數。這意味着這些函數調用的時候會阻塞主線程,造成主線程不能執行其他代碼,這是我們所不希望的。
所以怎麼解決這個問題呢?
最簡單的解決辦法就是使用異步的回調函數,有了異步的回調函數就不會阻塞主線程,看例子:
const networkRequest = () => {
setTimeout(() => {
console.log('Async Code');
}, 2000);
};
console.log('Hello World');
networkRequest();
這裏我們使用了setTimeout
方法去模擬網絡請求函數。
請注意:setTimeout
不是Javascript引擎提供的,而是web API(瀏覽器中)和C/C++ API(nodejs中)的一部分。
事件循環、Web API和消息隊列/任務隊列並不是Javascript引擎的一部分而是瀏覽器的Javascript運行環境或是Nodejs的Javascript運行環境的一部分,在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爲單位);3. 附加參數(會被傳到回調函數裏面)
setTimeout()
函數會在web API運行環境中進行一個2s
的倒計時,這個時候 setTimeout()
函數就已經執行完了,執行上下文從執行棧中彈出。再然後console.log('The End')
函數被執行,進入執行棧,結束後彈出執行棧。
這時候倒計時到期,setTimeout()
的回調函數被推到消息隊列中,但回調函數不會立即執行,這是事件循環開始的地方。
事件循環
事件循環的工作就是去查看執行棧,確定執行棧是否爲空,如果執行棧爲空,那麼就去檢查消息隊列,看看消息隊列中是否有待執行的回調函數。它按照類似如下的方式來被實現:
while (queue.waitForMessage()) {
queue.processNextMessage();
}
在這裏,執行棧已經爲空,消息隊列包含一個setTimeout
函數的回調函數,因此事件循環把回調函數的執行上下文壓入執行棧的頂端。
然後console.log(‘Async Code’)
函數的執行上下文被壓入執行棧,結束後從執行棧彈出。這時候回調函數執行結束,對應的執行上下文也從執行棧中彈出。
DOM事件
**消息隊列(也叫任務隊列)**中也會包含來自DOM事件(比如點擊事件,鍵盤事件等),看例子:
document.querySelector('.btn').addEventListener('click',(event) => {
console.log('Button Clicked');
});
對於DOM事件來說,web API中會有一個事件偵聽器堅挺某個事件被觸發(在這裏是click事件),當某個事件被觸發時,就會把相應的回調函數放入消息隊列中執行。
事件循環再次檢查執行棧,如果執行棧爲空,就把事件的回調函數推入執行棧。
我們已經瞭解了異步回調和事件回調是如何執行的,這些回調函數被存儲在消息隊列中等待被執行。
ES6任務隊列和微任務隊列
ES6中爲promise
函數引入了微任務隊列(也叫作業隊列)的概念。微任務隊列和消息隊列的區別就是優先級上的區別,微任務隊列的優先級要高於消息隊列。也就是說在微任務隊列的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
的response被存儲在微任務隊列中,有比消息隊列更高的優先級。
再看另一個例子,有兩個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
被resolved了,然後這個promise
的回調函數會被添加到微任務隊列中。並且它會被優先執行,無論消息隊列中的回調函數的執行會花費多長時間,都要排隊。
比如:
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中同步和異步代碼是怎麼執行,以及一些其它的概念(包括執行棧,事件循環,微任務隊列,消息隊列等)。
以上。
能力有限,水平一般,歡迎勘誤,不勝感激。
訂閱更多文章可關注公衆號「前端進階學習」,回覆「666」,獲取一攬子前端技術書籍