iT邦幫忙

2024 iThome 鐵人賽

DAY 17
0

https://ithelp.ithome.com.tw/upload/images/20241001/20168201d8jZjx1t8Q.png

今天要介紹的是 Promise 模式,在前端應用中,有很多需要進行非同步處理的場景,最常見的就是向後端發送 API 請求以取得資料,而這種發送請求的任務因為通常無法預期何時執行完畢,為了避免等待時間過長、等待時無法執行其他任務,我們就會用非同步的方式來處理這些任務。

在介紹 Promise 之前,先說明幾個概念。

  • 單執行緒(single threaded):指的是一個程序一次只能處理一個任務。換句話說,程序中只有一條執行緒在運行代碼,所有的任務都必須排隊,按照先後順序執行
    • JavaScript 是單執行緒的程式語言,它一次只能執行一個指令
  • 同步執行(synchronous execution):指任務按照順序逐個執行,目前的任務完成後才會執行下一個任務。在執行同步任務時,如果一個任務要執行較長時間,整個程式會暫停,直到該任務完成為止
    • 可以把同步執行想成它是以阻擋(blocking)的方式執行的,只有在目前程式碼完成後,才會執行下一行
    • 示意圖如下,可以看出每個任務都要執行完才會執行下一個
      https://ithelp.ithome.com.tw/upload/images/20241001/20168201jyCkj0OArN.jpg
      圖 1 同步執行示意圖(資料來源:自行繪製)
  • 非同步執行(asynchronous Execution):允許程式在等待某操作(例如:I/O 操作或網路請求)完成時,不必阻塞執行緒,會將這些操作切換為在背景執行它,然後將控制權交給下一行,繼續處理其他任務。
    • 可以把非同步想成非阻塞(non-blocking)的方式執行,目前程式碼如果要執行很久,也不會阻塞後面程式碼的執行,執行時不會卡住
    • 示意圖如下,可以看出 C 和 E 任務會在背後執行,而不會阻塞後面程式碼
      https://ithelp.ithome.com.tw/upload/images/20241001/20168201OkI6tjElUZ.jpg
      圖 2 非同步執行示意圖(資料來源:自行繪製)

很常會聽到「JavaScript 是單執行緒的語言,但它支援非同步執行。」這句話,這是因為雖然 JavaScript 本身只能一次執行一件事(單執行緒),但 JavaScript 的執行環境(如瀏覽器或 Node.js)透過特殊的機制來克服單執行緒的限制,讓程式能夠同時處理多個任務。而這個特殊機制就是所謂的事件循環(event loop)的機制,當 JavaScript 遇到非同步任務時,這些任務會被交給瀏覽器或 Node.js 中的相關 API 處理,而不是停留在主執行緒上,這樣就可讓 JavaScript 在主執行緒繼續執行後續任務。
關於事件循環其實很可以再拉一篇說明...,這裡先簡要介紹,事件循環的相關文章很多,推薦讀:JavaScript 中的同步與非同步(上):先成為 callback 大師吧!、請說明瀏覽器中的事件循環 (Event Loop)

沒有 Promise 之前:Callback 函式(回呼函式)

有時我們會希望在非同步任務完成後執行其他後續邏輯,這時就可透過 callback 的方式來定義非同步任務完成後要做的事,範例如下。
我們將 callback 作為參數傳給 fetchData,非同步的請求任務完成後,會呼叫參數傳入的 callback 函式,處理後續的邏輯。

// Callback
function fetchData(url, callback) {
    fetch(url)
        .then(response => response.json())
        .then(data => callback(null, data))
        .catch(error => callback(error));
}

fetchData('http://example.com', (error, data) => {
    if (error) {
        console.error(error);
    } else {
        console.log(data);
    }
});

然而,使用 callback 處理非同步可能會導致回呼地獄(callback hell),在程式碼形成巢狀的 callback,例如以下範例。
層層嵌套的程式碼會讓開發者很難看出整段程式碼的邏輯和流程,降低了可讀性和可維護性。

function firstRequest(url, callback) {
    // 發出網路請求
    callback(null, response);
}

function secondRequest(url, callback) {
    // 發出網路請求
    callback(null, response);
}

