Logo

新人日誌

首頁關於我部落格專題

新人日誌

Logo

網站會不定期發佈技術筆記、職場心得相關的內容,歡迎關注本站!

網站
首頁關於我部落格專題
部落格
分類系列文

© 新人日誌. All rights reserved. 2020-present.

初學者指南:了解 JavaScript 的 Promise 物件

最後更新:2026年9月14日JavaScript

本文為 AJAX 基本介紹系列文,第 8 篇:

  1. 初學者指南:什麼是HTTP 方法?
  2. 網頁狀態碼(HTTP Status Code)指南:從分類到常見應用
  3. 新手指南:什麼是同步與非同步?
  4. 新手指南:什麼是 AJAX?
  5. 新手指南:什麼是 XMLHttpRequest?
  6. 初學者指南:了解 JavaScript 的 fetch 函數
  7. 深入理解Fetch 函數的第二個參數
  8. JavaScript Promise 是什麼?從 fetch 印出的 pending 開始理解 👈進度
  9. JavaScript 中的await 關鍵字:從fetch 到現代非同步處理
  10. 初學者指南:了解JavaScript 中的Promise 和await
  11. fetch 方法到底取得的東西是什麼?
  12. 新手指南:什麼是 JSON?

你可能已經照著範例用 fetch 拿過資料,也寫過 .then(),但心裡一直有幾個問號。

為什麼 fetch 不直接把資料給你,要多包一層?

.then() 裡面那個函式,到底是誰在什麼時候去執行它的?

為什麼有時候看到 .catch(),有時候又看到 .then() 裡面塞兩個參數?

這幾個問題的答案都指向同一個東西:Promise。

同步與非同步

在看 Promise 之前,要先分清楚同步(synchronous)與非同步(asynchronous)這兩種執行方式。

同步操作

同步操作指的是執行某個任務時,必須等這個任務完成,才能繼續執行後面的程式碼。

也就是說,任務是依序排隊執行的。

console.log("任務1");
console.log("任務2");
console.log("任務3");

這三行會照著寫的順序印出「任務1」「任務2」「任務3」,上一行沒跑完,下一行不會開始。

非同步操作

非同步操作則是:某個任務啟動之後,不必等它完成,後面的程式碼就可以先跑。

console.log("任務1");

setTimeout(() => {
  console.log("任務2");
}, 2000);

console.log("任務3");

這段程式碼印出來的順序是「任務1」「任務3」「任務2」。

setTimeout 是一個非同步函式,執行到它的時候,JavaScript 引擎不會停下來等那兩秒,而是直接往下跑 console.log("任務3")。

兩秒之後,才輪到 setTimeout 裡面的函式執行,印出「任務2」。

為什麼需要非同步操作?

很多操作本質上就是要等的,如果每次都停下來等,整個網頁會卡住不能動。

  • 網路請求:跟伺服器要資料可能要好幾秒,非同步讓你在等的同時繼續做別的事。
  • 計時器:像 setTimeout 和 setInterval,指定時間到才執行,中間不擋住其他程式碼。
  • 檔案操作:讀寫檔案需要時間,非同步讓你不必卡在那裡等。

回調地獄:Promise 出現之前的寫法

非同步很好用,但它帶來一個新問題:如果任務二必須等任務一跑完才能開始,你要怎麼表達這個先後關係?

在 Promise 出現之前,唯一的答案藏在一段你上一節就寫過的程式碼裡。

什麼是回調?

回頭看前面那個 setTimeout:

setTimeout(() => {
  console.log("任務2");
}, 2000);

這樣寫看起來像是「那段程式碼寫在 setTimeout 裡面」,不太容易看出發生了什麼事。

我們把那個函式先取個名字,拉到外面:

function task2() {
  console.log("任務2");
}

setTimeout(task2, 2000);

現在看最後一行。task2 後面沒有括號。

有沒有那對括號,差別很大:

  • task2() 的意思是「現在就執行這個函式」。
  • task2 的意思是「這個函式本身」,跟數字 5 或字串 "hello" 一樣,就只是一個值。

所以 setTimeout(task2, 2000) 這一行並不會執行 task2,它做的事是把 task2 這個函式當成值交給 setTimeout,然後這一行就結束了。

兩秒之後,換 setTimeout 動手:它把手上保管的那個函式補上括號,替你執行 task2(),「任務2」這時候才被印出來。

