iT邦幫忙

2021 iThome 鐵人賽

DAY 16
2
Modern Web

Javascript 從寫對到寫好系列 第 16

Day 16 - Asynchronous 非同步進化順序 - Async/Await

前言

昨天聊了 callback 與 Promise,是如何過關斬將,不斷克服障礙走到 ES6。

然而只要程式規模不斷擴張,就永遠會有更高階的需求產生,讓我們繼續來看,Promise 之後發生了什麼故事吧!

非同步第三步 - async await

ES6 Promise 已經是滿不錯的設計,只是它長得還是太像非同步了(?),如果我有資料仰賴非同步取回,就會變成同步跟非同步兩個大區塊,但 ES7 新登場的 async/await,開始漸漸模糊了同步與非同步的界線。

async/await 是 ES7 版本的一個 Promise 語法糖,透過 asyncawait 兩個關鍵字,可以將原本執行多行 Promise 程式簡化成一行,並且使用方式非常貼近一般的同步程式碼,大幅提高程式的可讀性。

  • async 關鍵字放在 function 的前面,代表「宣告一個非同步的函式
  • await 關鍵字放在呼叫 async function 的前面,代表「呼叫並等待這個非同步函式
  • async function 必須回傳 Promise 物件
  • asyncawait 是成對出現的

async function 其實也是一種物件種類,有興趣可以查看 Mozilla MDN

await 的兩種擺法

// 用 setTimeout 模擬一個要等 3 秒的 Promise
const wait3Seconds = async (x) => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(x);
    }, 3000);
  });
}

// 函式先 await 再賦值
const a = await wait3Seconds(1);
const b = await wait3Seconds(2);
const sum = a + b;
console.log(sum); // 3

// 函式先賦值再 await
const c = wait3Seconds(1);
const d = wait3Seconds(2);
const sum2 = await c + await d;
console.log(sum2); // 3

有注意到上面的例子中,await 的擺放位置不同嗎?可是執行出來的 sum 跟 sum2 結果都一樣耶,所以 await 放哪很重要嗎?

好奇的話不妨複製貼到 console 跑跑看,記得分兩段跑,1~14 行先跑,看完結果再跑 17~20 行。

先公布答案:

第一段要等 6 秒才會有結果,而第二段只要等 3 秒。為什麼呢?

新手如果不熟,不妨這樣記憶:看到 await 代表要等

第一段 (函式先 await 再賦值):

  1. 程式執行到第 11 行,看到 await 所以要等,setTimeout 被啟動開始倒數
  2. (等了 3 秒之後) 賦值給 a
  3. 程式執行到第 12 行,看到 await 所以要等,setTimeout 被啟動開始倒數
  4. (等了 3 秒之後) 賦值給 b
  5. 程式執行到第 13、14 行,相加之後印出

第二段 (函式先賦值再 await):

  1. 程式執行到第 17 行,沒有 await 所以不等,把 Promise 物件賦值給 csetTimeout 被啟動開始倒數
  2. 程式執行到第 18 行,沒有 await 所以不等,把 Promise 物件賦值給 dsetTimeout 被啟動開始倒數
  3. 程式執行到第 19 行,看到 await 所以要等 c 完成
  4. (等了 3 秒之後)
  5. 看到 await 所以要等 d 完成,但剛剛那 3 秒已經讓 d 也完成了,所以相加之後印出

另一個值得思考的點是,因為 await 的順序不同,導致 ab 這兩個變數存的是 number,而 cd 則存了 Promise 物件,有興趣不妨印出來看看。

async await 解決了什麼問題

簡化了 Promise 複雜的結構,讓非同步函式可以變得像是同步一樣,不僅可讀性更高,在 error handling 的方面,也可以使用既有的 try catch 來解決,某種程度上也讓新手更容易上手。

async await 實戰

如同昨天 Promise 的例子,同步/非同步的界線非常鮮明,要改動常常要顧慮這一行到底是同步還非同步,但改用 async/await 之後,看起來全都像是同步程式碼:

const doQueryWallet = async () => {
    // 這裡都跟原本一樣,只是多了 async
};

const doOrder = async () => {
    // 這裡都跟原本一樣,只是多了 async
};

const wallet = await doQueryWallet();
if (wallet.balance > 0) {
    const order = await doOrder();
    if (order.status === 'ok') {
        console.log('下單成功');
    } else {
        console.log('下單失敗');
    }
} else {
    console.log('餘額不足');
}

