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

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 函式的 resolvereject 的參數名稱可以自定義,但在開發慣例上大多會維持此名稱。

promise 狀態與鏈接方法

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

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

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

鏈接方法有 thencatchfinally

  • then:接受兩個參數 onFulfilledonRejectedonFulfilled 處理 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 物件,可以這樣呼叫 thencatch

// 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 的原因在於可透過不斷鏈接 thencatch 方法來接續進行下一個任務,而不需要用巢狀的 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 設計模式之旅22
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言