所以函式最後確實有跑,只是執行它的人不是你,是 setTimeout,而且時間點也由 setTimeout 決定。

回到一開始的箭頭函式寫法,它做的是同一件事,只是省略了取名字這一步,直接把函式寫在參數的位置上。

像這樣交給別人保管、由別人決定什麼時候執行的函式,就叫做回調(callback)。

非同步操作幾乎都得這樣寫,因為它們的本質就是「現在還不能做,等一下才能做」。

你沒辦法在程式碼裡直接寫下那一行然後期待它等一下才跑,只能先包成函式交出去,讓負責等待的那一方在對的時機幫你執行。

為什麼每個任務都要多一個參數?

先只看一個任務。

假設你把任務一包成函式,然後想在它跑完之後接著做別的事:

❌ 直覺但行不通的寫法:

function firstTask() {
  setTimeout(() => {
    console.log("完成任務1");
  }, 1000);
}

firstTask();
console.log("任務1 後面要做的事");

執行結果是「任務1 後面要做的事」先印出來,一秒後才印「完成任務1」,順序整個反過來。

原因你已經知道了:setTimeout 是非同步的,firstTask() 這一行不會等,它啟動計時器之後立刻就結束了。

從外面看,firstTask() 執行完的那一刻,任務一其實還沒開始跑。

所以你不能站在外面等它,只能把要做的事送進去。

✅ 用回調的寫法:

function firstTask(callback) {
  setTimeout(() => {
    console.log("完成任務1");
    callback();
  }, 1000);
}

firstTask(() => {
  console.log("任務1 後面要做的事");
});

firstTask 多了一個參數 callback,這個參數就是「任務一做完之後要做什麼」。

firstTask 自己不知道那是什麼,也不需要知道,它只負責在對的時機執行它。

注意 callback() 這一行有括號。這裡就是真正執行的時刻——firstTask 現在扮演的角色,跟前面的 setTimeout 一模一樣:手上保管著一個函式,時間到了才補上括號執行。

最下面呼叫的時候,你傳進去的那個箭頭函式就是 callback 收到的東西。

順帶一提,這段程式碼裡有兩個回調:交給 setTimeout 的箭頭函式,以及交給 firstTask 的箭頭函式。它們是兩層不同的委託,只是剛好長得很像。

回調地獄長什麼樣子

一個任務長這樣還算清楚。

現在把三個任務串起來,每個都必須等前一個完成才能開始。

三個任務函式本身沒什麼好說的,就是同一段程式碼複製三份,只差印出來的字:

function firstTask(callback) {
  setTimeout(() => {
    console.log("完成任務1");
    callback();
  }, 1000);
}

function secondTask(callback) {
  setTimeout(() => {
    console.log("完成任務2");
    callback();
  }, 1000);
}

function thirdTask(callback) {
  setTimeout(() => {
    console.log("完成任務3");
    callback();
  }, 1000);
}

麻煩的是怎麼把它們串起來。

先看一個比較囉唆、但很好讀的寫法:把每一步做完之後要做的事,各自取一個名字。

function runTask2() {
  secondTask(runTask3);
}

function runTask3() {
  thirdTask(done);
}

function done() {
  console.log("所有任務完成");
}

firstTask(runTask2);

這段程式碼要從最後一行開始讀,因為那才是起點,前面三個函式在被交出去之前都只是躺著不動。

firstTask(runTask2) 的意思是:跑任務一,做完之後跑 runTask2。

那 runTask2 又是什麼?往上看它的定義:跑任務二,做完之後跑 runTask3。

runTask3 呢?跑任務三,做完之後跑 done。

done 最單純,就是印出「所有任務完成」。

實際執行的時候,順序會像這樣:

每一步都是同一個動作重複三次:執行手上保管的回調,啟動下一個任務,再把下一步交出去。

注意這四個函式名字後面全都沒有括號,它們都是被交出去保管的回調。

問題是實務上沒有人會這樣寫。

runTask2、runTask3、done 這些名字本身沒有帶來任何資訊,取名字唯一的目的只是為了把函式擺到別的地方去。

既然名字沒用,那就不要取了,直接把函式寫在參數的位置上:

firstTask(() => {
  secondTask(() => {
    thirdTask(() => {
      console.log("所有任務完成");
    });
  });
});

這段跟上面那段做的事一模一樣,只是把 runTask2 的內容原地展開、塞進 firstTask( 和 ) 中間,runTask3 和 done 也照做。

原本三個平放的函式,現在變成一個包一個。

