今天要介紹的是 Promise 模式,在前端應用中,有很多需要進行非同步處理的場景,最常見的就是向後端發送 API 請求以取得資料,而這種發送請求的任務因為通常無法預期何時執行完畢,為了避免等待時間過長、等待時無法執行其他任務,我們就會用非同步的方式來處理這些任務。
在介紹 Promise 之前,先說明幾個概念。
很常會聽到「JavaScript 是單執行緒的語言,但它支援非同步執行。」這句話,這是因為雖然 JavaScript 本身只能一次執行一件事(單執行緒),但 JavaScript 的執行環境(如瀏覽器或 Node.js)透過特殊的機制來克服單執行緒的限制,讓程式能夠同時處理多個任務。而這個特殊機制就是所謂的事件循環(event loop)的機制,當 JavaScript 遇到非同步任務時,這些任務會被交給瀏覽器或 Node.js 中的相關 API 處理,而不是停留在主執行緒上,這樣就可讓 JavaScript 在主執行緒繼續執行後續任務。
關於事件循環其實很可以再拉一篇說明...,這裡先簡要介紹,事件循環的相關文章很多,推薦讀:JavaScript 中的同步與非同步(上):先成為 callback 大師吧!、請說明瀏覽器中的事件循環 (Event Loop)
有時我們會希望在非同步任務完成後執行其他後續邏輯,這時就可透過 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 是 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 建構函式會接收一個函式 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 物件會處於以下 3 種狀態:
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 的狀態與呼叫流程。
圖 3 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 優於 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.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 呼叫的結果,避免重複請求。
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。
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 的優點如下:
.catch()
來捕捉非同步操作中的錯誤,而不需要在每個回呼中處理錯誤.then()
和 .catch()
的鏈式呼叫,可讓 Promise 使程式碼更具可讀性和結構化,減少嵌套的回呼函式使用 Promise 的缺點如下: