javascript
写给初学者的JavaScript异步编程和背后思想
導讀: 對于接觸JavaScript這門編程語言沒有多久的本菜雞而言,在相當長的一段時間內,我都完全無法理解這門語言中的異步編程,不明白什么叫異步編程以及為什么需要異步編程。為什么順序執(zhí)行程序就不行了呢?非要使用異步回調的方式來去做?經過一段時間的學習和探究,我算是初步了解了其中的道理和內涵。如果你像我一樣也是一個JavaScript的小白,希望你看完我寫的這篇文章之后,也可以解答你內心的很多困惑,并對JavaScript這門編程語言可以有一個更為深入的了解。
從單線程語言講起
很長一段時間,我對JavaScript語言的困惑來自于我不清楚什么是單線程編程語言,而這個特性對于JavaScript走向異步編程的方式至關重要。在我們開始去講單線程編程語言之前,有必須先去了解什么是進程,什么是線程。對這兩個概念如果用純文字講解略顯蒼白,這里推薦一個B站Up主對線程和進程講解的視頻。我就是看完這個視頻之后才明白進程和線程之間的關系的,相信你看完之后也可以理解。
有了之前的概念基礎,下面就來談談什么是單線程編程語言。單線程顧名思義就是一個進程里面只會有一個線程。這一個線程有的時候也會被稱之為主線程。如果一個進程里面只能有一個線程的話,那它就只能串行的執(zhí)行程序,就是只能執(zhí)行完A任務,然后再去執(zhí)行B任務。反之,如果一個進程里面有多個線程的話,那么它看起來好像是可以并行的執(zhí)行多個任務。對于單核CPU來說,系統(tǒng)會在多個線程之間快速不停地切換,就可以給你一種錯覺好像是多個線程在并行的執(zhí)行,但這只是一種錯覺而已。真正的并行應該是多核才能實現。關于串行、并行和并發(fā)這幾個概念,引用知乎某大佬一個形象的回答:
你吃飯吃到一半,電話來了,你一直到吃完了以后才去接,這就說明你不支持并發(fā)也不支持并行。
你吃飯吃到一半,電話來了,你停了下來接了電話,接完后繼續(xù)吃飯,這說明你支持并發(fā)。
你吃飯吃到一半,電話來了,你一邊打電話一邊吃飯,這說明你支持并行。
并發(fā)的關鍵是你有處理多個任務的能力,不一定要同時。并行的關鍵是你有同時處理多個任務的能力。所以我認為它們最關鍵的點就是:是否是『同時』。
多線程就類似于并發(fā)這樣的一個方式。只有多核才能做到真正的并行。
那么為什么JavaScript會采用這種單線程編程語言的方式呢?可以設想一下,如果JavaScript是一門支持多線程編程語言(比如Java),而多線程實際上是CPU在多個線程之間來回快速切換而已,當JavaScript運行在瀏覽器中,這時一個線程說要刪除一個圖片,另外一個線程說要添加一個圖片,如果有上百個線程都在爭搶CPU的運行時間的話,你的瀏覽器頁面就會有很大的不確定性了,我們并不希望自己的頁面加載、更新有很大不確定性。而如果一個進程只有一個線程的話,就不會有上述問題,任務一個接一個順序執(zhí)行就可以了。
單線程語言的問題
單線程的確可以不用考慮煩人的哪個任務先執(zhí)行,哪個任務后執(zhí)行的問題了,也不需要考慮多線程之間不同線程數據同步的問題。但是線程有他自身的問題。因為單線程是按順序執(zhí)行,如果一個任務執(zhí)行的很慢的話,后面的任務全部都會被阻塞,執(zhí)行不了。
想象你去超市買東西,如果前面有一個人結賬很慢,那你也只能干等著,別的什么事也做不了。對于任務也是同樣的道理,對于瀏覽器而言。我們通常需要各種各樣的網絡請求,去獲取圖片,視頻等網絡資源。如果一個網絡請求很慢,比如某一個圖片加載很慢,那后面的所有的數據都只能等著前面圖片加載完之后才能再去訪問網絡加載。但我們實際用瀏覽器訪問網頁體驗好像并不是這樣。比如有的時候可能某一個圖片加載很長時間也沒有加載出來,但這并沒有影響后面的圖片或者視頻等資源的加載。我們是如何做到這一點的呢?答案是使用異步編程(終于講到異步編程了^ – ^)
想不清楚是因為忘了考慮宿主環(huán)境!
我們首先引用一下lynnelv關于同步和異步的定義。
老實說,看完之后我其實是挺疑惑的!JavaScript是一個單線程語言,你比如說你有一個調用棧,就像在超市排隊結賬一樣,按順序執(zhí)行程序,這很好理解。但怎么又出現了一個任務隊列!單線程還能一邊搞調用棧還能一邊去做任務隊列嗎?!,舉一個例子:
比如上面代碼展示的setTimeout是一個異步執(zhí)行程序(書上就是這么說的),但是!單線程怎么可能會做到異步呢?假設你去排隊結賬,而且因為單線程,所以只能有一個結賬通道,那么前面人結賬再久,你也只能等著。你不能換一個通道去結賬。但這個任務隊列就是告訴你,我們還有一個結賬通道。比如這個setTimeout是異步函數,他的延時計時,不會影響你的主線程執(zhí)行。可是如果你只有一個線程,要么去執(zhí)行程序,要么去計時,怎么可能一邊計時,一邊還執(zhí)行程序呢?
后來我才明白,原來是這么回事!除了JavaScript本身之外,還有一個宿主環(huán)境。
JavaScript本身是不能自己單獨執(zhí)行的,要么在瀏覽器中,要么是在Node.js中,而上述兩個就是宿主環(huán)境。JavaScript本身是單線程編程語言這沒錯,但JavaScript的宿主環(huán)境給他提供了額外的并發(fā)功能。(也就是上面說的消息隊列)
上圖中,主線程運行的時候,產生堆(heap)和棧(stack),棧中的代碼調用各種外部API,它們在"任務隊列"中加入各種事件(click,load,done)。只要棧中的代碼執(zhí)行完畢,主線程就會去讀取"任務隊列",依次執(zhí)行那些事件所對應的回調函數。
上圖和對應的文字內容引用自JavaScript 運行機制詳解:再談Event Loop,圖中的WebAPIs就是JavaScript宿主環(huán)境所提供的線程。setTimeout會在該線程中執(zhí)行,并不會影響JavaScript自身的主線程。setTimeout的延時時間結束之后,它就會進入到圖中的回調消息隊列中,當主線程空閑時,消息隊列中的內容就會進入到主線程中去執(zhí)行。
有了異步編程這樣一個東西之后,JavaScript即使是單線程編程,也可以有并發(fā)的特性了。將網絡請求和I/O操作等耗時的任務都交給異步編程來實現,主線程把控整體流程,這樣主線程就不會被耗時任務拖累而發(fā)生阻塞。
Ajax
我們之前已經花了很大的篇幅來介紹異步編程和背后的思想。在這一部分我們談談對于瀏覽器而言,最重要的異步編程應用AJAX。
Ajax 即“Asynchronous Javascript And XML”(異步 JavaScript 和 XML),是指一種創(chuàng)建交互式、快速動態(tài)網頁應用的網頁開發(fā)技術,無需重新加載整個網頁的情況下,能夠更新部分網頁的技術。
通過在后臺與服務器進行少量數據交換,Ajax 可以使網頁實現異步更新。這意味著可以在不重新加載整個網頁的情況下,對網頁的某部分進行更新。
因為異步操作往往需要用到callback,如果不太清楚callback的話,可以看一下這篇文章,里面的關于callback的比喻很形象。因為異步操作不是同步任務,把異步請求或者操作發(fā)送出去之后就繼續(xù)執(zhí)行同步任務了,所以很適合用這種執(zhí)行完之后“打電話”回調的方式執(zhí)行任務。
知道callback的原理之后我們繼續(xù)回到AJAX。下面這段代碼是模擬AJAX請求的代碼:
function ajax(url, callback) {// 1、創(chuàng)建XMLHttpRequest對象var xmlhttpif (window.XMLHttpRequest) {xmlhttp = new XMLHttpRequest()} else { // 兼容早期瀏覽器xmlhttp = new ActiveXObject('Microsoft.XMLHTTP')}// 2、發(fā)送請求xmlhttp.open('GET', url, true)xmlhttp.send()// 3、服務端響應xmlhttp.onreadystatechange = function () {if (xmlhttp.readyState === 4 && xmlhttp.status === 200) {var obj = JSON.parse(xmlhttp.responseText)// console.log(obj)callback(obj)}} }當服務器有響應之后,把響應數據,通過回調函數返回回來。注意之前文章中說的回調函數特點,是程序執(zhí)行完把響應回調回來。在服務器響應這種異步的情況下,是很適合使用回調函數的。我們執(zhí)行上述ajax的代碼可以如下:
var url = 'https://getman.cn/mock/route/to/demo' ajax(url, res => {console.log(res) })這個URL路徑是一個在線測試網站,你可以點擊這里查看該網站詳情。如上所示,通常情況下我們定義回調函數的時候,是寫成箭頭函數的形式,在該函數里面具體定義要執(zhí)行什么樣的回調動作。為了便于理解,我把箭頭函數改成普通函數的形式,代碼如下:
ajax(url, function(res) {console.log(res) })最后再強調一下,我們是把一個函數作為參數傳遞進去了。這個函數就是回調函數。
理解了AJAX和回調函數之后,我們再來看下面一種情況。在實際編程中,我們經常會遇到執(zhí)行某些請求,然后有了反饋再出發(fā)a動作,a動作執(zhí)行完,有了反饋再觸發(fā)b動作…。舉一個例子,比如客戶端請求一段視頻內容,這時候服務器不會將整段視頻全部傳過去,而是切分成很多小段,一段一段傳給客戶端。客戶端得到一段視頻之后確認無誤,會再次發(fā)送請求,服務端就會再傳過來下一段內容。而這些操作都是要異步完成,如果我們寫成回調函數,大概可能會長這樣:
ajax(url, res => {dosomethingajax(url, res => {dosomethingajax(url, res => {dosomething......})}) })很可能會嵌套非常多的層數,從可讀性來講,非常的差,這種代碼結構稱之為回調地獄。對于這種回調地獄的寫法問題,我們可以通過promise來解決。
Promise
ES6中新增一個引用類型稱之為Promise,可以通過new操作符來實例化
我們來舉一個簡單例子:
let p = new Promise(() => {setTimeout(() => {console.log('wait one second')}, 1000) })但需要特別注意的,特別是對于初學者而言,我上面雖然寫的setTimeout是異步執(zhí)行,如果去執(zhí)行代碼的話,也的確會停留1s之后再去輸出。但這并不表示new Promise里面的所有代碼都會異步執(zhí)行。我們下面舉一個例子:
let p = new Promise(() => {console.log(1) }) console.log(2)如果你打印輸出的話,會發(fā)現,先輸出的1,后輸出的2。顯然,執(zhí)行console.log(2)是同步任務,如果Promise是異步的話,應該先打印2后打印1。因此,需要特別注意的是,Promise里面的代碼是同步任務,是立刻執(zhí)行的。可能這時候你有點糊涂了,我了解同步代碼同步執(zhí)行,異步代碼異步執(zhí)行(比如setTimeout)。那Promise有什么用呢?又怎么實現異步操作?別急,關于這一點,我們后面就會談到。
對于Promise而言,它最重要的特點在于其具有狀態(tài)管理的功能。它一共有三種狀態(tài):
- pending
- resolved
- rejected
Promise的一個特點就是它只能從pending狀態(tài)到resolved狀態(tài),或者從pending狀態(tài)到rejected狀態(tài)。不能反過來。或者它也可能一直處于pending狀態(tài),當我們new一個Promise的時候,它就是pending狀態(tài)。
那這個狀態(tài)有什么用?舉一個簡單例子:
let p = new Promise((resolve, reject) => {console.log('進入Promise')resolve('成功')// 因為promise狀態(tài)是不可逆的,所以reject實際是執(zhí)行不了的reject('失敗') }).then(res => {console.log(res) }, err => {console.log(err) })你可以執(zhí)行一下上述代碼,看看會輸出什么。
我們在new Promise的時候傳入兩個參數,分別是resolve和reject(需要注意的是這兩個參數都是函數)。調用resolve()會把狀態(tài)切換成resolved,調用reject()會把狀態(tài)切換成rejected(同時會拋出一個錯誤)。就像我們前面說的,狀態(tài)是不可逆的。所以上述代碼執(zhí)行完resolve()之后,reject()是不會被執(zhí)行的。
在上面的代碼中我們還看到.then方法,Promise.prototype.then()是為Promise實例添加處理程序的主要方法。執(zhí)行上述代碼我們也可以看到,resolve()或者reject()所傳遞的參數會進入到then中進行對應的處理。
通過上面的例子,我們就可以大致勾勒出Promise的應用場景。比如客戶端通過Promise發(fā)送一個異步請求給服務器。服務器如果響應成功,就在resolve所對應的then中去執(zhí)行對應代碼,如果響應失敗,就進入到reject,在reject對應的then中去執(zhí)行對應代碼。
另外,關于請求失敗的代碼,除了用then的第二個參數捕獲之外,還可以寫成catch的形式:
let p = new Promise((resolve, reject) => {console.log('進入Promise')resolve('成功')reject('失敗') }).then(res => {console.log(res) }).catch(err => {console.log(err) })以上,關于Promise最為基礎的部分介紹的已經足夠多了,下面我們嘗試用Promise來調用一下AJAX請求。
let p = new Promise((resolve, reject) => {console.log('客戶端向服務端發(fā)送請求')ajax(url, res => {console.log('第一次獲得的響應結果是', res)resolve('成功')}) }).then(resolveRes => {console.log('請求結果:', resolveRes)return new Promise((resolve, reject) => {ajax(url, res => {console.log('第二次獲得的響應結果是', res)resolve('成功')}) }) }).then(resolveRes => {console.log('請求結果:', resolveRes)return new Promise((resolve, reject) => {ajax(url, res => {console.log('第三次獲得的響應結果是', res)resolve('成功')}) }) })上述代碼模擬的是比如客戶端多次向服務器發(fā)送視頻請求的功能代碼。看起來一定程度上減輕了回調地獄這種糟糕寫法,將嵌套結構變成了鏈式結構。但看起來好像非常糟糕。一方面是大量重復代碼,非常冗余,另一方面,從理解層面也沒有改善多少。關于第二點,后面我們會介紹一種新的語法結構:async,await。不過現在我們還是繼續(xù)回到Promise中。
上面的代碼,需要特別強調的一點是,每次我們new一個Promise,都會加一個return,為什么要寫return呢?
比如在第二次請求的那個對應的Promise中不寫return的話,意味著就不會有返回值。后面的then執(zhí)行,相當于對戰(zhàn)原有的(就是最開始的那個)Promise對象的then繼續(xù)再執(zhí)行then(返回的會是一個空的promise)。只有return 之后,才意味著對新new出來的Promise再執(zhí)行then。因此在書寫代碼的時候一定要記得加return,否則如果發(fā)生多層Promise嵌套的時候很有可能出現邏輯錯誤。
下面我們把上面代碼中重復部分進行抽離,精簡一下代碼可以寫成這樣:
function getPromise(url) {return new Promise((resolve, reject) => {ajax(url, res => {console.log('獲得的響應結果是', res)})resolve('成功')}) }getPromise(url).then(resolveRes => {console.log('請求結果:', resolveRes)return getPromise(url) }).then(resolveRes => {console.log('請求結果:', resolveRes)return getPromise(url) }).then(resolveRes => {console.log('請求結果:', resolveRes)return getPromise(url) })經過抽離之后,代碼看起來簡潔很多。同樣的,我們還是要再強調一下這個return問題。明明定義getPromise函數的時候已經寫了return了,為什么后面再調用的時候還要再加return?因為函數定義部分可以看到,調用函數之后返回的相當于是一個new Promise,你可以對照之前寫的比較復雜的代碼,這個new Promise還需要再返回一下才行。
需要注意的一點是,在上面的例子中,then是平級的,也就是說,即使前一層請求失敗,也不會影響后一層的then執(zhí)行。如果你想對不同的請求失敗做統(tǒng)一的處理,可以這樣寫代碼:
getPromise(url).then(resolveRes => {console.log('請求結果:', resolveRes)return getPromise(url) }).then(resolveRes => {console.log('請求結果:', resolveRes)return getPromise(url) }).then(resolveRes => {console.log('請求結果:', resolveRes)return getPromise(url) }).catch(err => {console.log(err) })這樣的話,只要有一個請求失敗,就會直接觸發(fā)catch,別的then都不會被執(zhí)行了。
此外,Promise還有一些靜態(tài)方法,我這里就不演示了。具體可以看MDN的Promise文檔。
async / await
除了使用Promise之外,我們還可以使用async/await來實現異步編程。
async關鍵字用于聲明異步函數。這個關鍵字可以用在函數聲明、函數表達式、箭頭函數和方法上。此外需要注意的是,如同我們在介紹Promise中強調的一樣,雖然async關鍵字可以讓函數具有異步特征,但里面的同步代碼還是會同步執(zhí)行。只有遇到異步執(zhí)行的函數時才會異步執(zhí)行。
async的函數在執(zhí)行后都會自動返回一個Promise對象,所以,我們也可以在async函數后面接then來做處理。不過我們還有一種更好的方式是使用await關鍵字。await可以獲取后面Promise對象成功狀態(tài)傳遞出來的參數。
我們下面舉一個具體例子,還是調用之前在講ajax部分定義的函數。
function getPromise(url) {return new Promise((resolve, reject) => {ajax(url, res => {console.log('獲得的響應結果是', res)})resolve('成功')}) }// 改造后 function getPromise(url) {return new Promise((resolve, reject) => {ajax(url, res => {resolve(res)})}) }我們把之前定義的getPromise函數略微改造一下。然后還是對之前的客戶端向服務端多次請求數據的功能使用async/await用代碼改造一下:
async function getData(){const res1 = await getPromise(url)console.log(res1)const res2 = await getPromise(url)console.log(res2)const res3 = await getPromise(url)console.log(res3) } getData()通過await等待,我們會在每次得到數據之后再發(fā)送下一次請求。這樣異步編程看起來就像同步代碼了,使代碼變得更容易理解。
因為async返回的是Promise,所以,對于失敗的請求,我們還是可以通過then的第二個參數或者catch來捕獲:
getData().catch(err => {console.log(err) })參考資料
[1] JavaScript異步編程
[2] 說一說javascript的異步編程
[3] 線程進程 |兩個簡單例子告訴你什么是進程和線程 | 進程線程原來如此簡單
[4] 并發(fā)與并行的區(qū)別是什么?
[5] 深入理解js事件循環(huán)機制(瀏覽器篇)
[6] JavaScript 運行機制詳解:再談Event Loop
[7] 《JavaScript高級程序設計(第4版)》
[8] 《深入淺出Nodejs》
[9] ajax
[10] Callback(回調)是什么?
總結
以上是生活随笔為你收集整理的写给初学者的JavaScript异步编程和背后思想的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 详解JavaScript中ES5和ES6
- 下一篇: 如何理解JavaScript多个连续箭头