[突破前端面試]——Promise&&Async/Await

NO IMAGE

前言

年前年後跳槽季,準備從面試內容入手看看前端相關知識點,旨在探究一個系列知識點,能力範圍之內的深入探究一下。重在實踐,針對初級前端和準備面試的同學,爭取附上實際的代碼例子以及相關試題~系列名字就用【禿破前端面試】—— 因為圈內大家共識,技術與髮量成正比。😄希望大家早日 禿 破瓶頸

關於面試題或者某個知識點的文章太多了,這裡筆者只是想把個人的總結用代碼倉庫的形式記錄下來並輸出文章,畢竟理論不等於實踐,知其然也要知其所以然,實踐用過才能真正理解~

相關係列文章:

Promise

Promise 背景

凡事有因必有果,新事物的出現就代表著老的事物不能滿足我們的需求了。Promise 這個新事物就是在這個背景下出現的,而它代替的老事物就是ES6 之前經常被用的 callback(回調函數)。

雖然 ES6 Promise 已經並不能算是新事物了,但是就背景來說,它剛出現的時候確實是來解決異步回調地獄問題的。

回調地獄

什麼是回調地獄,來看一個最簡單的示例:

setTimeout(() => {
console.log(111);
setTimeout(() => {
console.log(222);
setTimeout(() => {
console.log(333);
setTimeout(() => {
console.log(444);
// 你還可以放置更多
...
}, 4000);
}, 3000);
}, 2000)
}, 1000);

一般來說回調地獄就是出現在異步操作中,下一次的操作依賴上一次的結果,一環套一環,套著套著就套的我們頭痛難忍,寫出了上面的代碼。

當然,上面有點為了黑而黑了,事實上,經常使用的場景應該是 AJAX 請求以及數據庫的各種操作會產生回調地獄。下面代碼就是一個標準的數據庫查多次表的一個操作(這裡我只查了兩次,但是也已經形成了嵌套)。

 /**
* 回調地獄示例
*/
const db = Object.create(null); // 假設這就是連接數據庫的對象
/**
* 第一步,從 A 表查出 id 為 1 的用戶
* 第二步,從 B 表查出文章作者是 id = 1 用戶 username 的所有文章
**/
db.query('SELECT * FROM A WHERE id = 1', function(err, results) {
if (err) throw err;
// 完成第一步,開始第二步
db.query(`SELECT * FROM B WHERE author = ${results[0].username}`, function(err, results) {
if (err) throw err;
// 完成第二步,開始幹壞事
console.log(results);
});
});

上面代碼,如果再繼續查下去,一定跟上面的代碼差不太多,而數據庫查詢也確實可能會出現上面的情況。

Promise 解決異步避免回調地獄

出現問題了,就得解決啊,Promise 就出現了,先來看看 Promise 怎麼解決回調地獄的。

 // promise 解決
function f1() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(111), 1000);
}).then(data => console.log(data));
}
function f2() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(222), 2000);
}).then(data => console.log(data));;
}
function f3() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(333), 3000);
}).then(data => console.log(data));;
}
function f4() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(444), 4000);
}).then(data => console.log(data));;
}
f1().then(f2).then(f3).then(f4);

嗯,這麼一看,確實是解決了,並沒有函數嵌套,然後調用也變成了鏈式調用。當然,這個例子也有點特殊,反過來看看數據庫查詢數據的例子:

/**
* 使用 Promise
* 因為 Promise 是 ES6,所以下面所有代碼都使用 ES6 語法
**/
new Promise((resolve, reject) => {
db.query('SELECT * FROM A WHERE id = 1', (err, results) => {
if (err) reject(err);
resolve(results);
});
}).then(data => {
// 拿到第一步數據,開始第二步
db.query(`SELECT * FROM B WHERE author = ${results[0].username}`, (err, results) => {
if (err) reject(err);
// 完成第二步,開始幹壞事
console.log(results);
}); 
}).catch(err => {
throw err;
});

