iT邦幫忙

2021 iThome 鐵人賽

DAY 15
1
Modern Web

Javascript 從寫對到寫好系列 第 15

Day 15 - Asynchronous 非同步進化順序 - Callback 與 Promise

前言

在上一篇文章中,我們深入了解 Javascript 非同步的核心,到底在背景做了哪些事,才構成了我們實際看到的畫面。

今天讓我們來了解一些常見的非同步操作,callback、Promise,他們的演化脈絡,以及各自的優缺點吧!

非同步第一步 - Callback Function

中文可以叫做「回呼函式」,基本上就是一個普通的函式,但因為被應用在「非同步結束後才呼叫」,所以多得到一個 callback function 的稱號。

有沒有覺得 callback 這個詞特別眼熟?昨天在討論非同步核心時,有提到一個:

Callback Queue:用來存放從 Web api 過來,準備要進入 Call Stack 的指令

沒錯,就是你想的那樣!

Callback Queue 就是用來讓 callback function 排隊等待的地方。

Callback function 出沒地

callback function 最常見的就是 DOM 事件綁定:

elem.addEventListener('click', callback);

或者計時器:

setTimeout(callback, 1000);

這個東西很常會跟我們在 Day 8 提到的高階函式 HoF(Higher-order function) 搞混。

簡單來說,把一個 function A,傳進另外一個 function B 當參數,等到 B 的事情做完,他會去呼叫並執行那個 A。

我們會稱呼 function A 為 callback function(因為被帶入),而 function B 為 HoF(因為有函式參數帶入)。

所以上面兩個例子中,addEventListenersetTimeout 就是 HoF,而 callback 就是 callback function。

Callback 解決了什麼問題

面對不知道要等多久的事情,與其站在那邊等,不如直接給它一個 function,告訴它事情做完就來執行 function,我就可以去做其他事情了。

生活化來說,就像訂網購一樣,我在網站上下訂商品(執行 function B),但不可能下訂之後就立刻準備好,所以我也同時告訴它,包裹準備好之後,幫我送到我家(執行 function A)。

值得注意的是,我完成下訂後,就可以去澆我的花、追我的劇,不用特地等包裹準備好才跟對方說地址,我可以同時去做我其他事情,包裹就會自動送到了。

寫成簡單的程式碼就像是這樣:

const doOrder = () => {
    console.log('傳送交易資料到主機預計 2 秒...');
    setTimeout(() => {
        console.log('傳送完成,開始配送預計 10 秒...');
        setTimeout(() => {
            console.log('包裹已送達!');
        }, 10000);
    }, 2000);
    
    
};

doOrder();
console.log('訂單送出去了,可以去澆花、追劇囉!');

執行結果

傳送交易資料到主機預計 2 秒...
訂單送出去了,可以去澆花、追劇囉!
// (2秒空檔)
傳送完成,開始配送預計 10 秒...
// (10秒空檔)
包裹已送達!

setTimeout 的第一個參數,就是我們的 callback function,我們沒有被動地等 setTimeout 的秒數跑完,而是主動告訴它下一步是什麼,然後就可以利用時間去做其他事

可以說,callback function 是一個轉被動為主動的姿態啊!

聽起來很棒,對於需要等待的事情,我不用等,直接告訴它下一步要做什麼,這樣我就可以去逍遙了。

Callback Hell

但如果今天有一件事情,我告訴它下一步,還要再告訴它下一步的下一步,以及下一步的下一步的下一步...

我下一步你的下一步!
我 callback 的時候 callback!

延續上一個例子,我需要在下訂單之前,先查詢我的錢包是否有餘額,那就會變這樣:

  1. 先幫我查餘額
  2. 查完餘額幫我下訂單
  3. 下完訂單幫我送過來
const doOrder = () => {
    console.log('查詢錢包預計 3 秒...');
    setTimeout(() => {
        console.log('查詢完成,傳送交易資料到主機預計 2 秒...');
        setTimeout(() => {
            console.log('傳送完成,開始配送預計 10 秒...');
            setTimeout(() => {
                console.log('包裹已送達!');
            }, 10000);
        }, 2000);
    }, 3000);
};

doOrder();
console.log('訂單送出去了,可以去澆花、追劇囉!');

執行結果

查詢錢包預計 3 秒...
訂單送出去了,可以去澆花、追劇囉!
傳送交易資料到主機預計 2 秒...
// (2秒空檔)
傳送完成,開始配送預計 10 秒...
// (10秒空檔)
包裹已送達!

是不是離波動拳愈來愈近了呢?

波動拳

沒錯,callback 本身是很好的方式,但在處理一連串巢狀 callback 時,視覺上容易出錯(這個右括號是哪一層的右括號?),雖然可以用 linter 解決,但要做錯誤處理(error handling)也會更為複雜,每一層的 code 又會增加,最後變成一大坨難以維護的「程式碼群」。

非同步第二步 - Promise

於是 Promise 誕生了,拯救在地獄浮沉的 callback 們。

Promise 已經是 ES6 的標準了,可以想像是一種特別的物件,這種物件是用狀態機的方式,來表達任務執行的狀態,主要有三種狀態:

  • pending
  • fulfilled
  • rejected