function thirdRequest(url, callback) {
    // 發出網路請求
    callback(null, response);
}

function fourthRequest(url, callback) {
    // 發出網路請求
    callback(null, response);
}

firstRequest('https://api.example.com/first', (err, response) => {
    if (err) {
        console.error('Error in first request:', err);
        return;
    }
    secondRequest('https://api.example.com/second', (err, response) => {
        if (err) {
            console.error('Error in second request:', err);
            return;
        }
        thirdRequest('https://api.example.com/third', (err, response) => {
            if (err) {
                console.error('Error in third request:', err);
                return;
            }
            fourthRequest('https://api.example.com/fourth', (err, response) => {
                if (err) {
                    console.error('Error in fourth request:', err);
                    return;
                }
                console.log('All requests completed');
            });
        });
    });
});

Promise 簡介

Promise 是 JavaScript ES6 推出的語法,是一種更現代化處理 JavaScript 非同步運算的方式。
Promise 是一個建構函式的語法,首先,因為建構函式也是一個物件,Promise 這個物件本身會有一些 JavaScript 定義好的方法,例如:Promise.all、Promise.race 等,完整請見官方文件。

// Promise 作為一個物件,擁有的靜態方法如:
Promise.all()
Promise.race()
Promise.reject()
Promise.resolve()

其次,我們可以用 new 關鍵字來建立一個 promise 物件實例,而這個建立出來的物件實例則可以用 Promise 的原型方法,例如 then、finally、catch,完整介紹請見官方文件。

const promiseInstance = new Promise();

// 用 Promise 建立出的物件實例,可使用的原型方法:
promiseInstance.then();    
promiseInstance.catch();   
promiseInstance.finally();

建立一個 promise 物件

要如何建立一個 promise 呢? Promise 建構函式會接收一個函式 executor 作為參數,這個 executor 會接收 resolve 和 reject 兩個參數,且 executor 函式內的程式碼會被立即執行(executor 函式會在 Promise 建構式回傳 Promise 物件前被執行),在 executor 函式內可以放我們想執行的任務邏輯(通常是非同步任務,例如網路請求、setTimeout),並在這個任務成功後呼叫 resolve,或是在這個任務失敗時呼叫 reject,來表示這個任務成功還是失敗。resolve 和 reject 就是用來分別表示成功和失敗的結果,兩個只有其中一個能被執行,執行 resolve() 後就無法執行 reject(),反之亦然。
而為何要用 resolve() 或 reject() 呢?這是用來告訴 promise 我們的操作已經完成、結束了(不管成功或失敗),這樣 promise 物件就可根據完成的結果來呼叫對應的後續邏輯,所謂「後續邏輯」就是 promise 物件可用 then 或 catch 方法來接收 resolve 或 reject 傳送的成功或失敗結果。

const myPromise = new Promise((resolve, reject) => { // Promise 接收 executor 函式參數
  console.log('create new Promise'); // 立即執行
  // 一些非同步操作
  
  if(success){ // 如果操作成功就呼叫 resolve,傳入成功的結果值
      resolve(data);
  }else{ // 如果操作失敗就呼叫 reject,傳入失敗的結果值
      reject(error);
  }
});

// 接下來可用 myPromise 物件操作,呼叫鏈接方法 then、catch 來處理成功或失敗的結果

executor 函式的 resolve 及 reject 的參數名稱可以自定義,但在開發慣例上大多會維持此名稱。

promise 狀態與鏈接方法

一個 promise 物件會處於以下 3 種狀態:

  • pending:等待中,屬於未確認的狀態
  • fulfilled(或稱 resolved):操作成功,屬於已確認(settled)的狀態
  • rejected: 操作失敗,屬於已確認(settled)的狀態

promise 初始狀態都是 pending,當我們呼叫 resolve() 時,狀態就會變成 fulfilled,而如果呼叫 reject(),狀態就會變成 rejected。當 promise 進入到 fulfilled 或 rejected 狀態時,就算作這個任務已完成,狀態不會再改變。當狀態轉換發生時(pending 變成 fulfilled,或 pending 變成 rejected),那些鏈接的方法(then、catch、finally)就會被立即呼叫。