相比之下,看起來確實要好看一些。

Promise 基礎

Promise 對象用於表示一個異步操作的最終完成 (或失敗),及其結果值。Promise 對象是一個代理對象(代理一個值),被代理的值在 Promise 對象創建時可能是未知的。它允許你為異步操作的成功和失敗分別綁定相應的處理方法(handlers)。 這讓異步方法可以像同步方法那樣返回值,但並不是立即返回最終執行結果,而是一個能代表未來出現的結果的 promise 對象。

它的出現是為了解決 ES6 之前 JS 代碼中頻繁嵌套回調函數所導致的回調地獄問題,Promise 為 ES6 特性。

Promise 狀態

一個 Promise 對象值是未知的,狀態是可變的,但是無論怎麼變化,它的狀態永遠處於以下三種之間:

  • pending:初始狀態,既不是成功,也不是失敗。
  • fulfilled:意味著操作成功完成。
  • rejected:意味著操作失敗。

Promise 的狀態會發生變化,成功時會從pending -> fulfilled,失敗時會從pending -> rejected,但是此過程是不可逆的,也就是不能從另外兩個狀態變成pendingfulfilled/rejected這兩個狀態也被稱為 settled 狀態。

Promise使用

JS 萬物皆對象,所以 Promise 也可以被我們new出來。我們通過下面的語法來新建一個 Promise 對象:

new Promise( function(resolve, reject) {...} /* executor */  );

Promise 的構造函數有一個參數 —— 是一個帶有兩個參數(resolve, reject)的函數,這兩個參數分別代表此次異步操作的結果也就是Promise的狀態。resolvereject函數被調用時,分別會將此次 Promise 的狀態改成fulfilled或者rejected,一旦異步操作結束,Promise 的最終狀態只能是二者之一,如果異步成功,該狀態會被resolve函數修改為fullfilled;相反當異步過程中拋出一個錯誤,那麼該狀態就會被reject函數改成rejected

Promise API

Promise 的原型鏈以及對象本身有一些方法供我們使用,其中最常用也比較有可說性的就是下面這幾個:

then —— Promise.prototype.then(onFulfilled, onRejected)

添加解決(fulfillment)和拒絕(rejection)回調到當前 promise, 返回一個新的 promise, 將以回調的返回值來 resolve。

這麼看起來總是晦澀難懂的,還是得實際代碼來看:

 new Promise((resolve, reject) => {
setTimeout(() => resolve(111), 1000);
}).then(data => {
console.log(data);
});
new Promise((resolve, reject) => {
setTimeout(() => reject(111), 1000);
}).then(data => {
console.log(data);
});

[突破前端面試]——Promise&&Async/Await

可以看到,.then裡面拿到的是我們 Promise resolve 過後的數據。並且他還會返回一個 Promise 繼續供我們調用,比如:

new Promise((resolve, reject) => {
setTimeout(() => resolve(111), 1000);
}).then(data => {
console.log(data); // 打印 111
return data + 111; // 相當於 resolve(data + 111)
}).then(data => {
console.log(data); // 打印 222
});

then()用法比較簡單,大家肯定也經常用,這裡其實就知道.then()是可以一直鏈式調用的,因為它的返回值也是一個 Promise,就可以了。

catch — Promise.prototype.catch(onRejected)

添加一個拒絕(rejection) 回調到當前 promise, 返回一個新的 promise。當這個回調函數被調用,新 promise 將以它的返回值來 resolve,否則如果當前 promise 進入 fulfilled 狀態,則以當前 promise 的完成結果作為新 promise 的完成結果。

 new Promise((resolve, reject) => {
setTimeout(() => reject(111), 1000);
}).then(data => {
console.log('then data:', data);
}).catch(e => {
console.log('catch e: ', e);
});

[突破前端面試]——Promise&&Async/Await

