一.前言
在早期 JavaScript
的 ES5
語法中,多層函數的回調嵌套是一件讓人很頭疼的事兒,行內黑話一般稱之爲回調地獄 。
可能有些夥計還沒遇到過此類業務場景,但是沒關係,只要在前端圈裏混,蒼天會繞過誰呢?所以爲了大家,我就舉個特別常見的業務場景:
- 有三個接口,分別爲
URL-A
,URL-B
,URL-C
(都是get
請求),我們需要分別向這三個接口請求獲取數據。 - 請求
URL-B
時需要帶上URL-A
返回的數據,同理,請求URL-C
時也要帶上URL-B
返回的數據。
我們來看看用早期的 jquery ajax
會怎麼處理:
$.get('/URL-A', function(resA){
// do Something
$.get('/URL-B?query=' + resA,function(resB){
// do Something
$.get('/URL-C?query=' + resB, function(resC){
// do Something
})
})
})
從上面我們可以看出,這一段代碼是很不健康的,爲什麼這麼說?有以下幾點理由:
- 代碼橫向發展,而不是縱向變多,就像人不長高反而長胖一般,十分不健康。
- 業務邏輯不夠直觀,維護困難。
- 業務代碼與公用代碼難以抽離,函數之間強耦合,一旦報錯很難快速定位問題所在。
當然,我們也可以用函數內 callback
的形式來改寫上面的這段代碼,使之變得更直觀些:
// 請求 URL-C
function getURLCData(res){
$.get('/URL-C?query=' + res, function(res){
// do Something
})
}
// 請求 URL-B
function getURLBData(res){
$.get('/URL-B?query=' + res, function(res){
// do Something
getURLCData(res)
})
}
// 請求 URL-A
function getURLAData(){
$.get('/URL-A', function(res){
// do Something
getURLBData(res)
})
}
這樣我們就避免了函數的縱向發展,公共代碼與業務代碼也可以抽離,但是這種方式還不夠直觀,在複雜業務,超高併發請求下,業務代碼依舊晦澀。
所以,在 ES6
中提出了 Promise
用來解決回調嵌套的問題。
以上代碼的 Promise
改寫我們在下文再講,我們先講講何爲 Promise
。
二. Promise
的基本用法
對於 Promise
我們可以這麼理解,如果一個函數 Promise
(數據準備好了)了,那麼我們就可以 then
乾點事情。
MDN
對其有以下描述:
Promise 對象是一個代理對象(代理一個值),被代理的值在Promise對象創建時可能是未知的。它允許你爲異步操作的成功和失敗分別綁定相應的處理方法(handlers)。 這讓異步方法可以像同步方法那樣返回值,但並不是立即返回最終執行結果,而是一個能代表未來出現的結果的promise對象
Promise
有以下四個函數可以調用:
Promise.all(iterable)
這個方法返回一個新的 promise 對象,該 promise 對象在 iterable 參數對象裏所有的 promise 對象都成功的時候纔會觸發成功,一旦有任何一個 iterable 裏面的 promise 對象失敗則立即觸發該 promise 對象的失敗。
注意,iterabe 參數爲數組,數組裏存放 promise 對象。Promise.race(iterable)
當iterable參數裏的任意一個子promise被成功或失敗後,父promise馬上也會用子promise的成功返回值或失敗詳情作爲參數調用父promise綁定的相應句柄,並返回該promise對象。Promise.resolve(value)
返回一個狀態由給定value決定的Promise對象。Promise.reject(reason)
返回一個狀態爲失敗的Promise對象,並將給定的失敗信息傳遞給對應的處理方法
我們來看個例子:
實現一個簡單的定時 promise:
function delayLogNum(){
return new Promise((resolve, reject) => {
setTimeout(()=> {
console.log('success');
resolve('ok');
}, 3000)
})
}
delayLogNum().then(res => {
console.log(res)
})
結果如下所示:
三.Promise
處理串行和並行
在 JavaScript
中已經有同步,異步,串行,並行這些概念了,大家需分清楚其中的區別:
- 同步與異步是指是在 JavaScript 的主線程中執行(同步)還是丟到任務隊列中執行(異步)。
- 串行和並行是指在異步任務隊列中的函數是按順序一個一個執行(串行)還是所有隊列中的函數一起執行,但是必須在所有函數執行完畢後再接着執行下一步。
在 ES7
中新增加了 async
和 await
關鍵字:
async
用於定義一個返回 AsyncFunction 對象的異步函數。異步函數是指通過事件循環異步執行的函數,它會通過一個隱式的 Promise 返回其結果。await
操作符用於等待一個Promise 對象。它只能在異步函數 async function 中使用。
ok,梳理完了前置知識點,我們來看看利用 Promise
和 async await
怎麼處理串行。
舉一個例子:遍歷一個 Number
數組並且在每次遍歷時延時 1
秒輸出遍歷的數值。
function delay(){
return new Promise((resolve, reject) => {
setTimeout(resolve, 1000)
})
}
async function eachEveryVal(item){
await delay();
console.log(item)
}
async function eachArr(data){
for(var val of data){
await eachEveryVal(val)
}
}
eachArr([1, 2, 3, 4])
我們再舉一個例子,就拿最開始那段代碼來說,就是典型的異步串行操作,我們可以這麼來改寫它:
function getURL(url){
return new Promise((resolve, reject) => {
$.get(url, res => {
resolve(res)
})
})
}
async getData(){
let dataA = await getURL('URL-A');
let dataB = await getURL('URL-B?query=' + dataA);
let dataC = await getURL('URL-C?query=' + dataB);
};
getData();
講完了串行,我們再來講講異步並行。假設我們有以下需求:
- 有三個接口,分別爲
URL-A
,URL-B
,URL-C
(都是get
請求),我們需要分別向這三個接口請求獲取數據。 - 在三個請求都結束後,拿到他們的數據進行業務處理。
這就是一個典型的並行的業務需求,我們也可以用 promise
來實現它。
const URI_LIST = ["URL-A", "URL-B", "URL-C"];
function getURL(url){
return new Promise((resolve, reject) => {
$.get(url, res => {
resolve(res)
})
})
}
async function getData(){
const promises = URI_LIST.map(url => getURL(url));
Promise.all(promises).then(res => console.log(res))
};
getData();
我大概解釋下這段代碼:
getURL()
函數返回一個promise
,並在傳入url
參數給$.get()
調用,請求成功後調用reslove(res)
來返回請求結果。getData()
函數聲明一個promises
來存放getURL(url)
返回的promise
對象,通過URI_LIST.map()
來得到我們在基礎用法中所講的iterable
參數對象,並將此對象傳入Promise.all()
中,最後通過then()
獲取結果。要注意的是,此結果是三個請求返回的數據組成的數組。
四.Promise
常見特性
有以下5個特性需要大家理解:
- Promise 捕獲錯誤與 try catch 等同.
- Promise 擁有狀態變化.
- Promise 方法中的回調是異步的.
- Promise 方法每次都返回一個新的 Promise.
- Promise 會存儲返回值.
下面我來一一解釋這 5 點特性:
1.Promise 捕獲錯誤與 try catch 等同
這句話的意識就是說,在 new Promise(()=>{})
中直接去 throw err
,是可以通過 Promise.catch()
方法捕捉的,這也就意味中 Promise
內部也通過 try catch
進行了異常處理。
2.Promise 擁有狀態變化
Promise
有以下三種狀態:
- pending: 初始狀態,既不是成功,也不是失敗狀態。
- fulfilled: 意味着操作成功完成。
- rejected: 意味着操作失敗。
而 Promise.resolve()
和 Promise.reject()
都會改變 promise
的狀態值,其中 resolve
會將此狀態值修改爲 fulfilled
, 而 reject
會將此狀態值修改爲爲 rejected
。
特別的,一旦 Promise
的狀態值被改變,就會被固定,不再發生變化。也就是說只要你 resolve()
或者 reject()
了一次,在這之後無論你再調用幾次這兩個方法都不起效果。
3.Promise 方法中的回調是異步的
先解釋一下,Promise
方法中的回調是異步的這句話中的方法是指 Promise
中的 catch
,then
,finally
這些方法,而不是指 new Promise()
中的 executor
函數,這個函數你可以把它理解爲一個立即執行函數。
想要真正理解 Promise 方法中的回調是異步的
這句話,還沒有這麼簡單,爲什麼這麼說,因爲 setTimeout
也是異步的,如果它們兩同時存在且作用域平級,那麼誰先執行,誰後執行,它們之間的競爭關係怎麼確認?
想要了解這其中的原理,我們就需要了解一個概念:微任務(microtasks)和宏任務(tasks)。
我們已經知道,JavaScript
是單線程的操作,正是因爲如此,纔有了現在的同步和異步之分。在主線程中,一般是按順序執行同步任務。而其他的異步任務則會掛起,當它們有返回值後會添加到任務隊列中。等到主線程的同步任務執行完畢後,它會去任務隊列中讀取(按先進先出的原則)異步任務執行。以此形成一個反覆的過程被稱爲事件循環。借用一個掘金上的圖片,侵刪:
而在異步任務中,其實又可以細分爲宏任務和微任務。
- 宏任務可以當成了廣義的異步隊列中的任務,嚴格按照順序壓棧和執行。比如說 整體代碼,
setTimeout
,setInterval
,MessageChannel
(Web Worker中的管道通信)。 - 微任務是當前宏任務執行完成後立即執行的任務。
而在整個異步流程中,JavaScript
會先進入整體代碼執行宏任務,然後再檢查是否有微任務需要執行,如果有,則需要立即執行;如果沒有則檢查隊列,開始執行下一批宏任務並檢查微任務。借用一個掘金上的圖片,侵刪:
總結一下, Promise
中的 executor
函數是處於主線程同步隊列中執行(立即執行函數),而其他的方法諸如 then
, catch
等,則是異步任務隊列中的微任務,諸如 setTimeout
,setInterval
等函數必須在微任務執行完畢後再開始執行。
所以,看到這兒,整個 Promise
中的函數內部在整個執行棧的執行順序和競爭關係就已經很清晰了。
4.Promise 方法每次都返回一個新的 Promise
這兒的意思很直白,意味着無論是 then
,catch
亦或是 finally
都會返回 一個新的 Promise
對象。
5.Promise 會存儲返回值
一般情況下我們都會這樣來使用 Promise
:
function p(flag){
return new Promise((resolve, reject) => {
if(flag){
resolve('success')
}else{
reject('error')
}
})
};
p(true).then(res => console.log('res', res))
可以看到,我們通常會把一些參數或者函數在成功狀態下通過 resolve()
傳遞給 then()
函數來接收並作相應處理;在失敗狀態下通過 reject()
把錯誤信息傳遞給 catch()
函數來處理。
特別的,如果你在 Promise
直接返回某些參數, Pormise
也會捕捉到你返回的參數並把它包裝成 Promise
對象並傳遞給對應的接收函數。
五. Promise
面試題
5.1 請用 Pormise
實現以下流水燈,已知紅黃綠三個函數,要求紅燈3秒執行一次,黃燈2秒執行一次,綠燈1秒執行一次:
function red() {
console.log('red');
}
function green() {
console.log('green');
}
function yellow() {
console.log('yellow');
}
function delay(time){
return new Promise((resolve, reject) => {
setTimeout(resolve,time)
})
}
async function runTask(){
await delay(3000);
red();
await delay(2000);
green();
await delay(1000);
yellow();
// 遞歸循環播放
runTask()
}
runTask();
5.2 請用 Pormise
實現 mergePromise 函數,把傳進去的數組按順序先後執行,並且把返回的數據先後放到數組 data 中:
const timeout = ms => new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
}, ms);
});
const ajax1 = () => timeout(2000).then(() => {
console.log('1');
return 1;
});
const ajax2 = () => timeout(1000).then(() => {
console.log('2');
return 2;
});
const ajax3 = () => timeout(2000).then(() => {
console.log('3');
return 3;
});
const mergePromise = ajaxArray => {
// 在這裏實現你的代碼
};
mergePromise([ajax1, ajax2, ajax3]).then(data => {
console.log('done');
console.log(data); // data 爲 [1, 2, 3]
});
// 要求分別輸出
// 1
// 2
// 3
// done
// [1, 2, 3]
我們先分析一下題目,看到這個題目是不是就有一種很熟悉的感覺?像不像我們在上面改寫的異步並行?
你的感覺沒錯,實際上這道題考查的就是讓你手寫一個簡單的 Promise.all()
函數。
所以,我們就能知道,上題中 ajaxArray
參數實際上就是一個包含多個 Promise
對象的數組,我們可以用並行遍歷的方式來處理它。
const mergePromise = ajaxArray => {
let seq = Promise.resolve();
let data = [];
ajaxArray.map(func => {
seq = seq.then(func).then(res => {
data.push(res);
return data;
})
})
return seq;
};
六.小結
如果你看到這了這,那麼恭喜你,不管你有沒有吸收其中的內容,你至少你知道了整個 Promise
應該怎麼去學。實際上在工作中 Promise
的應用是很多的,包括我們使用的 babel
中也會有 Promise-polyfill
。現在已經是 9102
年了,前端圈已經逐漸穩定下來,這意味着你我的時間已然不多,所以加油吧,夥計們。