讀這種程式碼有個訣竅:只看每一層的第一行。

由上往下是 firstTask、secondTask、thirdTask、印出「所有任務完成」,這就是執行順序,中間的縮排可以先不管。

至於最後那三行 });,是由內往外收尾:最上面那個關掉 thirdTask 的呼叫,中間那個關掉 secondTask,最下面那個關掉 firstTask。

問題就在這裡:每多一個任務,就多一層縮排。

而且縮排增加的原因很無奈:你要在任務一做完之後跑任務二,但「任務一做完」這件事只有 firstTask 的回調知道,所以 secondTask 只能寫在裡面。同樣的道理,thirdTask 只能再往裡面塞一層。

三個原本平起平坐、只是有先後順序的步驟,就這樣被寫成了三層巢狀結構。

把實際發生的順序畫出來,它就是一條直線:零秒開始任務一,一秒開始任務二,兩秒開始任務三,三秒全部完成。

但寫成程式碼之後,這條直線變成了一座往右下走的樓梯。

這就是回調地獄真正麻煩的地方——你腦中想的是一連串先後發生的事,程式碼卻長成另一種形狀,兩者對不起來。

而這還是最順利的情況。如果每個任務都要處理失敗,你得在每一層各寫一次錯誤處理,因為外層的程式碼看不到內層發生了什麼事。

這種因為回調層層嵌套而失控的寫法,就叫做回調地獄(Callback Hell)。

延伸閱讀:回調地獄範例程式碼解析

Promise 是什麼?

先看一個畫面。

fetch 是 JavaScript 內建的函式,功能是跟指定的網址要資料。

它是非同步的,資料要等一小段時間才會回來,所以它正好可以拿來觀察非同步的行為。

現在直接把 fetch 回傳的東西印出來:

console.log(fetch("https://jsonplaceholder.typicode.com/users/1"));
// Promise { <pending> }

資料明明還沒回來,fetch 卻已經回傳東西給你了。

而且那個東西不是資料,是一個叫做 Promise 的物件,後面還跟著 <pending>。

延伸閱讀:初學者指南:了解 JavaScript 的 fetch 函數

這就是 Promise:一個非同步操作立刻回傳給你的物件,代替那個還沒生出來的結果。

它身上有兩樣東西。

一個是結果欄位,現在還空著,等非同步操作跑完才會被填進去。

另一個是待辦清單,記錄著「結果填進來之後要做什麼」。你用 .then() 把函式放進這份清單,等結果到了,JavaScript 會自己把清單上的函式拿出來執行,並且把結果當成參數傳進去。

<pending> 就是在告訴你:結果欄位現在還是空的。

Promise 的三種狀態

pending 只是三種狀態的其中一種。

  • Pending(等待中):結果欄位還空著,非同步操作還沒跑完。
  • Fulfilled(已成功):操作成功了,結果欄位裡放著一個值。
  • Rejected(已失敗):操作失敗了,結果欄位裡放著失敗的原因。

這裡有一件最容易被忽略、但後面所有行為都建立在它上面的事:狀態只會離開 Pending 一次,而且不會再變回去。

一個 Promise 從 Pending 變成 Fulfilled 之後,就永遠是 Fulfilled,不會再轉成 Rejected,也不會退回 Pending。

因為狀態只會二選一,所以掛在同一個 Promise 上的 .then() 和 .catch(),永遠只會有一邊被執行。

如何建立一個 Promise?

平常你比較常遇到的是別人給你的 Promise,例如 fetch() 回傳的那個。

但自己動手做一個,是理解它怎麼運作最快的方式。

const myPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve("操作成功!");
    } else {
      reject("操作失敗!");
    }
  }, 2000);
});

這段程式碼在做的事是:建立一個 Promise,並且用 setTimeout 假裝有一個要跑兩秒的非同步操作。

先看 new Promise() 的參數,它是一個函式。JavaScript 會在你建立 Promise 的當下馬上執行這個函式,所以你的非同步操作要寫在裡面。

再看這個函式自己的兩個參數 resolve 和 reject。

你可能會覺得奇怪:這兩個名字我從來沒有宣告過,為什麼可以直接拿來用?

因為它們不用你定義,是 JavaScript 準備好塞給你的兩個函式。

resolve 的作用是把狀態從 Pending 推到 Fulfilled,你傳給它的東西會變成結果欄位裡的值。

reject 則是把狀態推到 Rejected,你傳給它的東西會變成失敗的原因。