當然 error handling 的部分,也可以用熟悉的 try catch 包起來:

try {
    const wallet = await doQueryWallet();
    if (wallet.balance > 0) {
        const order = await doOrder();
        if (order.status === 'ok') {
            console.log('下單成功');
        } else {
            console.log('下單失敗');
        }
    } else {
        console.log('餘額不足');
    }
} catch (err) {
    console.error(err);
}

非同步常見的三種順序

以上講的 case,大部分都還是基於「由上而下」的順序,也就是我們很熟悉同步的程式碼順序,一個一個由上而下執行,第二行絕對會等第一行結束才執行。

非同步有以下三種常見的順序,可以透過 async/await 及 Promise 簡單達到以下效果:

  • sequence (序列)
  • parallel (平行)
  • race (競爭)

sequence

這是我們最常見的順序,A 執行完換 B,B執行完換 C,後者不能早於前者,通常是因為後者依賴於前者的資料

比如:一定要先查完帳戶餘額,確定有餘額才能夠下單。

const sequence = async () => {
    const output1 = await a();
    const output2 = await b();
    const output3 = await c();
    return `sequence is done ${output1} ${output2} ${output3}`;
}
成功的情況

a
|--------|
         b
         |--------|
                  c
                  |--------|
                           非同步結束
                           |----------
拒絕的情況

a
|--------|
         b
         |--------X
                  非同步結束(throw error)
                  |--------

parallel

這是一個比較有效率的執行方式,不管順序,把所有非同步程式都撒出去執行,等全部都完成(fulfilled)再一次告訴我。通常代表這幾個非同步函式沒有互相依賴的資料,可以同時執行

比如:查詢店家評論、查詢我的優惠券、查詢帳戶餘額,三者沒有互相依賴的資料,但是會出現在同一個頁面,因此把三個函式都發出去,全部回來再一次做接下來的動作(比如把 loading 圖示關掉)。

const parallel = async () => {
    const [output1, output2, output3] = await Promise.all([a(), b(), c()]);
    return `parallel is done ${output1} ${output2} ${output3}`;
}
成功的情況

a
|-----|
b
|--------------|
c
|--------|
               非同步結束,取得回傳結果
               |----------
拒絕的情況

a
|-----X
b
|--------------|
c
|--------|
      非同步結束,取得回傳錯誤
      |----------

注意,成功與失敗的回傳值是不同的,成功時把結果包成一個 array 回傳,裡頭的順序即 Promise.all 裡面的順序。而拒絕則是任何一個 Promise 拒絕(reject)就會立刻回傳最早拒絕的結果。

注意,Promise 是不可打斷的,發出去的函式跟潑出去的水一樣,唯一能做的,就是忽略回傳值,所以即便上圖 a 最早就被拒絕了,b 跟 c 還是會把他們該做的事情在背景跑完,只是我們不會收到結果就是了。

race

這算是 parallel 的一個變種(?),也是把所有非同步程式都撒出去執行,但就像賽跑一樣,只要任何一個完成(fulfilled)或拒絕(reject),就立刻回傳告訴我,其他未完成的則直接忽略,比較是運用在較特殊的情境。

比如:可以幫 request 設定一個 timeout 的時間限制,放一個 setTimout 10 秒就會 reject 的 Promise 進去 Promise.race,就會強制在 10 秒之內得到結果。

成功的情況

a
|-----|
b
|--------------|
c
|--------|
      非同步結束,取得回傳結果
      |----------
拒絕的情況

a
|-----X
b
|--------------|
c
|--------|
      非同步結束,取得回傳錯誤
      |----------
const race = async () => {
    const output1 = await Promise.race([a(), b(), c()]);
    return `race is done ${output1}`;
}

結語

非同步很複雜,但也就是因為很複雜,可以玩的花樣更多,許多複雜的需求其實都是建構在非同步程式中,否則我們的程式就永遠只能同步一行一行執行,似乎也少了一些醍醐味。

無論如何看待
我終將完成我的
承諾

參考資料

JAVACRIPT.INFO: Promises, async/await
MDN - Promise.all
MDN - Promise.race


上一篇
Day 15 - Asynchronous 非同步進化順序 - Callback 與 Promise
下一篇
Day 17 - Error Handling 錯誤處理
系列文
Javascript 從寫對到寫好30

尚未有邦友留言

立即登入留言