如上圖所示:通常來說,一般寫到 catch 就表示發生異常了,一般就結束了,但是從文檔說明來看,它返回的也是一個 Promise,我表示並沒有這麼用過,但是還是實驗一下吧:

 new Promise((resolve, reject) => {
setTimeout(() => reject(111), 1000);
}).then(data => {
console.log('then data:', data);
}).catch(e => {
console.log('catch e: ', e);
return e;
}).then(data => {
console.log('catch data: ', data);
});

[突破前端面試]——Promise&&Async/Await

好吧,漲姿勢了,但是還是那句話,個人覺得 catch 到錯誤就可以了,沒必要下一步了,除非你還要用錯誤做其他的事情~

finally —— Promise.prototype.finally()

上面提到了catch()一般來說用於捕獲錯誤,所以大部分代碼應該是到這一步就結束了,但是實際上 Promise 提供了標準結束方法 finally(),只要 Promise 狀態變成 settled,無論是 rejected 還是 fulfilled,都會在 finally 裡捕獲。

 new Promise((resolve, reject) => {
setTimeout(() => reject(111), 1000);
}).then(data => {
console.log('then data:', data);
}).catch(e => {
console.log('catch e: ', e);
return e;
}).then(data => {
console.log('catch data: ', data);
return data;
}).finally(() => {
console.log('promise finally');
return 222;
}).then(data => {
console.log('finally data: ', data);
});

[突破前端面試]——Promise&&Async/Await

從上圖可以看得出,finally 也會返回一個 promise,但是我勸大家善良,真的到 finally 就可以結束了!!!這裡只是為了演示它的返回。

我想了一下,不常用的原因可能是自己太 low 了,其實它還是有很明顯的試用場景的。比如官方給出的Demo:

let isLoading = true;
fetch(myRequest).then(function(response) {
var contentType = response.headers.get("content-type");
if(contentType && contentType.includes("application/json")) {
return response.json();
}
throw new TypeError("Oops, we haven't got JSON!");
})
.then(function(json) { /* process your JSON further */ })
.catch(function(error) { console.error(error); /* this line can also throw, e.g. when console = {} */ })
.finally(function() { isLoading = false; });

這個場景應該在實際開發過程中很常用,如果不使用 finally,我們會在 then 和 catch 裡分別設置一次isLoading = false;,而使用 finally 則只需要賦值一次,不僅避免了重複代碼而且優化了邏輯~這才是正確的使用之道啊~

Promise.finally(fn)需要注意以下兩點:

  • 參數 fn 是一個無參函數,不論該 promise 最終是 fulfilled 還是 rejected。
  • finally 不會改變 promise 的狀態。

all —— Promise.all(iterable)

這個方法返回一個新的 promise 對象,該 promise 對象在 iterable 參數對象裡所有的 promise 對象都成功的時候才會觸發成功,一旦有任何一個 iterable 裡面的 promise 對象失敗則立即觸發該 promise 對象的失敗。這個新的 promise 對象在觸發成功狀態以後,會把一個包含 iterable 裡所有 promise 返回值的數組作為成功回調的返回值,順序跟 iterable 的順序保持一致;如果這個新的 promise 對象觸發了失敗狀態,它會把 iterable 裡第一個觸發失敗的 promise 對象的錯誤信息作為它的失敗錯誤信息。Promise.all 方法常被用於處理多 個promise 對象的狀態集合。

這個算是我經常使用的一個 API 了,上面的內容雖然有點長,但是總結起來其實也很簡單,大概就是如下三個方面:

  • 第一:接收一個 Promise 對象數組作為參數
  // promise 解決
function f1() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(111), 1000);
}).then(data => console.log(data));
}
function f2() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(222), 2000);
}).then(data => console.log(data));;
}
function f3() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(333), 3000);
}).then(data => console.log(data));;
}
Promise.all([f1, f2, f3]);