所以上面那段程式碼的意思是:兩秒後,如果 success 是 true 就呼叫 resolve,把這個 Promise 標記成功;否則呼叫 reject,標記失敗。

在你呼叫其中一個之前,這個 Promise 會一直停在 Pending。

用 .then() 拿到結果

Promise 建立好了,接下來的問題是:結果來了,你要在哪裡接?

答案就是 .then()。

你把「結果來的時候要做什麼」寫成一個函式交給它,等狀態變成 Fulfilled,JavaScript 會執行這個函式,並且把結果欄位裡的值當成參數傳進去。

myPromise.then((successMessage) => {
  console.log(successMessage); // 印出 "操作成功!"
});

這裡的 successMessage 就是前面呼叫 resolve("操作成功!") 時傳進去的那個字串。

參數名稱可以隨便取,重點是它接到的一定是 resolve 傳出來的東西。

處理失敗的情況

.then() 其實可以吃兩個參數。

promise.then(onFulfilled, onRejected);
  • onFulfilled(可選):狀態變成 Fulfilled 時要執行的函式。
  • onRejected(可選):狀態變成 Rejected 時要執行的函式。

所以要處理失敗,你有兩種寫法。

第一種是把失敗的處理函式塞進 .then() 的第二個參數:

const myPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    reject("操作失敗!");
  }, 1000);
});

myPromise.then(
  (successMessage) => {
    console.log(successMessage);
  },
  (errorMessage) => {
    console.error(errorMessage); // 印出 "操作失敗!"
  }
);

第二種是用 .catch():

myPromise
  .then((successMessage) => {
    console.log(successMessage);
  })
  .catch((errorMessage) => {
    console.error(errorMessage); // 印出 "操作失敗!"
  });

兩種寫法在這個例子裡結果一樣,但實務上大多會用 .catch()。

原因在下一節串起多個 .then() 的時候會看得很清楚:.catch() 可以一次接住整條鏈上的錯誤,而 .then() 的第二個參數只管得到它前面那一個。

錯誤處理

.then() 裡面丟出來的錯誤,也會被後面的 .catch() 接住。

const myPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("操作成功!");
  }, 1000);
});

myPromise
  .then((successMessage) => {
    console.log(successMessage);
    throw new Error("出現錯誤");
  })
  .then((message) => {
    console.log(message); // 不會執行
  })
  .catch((error) => {
    console.error(error); // 印出 "出現錯誤"
  });

這個 Promise 本身是成功的,第一個 .then() 正常執行、印出「操作成功!」,然後在裡面丟出一個錯誤。

錯誤一旦丟出來,中間的 .then() 就會被整個跳過,直接交給最後的 .catch()。

Promise 如何解決回調地獄

回頭看前面那段三層巢狀的程式碼。

.then() 有一個關鍵特性:它會回傳一個新的 Promise。

既然回傳的還是 Promise,那就可以再接一個 .then(),接完再接一個。原本要靠巢狀表達的先後順序,就變成一條由上往下的直線。

❌ 用回調的寫法:

firstTask(() => {
  secondTask(() => {
    thirdTask(() => {
      console.log("所有任務完成");
    });
  });
});

任務之間的先後關係是用縮排層級表達的,多一個任務就多一層縮排。

✅ 用 Promise 的寫法:

const firstTask = new Promise((resolve) => {
  setTimeout(() => {
    resolve("完成任務1");
  }, 1000);
});

const secondTask = (message) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(message + " -> 完成任務2");
    }, 1000);
  });
};

const thirdTask = (message) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(message + " -> 完成任務3");
    }, 1000);
  });
};

firstTask
  .then((message) => {
    console.log(message); // 印出 "完成任務1"
    return secondTask(message);
  })
  .then((message) => {
    console.log(message); // 印出 "完成任務1 -> 完成任務2"
    return thirdTask(message);
  })
  .then((message) => {
    console.log(message); // 印出 "完成任務1 -> 完成任務2 -> 完成任務3"
  })
  .catch((error) => {
    console.error(error);
  });

任務之間的先後關係變成由上往下讀,多一個任務就多一層 .then(),縮排不會增加。

這裡有一個容易漏掉的細節:每個 .then() 裡面都要 return。

當你在 .then() 裡面回傳一個 Promise,JavaScript 會等那個 Promise 有結果之後,才把結果傳給下一個 .then()。如果忘了 return,下一個 .then() 拿到的就是 undefined,而且不會等。

