系列文章:
- 先擼清楚:并發/并行、單線程/多線程、同步/異步
- 論Promise在前端江湖的地位及作用
前言
上篇文章闡述了并發/并行、單線程/多線程、同步/異步等概念,這篇將會分析Promise的江湖地位。
通過本篇文章,你將了解到:
- 為什么需要回調?
- 什么是回調地獄?
- Promise解決了什么問題?
- Promise常用的API
- async和await 如影隨形
- Promise的江湖地位
1. 為什么需要回調?
1.1 同步回調
先看個簡單的Demo:
function add(a: number, b: number) {return a + b
}function reprocess(a: number) {return a * a
}function calculate() {//加法運算let sum = add(4, 5)//進行再處理let result = reprocess(sum)//輸出最終結果console.log("result:", result)
}
先進行加法運算,再對運算的結果進行處理,最終輸出結果。
在reprocess()函數里我們對結果進行了平方,現在想要對它進行除法操作,那么依葫蘆畫瓢,需要再定義一個函數:
function reprocess2(a: number) {return a / 2
}
再后來,還需要繼續增加其它功能如減法、乘法、取模等運算,那不是要新增不少函數嗎?
假設該模塊的主要功能是進行加法,至于對加法結果的再加工它并不關心,外界調用者想怎么玩就怎么玩。于是,回調出現了。
我們重新設計一下代碼:
//新增函數作為入參
function add(a: number, b: number, callbackFun: (sum: number) => number) {let sum = a + breturn callbackFun(sum)
}function calculate() {//加法運算let result = add(4, 5, (sum) => {return sum / sum})//輸出最終結果console.log("result:", result)let result2 = add(6, 8, (sum) => {return sum * sum - sum / 2})//輸出最終結果console.log("result2:", result2)
}
add()函數最后一個入參是函數類型的參數,調用者需要實現這個函數,我們稱這個函數為回調函數。于是在calculate()函數里,我們可以針對不同的需求調用add()函數,并通過回調函數實現不同的數據加工邏輯。
calculate()函數和回調函數是在同一線程里執行,并且按照代碼書寫的先后順序執行,此時的回調函數是同步回調。
1.2 異步回調
假若add()函數里對數據的加工需要一定的時間,我們用setTimeout模擬一下耗時操作:
//新增函數作為入參
function add(a: number, b: number, callbackFun: (sum: number) => void) {setTimeout(() => {let sum = a + bcallbackFun(sum)})
}function calculate() {//加法運算add(4, 5, (sum) => {let result = sum / sum//輸出最終結果console.log("result:", result)//第1個打印})console.log("calculate end...")//第2個打印
}
從打印結果看,第2個打印反而比第一個打印先出現,說明第二個打印語句先執行。
calculate()函數執行add()函數的時候,并沒有一直等待回調的結果,而是立馬執行了第二個打印語句,而當add()函數內部實現執行時,才會執行回調函數,雖然calculate()和回調函數在同一線程執行,但是它們并沒有按照代碼書寫的先后順序執行,此時的回調函數是異步回調。
1.3 為什么需要它?
回調函數的出現使得代碼設計更靈活。
你可能會說:異步回調我還可以理解,畢竟或多或少都會涉及到異步調用,但同步回調不是脫褲子放屁嗎?
其實不然,同步回調更多的表現在靈活度上,比如我們遍歷一個數組:
const score = [60, 70, 80, 90, 100]
score.forEach((value, index, array) => {console.log("value:", value, " index:", index)
})
forEach()函數接收的是一個同步回調函數,該函數里可以獲取到數組里每一個值,并可以對它進行自定義的邏輯操作。
除了forEach()函數,同步回調還大量地被運用于其它場景。
2. 什么是回調地獄?
先看一段代碼:
interface NetCallback {//錯誤返回error: (errMsg: string) => void//成功返回succeed: (data: object) => void
}function fetchNetData(url: string, netCallback: NetCallback) {//模擬網絡耗時setTimeout(() => {if (Math.random() > 0.2) {//成功netCallback.succeed({code: 200, msg: 'success'})} else {//失敗netCallback.error(`${url} fetch error`)}}, 1000)
}function fetchStuInfo() {fetchNetData('/info/stu', {error: (errMsg) => {console.log(errMsg)},succeed: (data) => {console.log(data)}})
}fetchStuInfo()
上述代碼是很常規的異步回調過程,看起來很正經沒啥問題。
想象一種場景:通過stuId獲取stuInfo,stuInfo里存有teacherId,通過teacherId獲取teacherInfo,teacherInfo里有schoolId,通過schoolId獲取schoolInfo。
很顯然這三個接口是逐層(串行)依賴的,我們可以寫出如下代碼:
function fetchSchoolInfo() {//先獲取學生信息,成功后帶有teacherIdfetchNetData('/info/stu', {error: (errMsg) => {console.log(errMsg)},succeed: (data) => {//通過teacherId,再獲取教師信息,成功后帶有schoolIdfetchNetData('/info/teacher', {error: (errMsg) => {console.log(errMsg)},succeed: (data) => {//通過schoolId,再獲取學校信息fetchNetData('/info/school', {error: (errMsg) => {console.log(errMsg)},succeed: (data) => {console.log(data)}})}})}})
}
可以看到fetchSchoolInfo()函數里嵌套地調用了fetchNetData()函數,層層遞進,并且伴隨著error和succeed分支判斷,同時異常的錯誤很難拋出去。
此種場景下代碼并不簡潔,分支多容易出錯且不易調試,當需要依賴的更多時,我們就陷入了回調地獄。
3. Promise解決了什么問題?
3.1 Promise替代回調
怎么解決回調地獄的問題呢?這個時候Promise出現了。
還是以獲取學生信息為例:
function fetchNetData(url: string): Promise<any> {//模擬網絡耗時return new Promise((resolve, reject) => {setTimeout(() => {if (Math.random() > 0.2) {//成功resolve({code: 200, msg: 'success'})} else {//失敗reject(`${url} fetch error`)}}, 1000)})
}
與之前的對比,fetchNetData()函數只需要傳入一個參數,無需回調函數,它返回一個Promise。
當網絡請求成功,則調用resolve()函數,當網絡請求失敗則調用reject()函數。
既然返回了Promise,接著看看如何使用這個返回值。
function fetchStuInfo() {fetchNetData('/info/stu').then(data => {//成功console.log(data)}, error => {//失敗console.log(error)})
}
你可能會說,這看起來和使用回調的方式差不多呢,then()函數的閉包就相當于回調嘛。
確實,單看這個例子和回調差不多,接著嘗試用Promise改造之前的回調地獄。
function fetchSchoolInfo() {//先獲取學生信息,成功后帶有teacherIdfetchNetData('/info/stu').then(data => fetchNetData('/info/teacher')).then(data => fetchNetData('/info/school')).then(data => console.log(data)).catch(err => console.log(err))
}
這么看,使用Promise是不是簡潔了許多,回調方式代碼一直往右增長,而使用Promise每個接口請求都是平鋪,并且它們的邏輯關系是遞進的。
三個接口都成功,則打印成功的結果。
其中一個接口失敗,剩下的接口都不會再請求,并且錯誤結果被catch()函數捕獲。
3.2 Promise基本使用
Promise 是個接口,它有兩個函數:
- then(resolve,reject)函數,入參有兩個(都是可選的),返回Promise類型
- catch(reject)函數,入參有一個(可選),返回Promise類型
- 構造Promise需要傳遞一個參數,其是函數類型,該函數類型包括兩個入參:resolve和reject,當解決了Promise時需要調用resolve()函數,當拒絕了Promise時調用reject()函數
Promise中文意思是承諾,將Promise暴露出去意思就是將承諾放出來。
- 就像小明請小紅幫個忙
- 小紅不會立即幫忙,而是給小明一個承諾:我會回復你到底是幫還是不幫
- 小紅決定幫忙:調用resolve()函數,表示這個忙我幫定了
- 小紅決定不幫忙,調用reject()函數拒絕,表示愛莫能助
- 不論小紅作出了什么樣的答復,這個承諾就算結束了
用代碼表示如下:
function helpXiaoMing(): Promise<string> {return new Promise((resolve, reject) => {//擲骰子if (Math.random() > 0.5) {resolve('這個忙我幫定了')} else {reject('愛莫能助')}})
}
無論小紅resolve()還是reject(),最終小明得要知道結果。
當小明發起幫助請求時,他有兩種方式可以拿到小紅的回復:
- 一直等到小紅回復,對應await()函數
- 先去做別的事,等小紅通知,對應Promise.then()函數
我們先看第二種方式:
helpXiaoMing().then(value => {//成功的結果,value就是resolve的參數console.log(value)
}, reason => {//失敗的結果,reason就是reject的參數console.log(reason)
})
從上我們也發現了Promise一個特點:無論外部是否有監聽Promise結果,Promise都會按照既定邏輯更改它的狀態。也就是說無論小明是否關注小紅的承諾,她都需要給個準信。
回到最初的問題,Promise解決了什么問題:
- Promise本質上也是基于回調,只是把回調封裝了
- Promise解決嵌套回調地獄的問題
- Promise使得異步代碼更簡潔
- Promise支持鏈式調用,很好地關聯了多個異步邏輯
4. Promise常用的API
4.1 Promise 常用的API
上面列舉了使用Promise基礎三板斧:
- new Promise((resolve,reject)),構造Promise對象
- 修改狀態resolve()/reject()
- 監聽(接收)Promise狀態
1. then()可選參數
then()函數的兩個參數都是可選的
只關注成功狀態:
helpXiaoMing().then(value=>{console.log('success:',value)
})
只關注失敗狀態:
helpXiaoMing().then(null, reason => {console.log('fail:', reason)
})
兩者皆關注:
helpXiaoMing().then(value => {console.log('success:', value)
}, reason => {console.log('fail:', reason)
})
2. catch()可選參數
不想在then里監聽失敗的狀態,也可以單獨使用catch()
helpXiaoMing().then(value => {console.log('success:', value)
}).catch(reason => {})
失敗狀態有兩個來源:
- 顯示調用了Promise.reject()函數
- 代碼拋出了異常throw Error()
失敗的狀態會先找到最近能夠處理該狀態的地方。
3. finally()始終會執行
當Promise狀態更改后,finally始終會執行,執行的順序和書寫順序一致。
helpXiaoMing().then(value => {console.log('success:', value)
}).catch(reason => {console.log('error:', reason)
}).finally(() => {console.log('finally called')
})
Promise狀態只要變成了成功或失敗,那么finally打印將會執行,此時因為finally寫在最后,因此最后執行。
交換個位置:
helpXiaoMing().finally(() => {console.log('finally called')
}).then(value => {console.log('success:', value)
}).catch(reason => {console.log('error:', reason)
})
finally打印先執行。
4. then()/catch()/finally() 函數返回值
這三個函數都是返回了Promise,那他們的Promise的狀態由誰更改呢?
helpXiaoMing().then(value => {console.log('success:', value)return 'success occur'
}).then(value => {console.log('second then value:', value)
}).catch(() => {
})
第一個then()函數返回了一個Promise,而這個Promise的值就是第一個then()函數閉包里返回的 ‘success occur’。
當第二個then()執行時,會等待第一個then()函數返回的Promise狀態更改,此時return 'success occur’之后就會執行Promise.resolve( ‘success occur’),因此第二個then()函數打印:second then value: success occur
同樣的,當在catch()函數的閉包里返回值時,該值也作為下一個then()的入參。
helpXiaoMing().then(value => {console.log('success:', value)return 'success occur'
}).catch(() => {return '抓到錯誤,將信息傳遞給下一個then'
}).then(value => {console.log('second then value:', value)
})
至于finally(),它的閉包里沒有參數,返回值也不會傳遞下去。
then()/catch()函數特性使得Promise可以進行鏈式調用。
5. then()/catch()/finally() 函數閉包返回值
理論上這幾個函數的的閉包能夠返回任意值,先看Promise構造函數閉包里傳遞的類型:
function helpXiaoMing(): Promise<any> {return new Promise((resolve, reject) => {//擲骰子if (Math.random() > 0.5) {console.log('resolve')//resolve('這個忙我幫定了') 返回普通字符串(基本類型)resolve({msg: '這個忙我幫定了'})//返回對象} else {console.log('reject')//reject('愛莫能助') 返回普通字符串(基本類型)reject({reason: '愛莫能助'})//返回對象}})
}
由上可知,傳遞了引用對象類型,那么helpXiaoMing().then()閉包接收的參數也是對象。而對象里比較特殊的是返回Promise類型的對象。
function helpXiaoMing(): Promise<any> {//外層Promise對象return new Promise((resolve, reject) => {//擲骰子if (Math.random() > 0.5) {console.log('resolve')//內層Promise對象resolve(new Promise((resolve2, reject2) => {setTimeout(() => {resolve2('我是內部的Promise')}, 2000)}))} else {console.log('reject')//reject('愛莫能助') 返回普通字符串reject({reason: '愛莫能助'})//返回對象}})
}
當調用:
helpXiaoMing().then(value => {console.log('success:', value)return 'success occur'
})
then監聽的是內層Promise對象的變化,因此最終打印的結果是:
resolve
success: 我是內部的Promise
同樣的,then()/catch()/finally()閉包里也可以返回Promise對象
helpXiaoMing().then(value => {console.log('success:', value)return new Promise((resolve2, reject2) => {setTimeout(() => {resolve2('我是內部的Promise')}, 2000)})
}).then(value => {console.log('second then value:', value)
})
基于這種特性,Promise可作鏈式調用,就像最開始那會兒用Promise替代回調的寫法就涉及到了Promise鏈式調用。
4.2 Promise 易混淆的地方
先看第一個易混點:
helpXiaoMing().then(value => {console.log('success:', value)
}).then(value => {//猜猜這里的打印結果是什么console.log(value)
})
如果第一個then閉包執行成功,那么第二個then閉包的結果是啥?
答案是輸出:undefined
因為想要將數據往下傳遞,then()/catch()函數閉包里必須顯式返回數據:
helpXiaoMing().then(value => {console.log('success:', value)return value
}).then(value => {//猜猜這里的打印結果是什么console.log(value)
})
當然如果是簡單的表達式,那就可以忽略return:
helpXiaoMing().then(value => value).then(value => {//猜猜這里的打印結果是什么console.log(value)
})
與上面效果一致。
第二個易混點:
helpXiaoMing().then(value => {throw Error
}).catch()
catch()能夠捕獲到異常嗎?
答案是:不能
catch()需要傳入參數:
helpXiaoMing().then(value => {throw Error
}).catch(()=>{})
一個空的實現,就能捕獲異常。
第三個易混點:
finally()閉包在then()或catch()閉包之后執行?
答案是:不一定
這和傳統的try{…}catch{…}finally{…}不太一樣,傳統的先執行try里面的或者是catch里的,最終才執行finally,而此處Promise里的finally是表示該Promise狀態變為了"settled",至于在then()閉包還是catch()閉包前執行,決定點在于書寫的順序,具體的Demo在上一節。
第四個易混點:
Promise需要調用then()才會觸發狀態變化嗎?
答案是:不一定
function test() {return new Promise((resolve, reject) => {console.log('hello')resolve('hello')})
}
//沒有.then,Promise狀態也會變化
test()
4.3 Promise其它API
還有一些比較高級的API,如Promise.all()/Promise.allSettled()/Promise.race()/Promise.any()/Promise.reject()/Promise.resolve()等,此處就不再細說。
5. async和await 如影隨形
5.1 await 返回值
Promise確實比較好用,你可能已經發現了監聽Promise的狀態變化是個異步的過程,then()函數里的閉包其實就是傳一個回調函數進去。
有些時候我們需要等待異步任務的結果回來后再進行下一步操作,這個時候該怎么做呢?
之前提到過的Demo里,小明可以選擇一直等小紅的回復,也可以先去做別的事等小紅的通知,第二種場景上邊已經分析過了,這次我們來看看第一種場景。
async function testWait() {console.log('before get result')const result = await helpXiaoMing()console.log('after result:', result)
}
testWait()
使用await操作符會使得當前調用者一直等待Promise狀態變為完成(可能成功、可能失敗),如上第二條語句一直等到Promise結束。
如果Promise成功,則拿到具體結果,如果Promise失敗則會返回異常,因此需要對await本身進行異常捕獲:
async function testWait() {console.log('before get result')try {const result = await helpXiaoMing()console.log('after result:', result)} catch (e) {console.log(e)}
}
- await 作用是掛起當前線程,而不是讓線程停止執行(sleep等),掛起的意思是線程執行到await 這地方就暫時不往下執行了,但它不會休息,而是先去執行其它任務
- 等到await 的Promise返回,線程繼續執行await之后的代碼
- await 只能在async 修飾的函數里調用
5.2 async 修飾的函數返回值
async 修飾的函數最終會返回Promise
如上圖,經過async修飾的函數,它的返回值被包裝為Promise對象,而該Promise對象的值來源于async 函數的return 語句,此處我們沒有return,因此值類型是void。
此時Promise值類型是string。
await helpXiaoMing()發生了異常,await之后的代碼不會再執行。同時async返回的Promise會調用reject()函數將異常傳遞出去。
async function testWait() {console.log('before get result')const result = await helpXiaoMing()console.log('after result:', result)return '完成了'
}testWait().then(value => {//成功,走這console.log('value=>', value)
}, error => {//失敗走這console.log('error=>', error)
})
5.3 理解async和await的時序
看以下例子,猜猜打印結果是什么?
function waitPromise2() {return new Promise((resolve, reject) => {setTimeout(() => {resolve('waitPromise2返回')}, 1000)})
}async function testWait1() {console.log('before1 get result')const result = await waitPromise1()console.log('after1 result:', result)return '完成了testWait1'
}async function testWait2() {console.log('before2 get result')const result = await waitPromise2()console.log('after2 result:', result)return '完成了testWait2'
}testWait1()
testWait2()
答案是:
before1 get result
before2 get result
after2 result: waitPromise2返回
after1 result: waitPromise1返回
剛接觸async/await 的小伙伴可能會認為:
testWait1()里不是有await 阻塞了嗎?此時線程一直阻塞在await處,testWait2()沒機會執行,必須等到testWait1()結束后才能執行?
而實際的效果卻是:
- 線程執行到testWait1()里的await后掛起,并退出testWait1(),進而繼續執行testWait2()
- 在執行testWait2()的await后也會掛起
- 此時testWait1()和testWait2()都執行到await了,等待各自的Promise返回結果
- 由于testWait2()里的await時間較短,它先完成了所以先打印了"after2 result: waitPromise2返回",緊接著testWait1()的await 也返回了
當然,如果想要testWait1()和testWait2()按順序執行怎么辦呢?
我們知道testWait1()和testWait2()都會返回Promise,我們只需要await Promise即可:
async function testWait() {await testWait1()await testWait2()
}
testWait()
其打印結果如下:
before1 get result
after1 result: waitPromise1返回
before2 get result
after2 result: waitPromise2返回
5.4 async和await 作用
Promise代表的是異步編程,而通過async和await的親密配合,我們可以使用同步的方式編寫異步的代碼。
其它語言也有類似的操作,比如Koltin的協程里的withcontext()函數。
6. Promise的江湖地位
好了說了一大篇Promise,是時候總結一下了。
- Promise 是前端實現異步任務的基石
- Promise 存在于前端代碼的各個方面
至于地位嘛,類比閣老
本篇介紹了Promise的基本用法以及坑點,下篇將重點分析異步任務的時序(宏任務、微任務),相信你看完再也不用擔心時序問題了,敬請期待~