一個 Promise 剛建立的時候是 pending,接下來有兩種可能:

  • 成功,執行 resolve 並回傳 result,轉變成fulfilled 狀態,
  • 拒絕,執行 reject 並回傳 error,轉變成 rejected 的狀態。

生活化來說,Promise 就像是在商店街跟店家點餐時,店家不可能一點餐就立刻出餐,但也不好意思讓客人一直站在旁邊等,所以店家會給你這個:

取餐叫號機

取餐叫號機,就像是 Promise,你剛拿到時,餐點還沒好,所以是 pending,當餐點做好時,就會震動閃光(?)轉成 fulfilled 狀態,告訴你該做下一步了。(不過取餐叫號機好像很少有 rejected 狀態?也許是你的餐做失敗了,請回來重新點餐...)

我們可以用 .then() 的方式拿到他的 result,.catch() 抓到他的error:

const doSomethingReturnPromise = () => {
    // 用 setTimeout 來模擬一個非同步的 Promise
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        // 一秒後回傳資料
        resolve({ data: 123 });
      }, 1000);
    })
};

doSomethingReturnPromise()
    .then(function (result) {  
        // ...
    })
    .catch(function (err) {  
        // ...
    });

Promise 解決了什麼問題

可以看到其實 Promise 也運用了一些 callback 進來,但最大的不同是,他的 then()catch() 都可以回傳一個 Promise (可以想像成,每個階段工作人員都會給你一個叫號機),因此可以「串接」下去:

doSomethingReturnPromise()
    .then(function (result) {  
        // ...
        // return 另一個 promise
    })
    .then(function (result) {  
        // ...
        // return 另一個 promise
    })
    .then(function (result) {  
        // ...
    })
    .catch(function (err) {  
        // ...
    });

在 callback hell 遇到的問題,多層的縮排瞬間就被拉成一層,只要每個 .then() 都回傳 Promise,就可以確保 A 完成了做 B,B完成了做 C,你要串幾個都很容易擴充(promise chain)。

而且錯誤處理的部分也簡單了許多,中間不管有幾個 .then(),只要有任何一個 Promise 轉為 rejected,就會進入 .catch(),不用再一個個處理 error,只要處理一次即可。

Promise 實戰

針對剛剛的例子,試著寫寫看比較貼近真實一點的版本。比如我要先取得錢包餘額,確定如果還有餘額,才可以進行下單動作。

const doQueryWallet = () => {
    // 用 setTimeout 模擬從資料庫 IO 
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        // 一秒後回傳資料
        resolve({ balance: 100 });
      }, 1000);
    })
};

const doOrder = () => {
    // 用 setTimeout 模擬資料庫 IO
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        // 一秒後回傳資料
        resolve({ status: 'ok' });
      }, 1000);
    })
};

doQueryWallet()
    .then(function (result) {  
        if (result.balance > 0) {
            return doOrder();
        } else {
            console.log('餘額不足');
            // 往下回傳 pending 的 Promise (類似中斷 Promise)
            return new Promise((resolve, reject) => {})
        }
    })
    .then(function (result) {  
        if (result.status === 'ok') {
            console.log('下單成功');
        } else {
            console.log('下單失敗');
        }
    })
    .catch(function (err) {
        console.error(err);
    });

fetch

ES6 的 fetch 語法是最容易接觸到的 Promise 語法:

fetch('https://api.jokes.one/jod')
    .then(function(response) {
        return response.json();
    })
    .then(function(myJson) {
        console.log(myJson);
    })
    .catch(function(error) {
        console.log(error.message);
    });

(上面這一段真的可以複製貼到 console 執行,每日笑話lol)

結語

非同步系列的演化脈絡,雖然大家可能早就用 async/await 飛來飛去了,但就像我們學習歷史一樣,鑑古知今。

  • 在 ES5 以前是如何使用非同步語法?
  • 遇到了什麼困難?
  • ES6 多了 Promise 又解決了什麼問題?

重點就在於「解決了什麼問題」。

當我們更清楚這些新技術的誕生,不是為了出現而橫空出世,而是為了解決實際 coding 會遇到,可讀性、可維護性的問題,才會對於手上正在用的工具更加清晰。

有感於許多踏入前端領域的人,已經是框架時代的起飛時期,這些人可能不知道 jQuery 有劃時代的意義,只覺得是過時的產物,這樣盲目追求的人,容易不清楚手上工具的優勢與劣勢,簡單的展示頁面也動輒 Angular、React 飛來飛去,其實是很危險的。

希望這幾天的「非同步講古入門」,能夠點出每個工具在那個時代的意義。

一次次的等候
在愈來愈深的洞穴
漸漸迷失了歸途

參考資料

MDN - Promise
MDN - fetch


上一篇
Day 14 - Asynchronous 非同步核心
下一篇
Day 16 - Asynchronous 非同步進化順序 - Async/Await
系列文
Javascript 從寫對到寫好30

1 則留言

1
TD
iT邦新手 4 級 ‧ 2021-10-11 22:47:35

知道技術發展的脈絡也是很重要/有趣的一件事情呢

我要留言

立即登入留言