錯誤處理也一起簡化了。整條鏈上任何一個環節出錯,都會直接跳到最後那個 .catch(),不用像回調那樣每一層都寫一次。

.finally():不管成功失敗都要做的事

有些事情不管結果如何都得做,例如把載入中的轉圈圈關掉。

如果只有 .then() 和 .catch(),你得在兩邊各寫一次。

.finally() 就是為了這種情況存在的,它在狀態變成 Fulfilled 或 Rejected 之後都會執行。

fetchUserData(1)
  .then((userData) => {
    console.log(userData.name);
  })
  .catch((error) => {
    console.error(error);
  })
  .finally(() => {
    console.log("不管成功還是失敗,這行都會執行");
  });

跟前兩者不同的是,.finally() 的函式不接收任何參數。

因為它同時服務成功和失敗兩條路,沒辦法確定該給它值還是給它原因,乾脆兩個都不給。

實際應用:跟伺服器要資料

把前面的東西湊起來,看一個接近真實情況的例子。

function fetchUserData(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const success = true;
      if (success) {
        resolve({ id: userId, name: "John Doe" });
      } else {
        reject("無法取得使用者資料");
      }
    }, 3000);
  });
}

fetchUserData(1)
  .then((userData) => {
    console.log(`使用者ID: ${userData.id}, 使用者名稱: ${userData.name}`);
  })
  .catch((error) => {
    console.error(error);
  });

fetchUserData 這個函式本身不回傳資料,它回傳一個 Promise。

呼叫它的當下你什麼都拿不到,只拿到一張還在 Pending 的 Promise,三秒後 resolve 被呼叫,狀態變成 Fulfilled,.then() 才會拿到那個物件。

這也正是 fetch() 的運作方式。現在回頭看文章開頭那個 Promise { <pending> },應該就不奇怪了。

重點整理

回頭把整條線再走一次。

非同步操作沒辦法當場給你結果,所以在 Promise 出現之前,你只能把「結果來了要做什麼」包成函式交出去,讓別人在對的時機幫你執行,這就是回調。

一個任務這樣寫沒什麼問題,但只要任務之間有先後關係,下一步就得塞進上一步的回調裡。

三個任務疊成三層,程式碼的形狀跟實際發生的順序完全對不上,這就是回調地獄。

Promise 換了一個思路:非同步操作不再要求你先交出回調,而是立刻回傳一個物件給你。

這個物件身上有一個還空著的結果欄位,以及一份待辦清單,你想做的事用 .then() 放進清單,等結果填進來,JavaScript 會自己拿出來執行。

它的狀態一開始是 Pending,之後只會變成 Fulfilled 或 Rejected 其中一種,而且變了就不會再動。

正因為不可逆,掛在同一個 Promise 上的 .then() 和 .catch() 才永遠只有一邊會執行。

如果這個 Promise 是你用 new Promise() 自己建的,決定往哪邊走的是 resolve 和 reject——這兩個函式不用你定義,是 JavaScript 塞給你的,你呼叫哪一個就是哪一個。

拿結果的三個方法分工很清楚:.then() 接成功的值,.catch() 接失敗的原因,.finally() 兩種情況都會跑,也因為它同時服務兩邊,所以不收任何參數。

而真正把回調地獄解決掉的,是 .then() 會回傳一個新的 Promise 這件事。

回傳的還是 Promise,就還能再接一個 .then(),於是原本往右長的巢狀結構被攤平成一條由上往下的直線。

唯一要記得的是,在 .then() 裡面回傳 Promise 的時候別忘了寫 return,忘了的話下一個 .then() 拿到的會是 undefined,而且不會等。

目前還沒有留言,成為第一個留言的人吧!

發表留言

留言將在審核後顯示。

JavaScript

目錄

  • 同步與非同步
  • 同步操作
  • 非同步操作
  • 為什麼需要非同步操作?
  • 回調地獄:Promise 出現之前的寫法
  • 什麼是回調?
  • 為什麼每個任務都要多一個參數?
  • 回調地獄長什麼樣子
  • Promise 是什麼?
  • Promise 的三種狀態
  • 如何建立一個 Promise?
  • 用 .then() 拿到結果
  • 處理失敗的情況
  • 錯誤處理
  • Promise 如何解決回調地獄
  • .finally():不管成功失敗都要做的事
  • 實際應用:跟伺服器要資料
  • 重點整理