[突破前端面試]——Promise&&Async/Await

  • 第二:參數所有回調成功才是成功,返回值數組與參數順序一致
  // promise 解決
function f1() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(111), 1000);
});
}
function f2() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(222), 2000);
});;
}
function f3() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(333), 3000);
});;
}
Promise.all([f1(), f2(), f3()]).then(results => {
console.log(results);
});

[突破前端面試]——Promise&&Async/Await

可以看到,返回值是一個數組,並且每個元素對應的就是參數數組裡對應過後的resolve值。

  • 第三:參數數組其中一個失敗,則觸發失敗狀態,第一個觸發失敗的 Promise 錯誤信息作為 Promise.all 的錯誤信息。
 // promise 解決
function f1() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(111), 1000);
});
}
function f2() {
return new Promise((resolve, reject) => {
setTimeout(() => reject(222), 2000);
});;
}
function f3() {
return new Promise((resolve, reject) => {
setTimeout(() => reject(333), 3000);
});;
}
Promise.all([f1(), f2(), f3()]).then(results => {
console.log(results);
}).catch(e => {
console.log(e);
});

[突破前端面試]——Promise&&Async/Await

可以看到,當我把第二個和第三個分別設置成 reject 的時候,Promise.all 進入了 catch 也就是捕獲異常階段,捕獲到的是第二個 reject 內容,也就是第一次出現的 reject 的那個地方。

所以,一般來說,Promise.all 用來處理多個併發請求,也是為了頁面數據構造的方便,將一個頁面所用到的在不同接口的數據一起請求過來,不過,如果其中一個接口失敗了,多個請求也就失敗了,頁面可能啥也出不來,這就看當前頁面的耦合程度了~

race

當 iterable 參數裡的任意一個子 promise 被成功或失敗後,父 promise 馬上也會用子 promise 的成功返回值或失敗詳情作為參數調用父 promise 綁定的相應句柄,並返回該 promise 對象。

這個 API 講道理,不經常使用,但是在某些場景下,還是特別給力的。怎麼說的,字面意義就是競賽,想象一個場景,用戶登錄和取消,登錄過程是一個請求過程,會耗時,假設我這邊點擊登錄之後,數據請求過程中點擊了取消,那麼如果登錄還未響應回來,應該就是取消這個行為贏得了競爭,就不登錄了。

當然,登錄取消這個場景我沒有實際使用過,我只在一個地方用到過 Promise.race —— fetch timeout,眾所周知,前端如果使用 fetch 請求的時候,沒辦法設置超時時間,因為 fetch 內部並沒有 timeout 這個參數,那麼如果我們希望前端可以設置超時時間,比如超過5s沒有響應數據的話就認為請求超時了,這個時候可以使用 Promise.race 來幫助我們實現。因為 fetch 本質上也是 Promise,我們只需要在 Promise.race 裡將 fetch 和一個 5s 延時過後 reject/resolve 的 Promise 進行競賽即可。具體代碼如下:

// fetch timeout實現
timeoutPromise = () => new Promise((resolve) => {
setTimeout(() => {
resolve(
new Response(
'Timeout',
{
status: 408,
statusText: "Fetch timeout",
ok: false
}
)
);
}, timeout = 5000);
});
Promise.race([timeoutPromise(), fetch(url, opts)])
.then(res => res.json())
.then(data => {
return data;
});

因為我比較喜歡用 fetch,所以恰好有這個場景的使用,親測可用,具體細節內容大家可以根據自己的項目去修改,這裡不過多介紹,感興趣可以留言交流。

手寫一個 Promise

講到這裡,一定會有人問了,是不是又要手寫一個 Promise 了?當然不會! 我說過了,重在實踐,從實踐角度出發,我覺得並不會有人在項目裡使用自己手寫的 Promise 而是都直接 new Promise(),因此,我再去多此一舉浪費自己和大家的時間去寫一個並不會有人用的 Promise,也沒什麼意義,如果你們想了解內部實現,建議直接去看源碼~