鏈接方法有 then、catch和finally:

  • then:接受兩個參數 onFulfilled 和 onRejected,onFulfilled 處理 fulfilled 時的邏輯,onRejected 處理 rejected 時的邏輯。onFulfilled 收到的參數是 resolve() 傳入的參數,onRejected 收到的參數是 reject() 傳入的參數。另外,onFulfilled 參數是可選的,非必填參數。
    const onFulfilled = (fulfilledValue) => { ... };
    const onRejected = (rejectedValue) => { ... };
    
    promiseInstance.then(onFulfilled, onRejected);
    
  • catch:接受一個參數 onRejected,用來處理 rejected 時的邏輯。onRejected 收到的參數是 reject() 傳入的參數。
    const onRejected = (rejectedValue) => { ... };
    
    promiseInstance.catch(onRejected);
    
  • finally:接受一個參數 onFinally,用來處理 promise 已確認時的邏輯,也就是不管 promise fulfilled 或是 rejected,都會執行此 onFinally 函式。onFinally 函式不會接收任何參數,因為無法確認這個 promise 是成功還是失敗,它的適用情境是當你不關心失敗的原因或成功的值時。
    const onFinally = () => { ... };
    
    promiseInstance.finally(onFinally);
    

通常我們會用 then 來處理成功時的邏輯,用 catch 來處理失敗時的邏輯。
接續上面我們建立的 myPromise 物件,可以這樣呼叫 then 和 catch:

// const myPromise = new Promise(...)

myPromise.then((data) => {
  // 在 myPromise 被 resolve 時執行,會收到 resolve() 傳入的參數 data
  console.log('data is ' + data);
});

myPromise.catch((error) => {
  // 在 myPromise 被 reject 時執行,會收到 reject() 傳入的參數 error
  console.log('error', error);
});

以下為 Promise 示意圖,示意 Promise 的狀態與呼叫流程。
https://ithelp.ithome.com.tw/upload/images/20241001/20168201ur2mXh0zyd.jpg
圖 3 Promise 示意圖(資料來源:自行繪製)

Promise 基本範例

以下是一個網路請求的 Promise 範例:

function fetchData(url) {
    return new Promise((resolve, reject) => {
        fetch(url)
            .then(response => response.json())
            .then(data => resolve(data)) // 成功取得資料,resolve Promise,此時 promise 狀態會從 pending 轉為 fulfilled
            .catch(error => reject(error)); // 發生錯誤,reject Promise,此時 promise 狀態會從 pending 轉為 rejected
    });
}

fetchData('http://example.com')
    .then(data => console.log(data)) // then 會在 fetchData 這個 promise 呼叫 resolve 時執行,then 收到的 data 就是 resolve 傳入的 data
    .catch(error => console.error(error)); // catch 會在 fetchData 這個 promise 呼叫 reject 時執行,catch 收到的 error 就是 reject 傳入的 error

Promise 應用

Promise 鏈接(Promise Chaining)

Promise 優於 callback 的原因在於可透過不斷鏈接 then、catch 方法來接續進行下一個任務,而不需要用巢狀的 callback 來撰寫後續任務。
在 then 的函式內,可透過 return 的方式來進入下一個 then,而 return 的值就會被下一個 then 所接收。

function fetchData(url) {
    return new Promise((resolve, reject) => {
        fetch(url)
            .then(response => response.json())
            .then(data => resolve(data))
            .catch(error => reject(error));
    });
}

function processData(data) {
    // 處理資料
    return processedData;
}

fetchData(apiUrl)
    .then(data => processData(data)) // 第一個 then return 處理好的資料
    .then(processedData => console.log(processedData)) // 第二個 then 參數收到上一個 then return 的 processedData
    .catch(error => console.log(error));

Promise 平行性

可透過 Promise.all 來同時執行多個 promise,這些非同步任務會同時出發,執行時間最長的完成才算全部 promise 完成。
Promise.all 會接收一個可迭代物件(iterable)作為參數,通常是一個陣列,陣列內的元素都是 promises。Promise.all 會回傳一個 promise 來表示這些 promises 的結果,如果陣列內所有 promises 都 fulfilled,就會執行後續的 then 方法.then 會收到一個陣列作為參數,這個陣列會依照前面呼叫 Promise.all 傳入的 promises 陣列來傳回成功後結果,而不是依照 promise 完成的先後順序。示意如下:

Promise.all([
        promiseTask1,
        promiseTask2
    ]).then(([promiseTask1Val, promiseTask2Val])=>{ // then 會依照 promiseTask1, promiseTask2 的順序回傳成功的值,不論這裡個 promise 誰先完成
    //...
    })

而如果如果陣列內有任一個 promise rejected,就會執行後續的 catch 方法.catch 收到的參數是第一個失敗的那個 promise 透過 reject() 傳遞的值。

以下為完整範例。

function fetchData(url) {
    return new Promise((resolve, reject) => {
        fetch(url)
            .then(response => response.json())
            .then(data => resolve(data))
            .catch(error => reject(error));
    });
}

Promise.all([
        fetchData('http://example.com/1'),
        fetchData('http://example.com/2')
    ]).then(([data1, data2]) => { 
        console.log(data1, data2);
    }).catch(error => {
        console.lor(error);
    });

Promise 快取

可用使用快取來儲存 promise 呼叫的結果,避免重複請求。

const cache = new Map(); // 儲存請求過的結果

function memoizedFetchData(url) {
    if (cache.has(url)) { // 如果請求過此 url,就回傳該請求的結果
        return cache.get(url);
    }

    return new Promise((resolve, reject) => {
        fetch(url)
            .then(response => response.json())
            .then(data => {
                cache.set(url, data); // 將請求成功後的結果存起來
                resolve(data);
            })
            .catch(error => reject(error));
    });
}

// 使用方式:如果請求的 URL 在快取中,就回傳快取資料;否則發出新請求,並把結果快取
memoizedFetchData('http://example.com/')
    .then(data => console.log(data))
    .catch(error => console.erro(error));
    

使用快取要注意的是,如果一直快取,會有後端資料改變但快取內卻沒有改變的狀況,這樣使用者就會拿到舊資料,因此要注意更新快取的邏輯。上面這個範例是用 url 作為快取的 key 值,而像 TanStack Query 就是用 QueryKey 來作為快取的 key,詳細請見 深入淺出 TanStack Query(一):在呼叫 useQuery 後發生了什麼事。

Promise 重試

當 promise 失敗時,可在一定次數內重新執行 promise。

function fetchDataWithRetry(url) {
    let attempts = 0;

    const fetchData = () => new Promise((resolve, reject) => {
        fetch(url)
            .then(response => response.json())
            .then(data => resolve(data))
            .catch(error => reject(error));
    });

    const retry = error => {
        attempts++;
        if (attempts >= 3) { // 重試次數大於 3,就丟出錯誤說失敗
            throw new Error('Request failed after 3 attempts.');
        }
        console.log(`Retrying request: attempt ${attempts}`); 
        return fetchData().catch(retry); // 若重試次數小於 3,就再次執行,並用 catch 處理失敗邏輯,在 catch 內重試
    };

    return fetchData().catch(retry); // 失敗時會執行 retry,在 retry 時又會再呼叫一次 fetchData(如果嘗試次數小於 3)
}

優點

使用 Promise 的優點如下:

  • 可讀性高:相較於傳統的回呼函式,Promise 的寫法更接近同步的程式碼風格,讓程式碼更容易理解
  • 錯誤處理較簡單:Promise 提供統一的錯誤處理方式,可以用 .catch() 來捕捉非同步操作中的錯誤,而不需要在每個回呼中處理錯誤
  • 解決回呼地獄:透過 .then() 和 .catch() 的鏈式呼叫,可讓 Promise 使程式碼更具可讀性和結構化,減少嵌套的回呼函式

缺點

使用 Promise 的缺點如下:

  • 學習曲線較高:對初學者來說,Promise 的語法和運作機制可能需要一點時間和心力才能理解,且有時非同步的流程過於抽象,會讓程式碼的邏輯變得比較不直觀
  • 除錯較難:在使用 Promise 時,特別是當鏈式呼叫方法出現錯誤時,可能會較難追蹤錯誤發生的具體位置

Reference


上一篇
[Day 16] MVC 模式
下一篇
[Day 18] 命名空間化模式
系列文
30天的 JavaScript 設計模式之旅 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言