Promise 源碼地址

Async/Await

還得再來一遍,新事物的出現就代表著老的事物不能滿足我們的需求,ES6 剛出 Promise 來解決異步問題,ES7 就又出了一個 Async/Await(其實官方名字是 async function),看來 Promise 並沒有達到大傢伙的預期,所以官方就又搞了個更為優雅的異步解決方案。

為什麼說它是為了解決 Promise 帶來的問題,可以看看 MDN 官網的下面這段話:

async/await 的目的是簡化使用多個 promise 時的同步行為,並對一組 Promises 執行某些操作。正如 Promises 類似於結構化回調,async/await 更像結合了 generators 和 promises。

Promise 並不是完美的解決方案

上面提到的那個異步嵌套 setTimeout的例子來說,事實上,大部分人用 Promise 應該並不會像上面的代碼那樣寫,而是下面這樣:

/* Async/Await */
new Promise((resolve, reject) => {
setTimeout(() => resolve(111), 1000);
}).then(data => {
console.log(data);
new Promise((resolve, reject) => {
setTimeout(() => resolve(222), 2000)
}).then(data => {
console.log(data);
new Promise((resolve, reject) => {
setTimeout(() => resolve(333), 3000)
}).then(data => {
console.log(data);
new Promise((resolve, reject) => {
setTimeout(() => resolve(444), 4000)
}).then(data => {
console.log(data);
})
})
})
});

嗯,說實話,其實 Promise.then() 如果使用過多,依然還是回調地獄,嵌套依然沒有消失,所以來說,Promise 並不能稱之為完美的異步方案,因此,ES7 提出了 async function,它用來更為優雅的解決異步。我們這次就來看看它的魅力:

  // 定時器嵌套
function f1() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(111), 1000);
}).then(data => console.log(data));
}
function f2() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(222), 2000);
}).then(data => console.log(data));;
}
function f3() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(333), 3000);
}).then(data => console.log(data));;
}
function f4() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(444), 4000);
}).then(data => console.log(data));;
}
async function timeoutFn() {
await f1(); // 開始執行第一個異步函數
await f2(); // 第一個執行完,開始執行第二個異步函數
await f3(); // 第二個執行完,開始執行第三個異步函數
await f4(); // 第三個執行完,開始執行第四個異步函數
}
timeoutFn();
// 數據庫查詢
async function queryData() {
try {
// 第一步,獲取數據
const step1Data = await db.query('SELECT * FROM A WHERE id = 1');
// 第二步,獲取數據
const step2Data = await db.query(`SELECT * FROM B WHERE author = ${step1Data[0].username}`);
console.log(step2Data);
} catch(e) {
throw e;
}
}

看看上面的代碼,多麼的優美,完全的同步流程~稱之為最完美異步解決方案一點也不為過。

async function 基礎

關於 async function,其實並沒有過多的 API,因為它更像是一個高級語法糖,官方文檔給出的也更多都是使用示例。在這裡,其實我們只需要知道並強調一件事 —— await 關鍵字用來暫停等待異步函數的執行結束,如果是 Promise,也就是等待它的 settled 狀態,並且 await 只能出現在 async function 內部,不可單獨使用

示例

官方給出了一個比較有意思的例子:

// 一個1秒的異步函數
var resolveAfter1Second = function() {
console.log("starting fast promise");
return new Promise(resolve => {
setTimeout(function() {
resolve("fast");
console.log("fast promise is done");
}, 1000);
});
};
// 一個2秒的異步函數
var resolveAfter2Seconds = function() {
console.log("starting slow promise");
return new Promise(resolve => {
setTimeout(function() {
resolve("slow");
console.log("slow promise is done");
}, 2000);
});
};
// 下面這種寫法是一起執行異步函數,只不過因為await等待導致輸出有先後
var concurrentStart = async function() {
console.log('==CONCURRENT START with await==');
const slow = resolveAfter2Seconds(); // starts timer immediately
const fast = resolveAfter1Second(); // starts timer immediately
// 1. Execution gets here almost instantly
console.log(await slow); // 2. this runs 2 seconds after 1.
console.log(await fast); // 3. this runs 2 seconds after 1., immediately after 2., since fast is already resolved
}
// 下面這種是標準的等待寫法
var sequentialStart = async function() {
console.log('==SEQUENTIAL START==');
// 1. Execution gets here almost instantly
const slow = await resolveAfter2Seconds();
console.log(slow); // 2. this runs 2 seconds after 1.
const fast = await resolveAfter1Second();
console.log(fast); // 3. this runs 3 seconds after 1.
}

[突破前端面試]——Promise&&Async/Await

具體來說大家可以自己實際體驗一下,第二種沒什麼可說的,想象中就是這個樣子,因為 await 會暫停等待函數執行完之後再向下執行,因此等待時間不會重疊,先等待2秒執行 slow 後再等待1秒執行 fast。

而第一種

const slow = resolveAfter2Seconds();
const fast = resolveAfter1Second();
console.log(await slow);
console.log(await fast);

上面這兩個異步函數因為沒有 await 關鍵字,都是立即執行,因此先輸出promise start,之後,兩個函數延時不同,雖然 slow 先執行,但是是2秒,而 fast 後執行是1秒,先輸出fast done再輸出slow done。最後,await 關鍵字發揮作用,雖然 fast 先執行完,但是你還是要等 await slow 完事之後才能 await fast。

總結

這裡就不給相關面試題了,把背景和基礎內容都瞭解了,API 都知道如何使用了,那麼面試題也就百變不離其宗了,也沒什麼可說的了。寫到此處忽然想起來一個問題,那麼還是說一下吧。setTimeout 和 Promise 都是異步操作,那麼誰更快呢?

function whoFast() {
setTimeout(() => console.log('settimeout'), 0);
new Promise(() => {
console.log('promise');
})
}

[突破前端面試]——Promise&&Async/Await

實踐是檢驗真理的唯一標準,promise 無關順序更快執行,至於原理,大家就去看 js 的 event loop 機制吧,如果感興趣,後續也可以寫~

代碼地址

補充

前面幾篇個人覺得寫得很好的沒啥人看,這一篇感覺也沒寫什麼居然很多人評論,確實沒想到,所以有一些細節並沒有考慮到,😄。在這裡進行補充:

補充一: Promise.allSettled(iterable)

上面提到了,Promise.all([])如果出現異常則會直接返回第一個錯誤,那麼即使有的成功了也不會返回,這樣做有時候會出現問題,一個頁面兩個接口,使用Promise.all()來獲取,如果一個成功一個失敗你至少應該把成功那個展示才對,嗯,所以這時候就用到了Promise.allSettled(),它返回的也是一個對應數組,裡面是對應 Promise 的 setteld 狀態,可能成功,也可能失敗~

const promise1 = Promise.resolve(3);
const promise2 = new Promise((resolve, reject) => setTimeout(reject, 100, 'foo'));
const promises = [promise1, promise2];
Promise.allSettled(promises).
then((results) => results.forEach((result) => console.log(result.status)));
// expected output:
// "fulfilled"
// "rejected"

補充二: await 後面接同步代碼會如何?

[突破前端面試]——Promise&&Async/Await

直接上圖上面說過,await 是等待異步代碼執行結束,後面一般都會跟異步函數,但是如果你就是要跟同步代碼會怎麼樣呢?沒關係,上圖你也能看得出,跟同步代碼,await 同步代碼依然會轉換成 Promise~

參考文章

相關文章

從客戶端角度窺探小程序架構

如何用上webpack/gulp/rollup,搭建一個基於Vue的UI庫腳手架

【學習總結】TCP協議理解

浪到飛起的前端女程序員的2019|年度徵文