iT邦幫忙

2026 iThome 鐵人賽

DAY 10
1
JavaScript

30 天新世代 JavaScript 自我學習指南系列 第 10

Day 10|重新看 Promise:它不是非同步魔法,而是結果的狀態容器

  • 分享至 

  • xImage
  •  

摘要

一句摘要:回到 Promise 的核心:executor 同步執行、狀態只能前進,以及 reaction job 如何安排後續通知。

前置知識:理解函式、callback 與 .then() 的基本寫法即可;這篇會從 new Promise() 重新建立運作模型。

學習路線:從 executor 的同步執行、Promise 狀態轉換,一路追到 reaction job 的排程過程。

標籤Promise Microtask Event Loop 語言規格


今日學習目標

在同步程式裡,呼叫函式時通常能在當下取得回傳值。

但網路回應、timer 或 event 的結果不一定已經出現。JavaScript 需要一種穩定的表示方式,記錄「這個結果未來會成功或失敗」。

Promise 就是這個未來結果的表示方式。

也因為初學 JavaScript 時需要用到非同步流程時,都會和 Promise 搭上邊,看著看著很容易被誤解成「幫 JavaScript 執行非同步工作的魔法」,但如果用久了仔細去看會慢慢體會到:

Promise 不負責做網路請求、等待計時器或監聽點擊事件;它負責保存一個未來結果的狀態,並在結果出現後通知等待它的人。

今天就從 new Promise() 重新看一次這個流程,並用 TC39 規範對照幾個關鍵步驟,來探索以前被忽略的語言細節,也會加深自己對 Promise 的感覺。

https://ithelp.ithome.com.tw/upload/images/20260913/20145251QCqQzWZrFs.png


Promise 並不會把程式全部變成非同步

看到 Promise 時,不要直接把它理解成「裡面的程式全部稍後執行」,可ㄧ可以分成三層看:

1. executor 是否立刻執行?
   → 是,同步。

2. 結果何時出現?
   → 看裡面是否呼叫 timer、fetch、event、Worker 等未來事件來源。

3. .then() 後續何時執行?
   → Promise settled 後,後續 reaction 會被排程,稍後才執行。

即使 Promise 已經立刻完成,.then() 仍會晚一點執行:

console.log("A:同步開始");

const promise = new Promise((resolve) => {
  console.log("B:executor 同步執行");

  setTimeout(() => {
    console.log("D:timer callback");
    resolve();
    console.log("E:resolve 後繼續執行");
  }, 0);
});

promise.then(() => {
  console.log("F:then handler");
});

console.log("C:同步結束");

// A:同步開始
// B:executor 同步執行
// C:同步結束
// D:timer callback
// E:resolve 後繼續執行
// F:then handler

因此 Promise 的工作不是「讓所有程式非同步」,而是用統一介面保存未來結果,並安排後續的通知。


先用 TC39 名稱對齊 Promise 流程

接下來會看到幾個名稱很長的抽象操作,也就是 TC39 裡面到底規範哪些流程,不過耐著性子看完後發現跟家電說明書一樣😆,以後怎麼用會更清楚,忘了再翻閱也行。

這些名稱不是開發者可以直接呼叫的 JavaScript API,而是 ECMAScript 用來描述引擎必須如何處理 Promise 的步驟。

先把它們當成本篇的閱讀地圖即可;後面會再對照 Promise 的狀態與範例,把排程流程展開。

建立 Promise:

注意到一開始建立 Promise 時,就已經對這個容器標記 pending 狀態

順序 TC39 規格步驟 白話理解
1 Promise constructor 建立內部欄位,狀態設為 pending 先建立等待結果的容器。
2 CreateResolvingFunctions 建立這個 Promise 專屬的 resolvereject
3 Call(executor, ..., [resolve, reject]) 立刻同步執行 executor。

第二段才是 .then() reaction 的排程路徑:

這邊就是常見我們在接續非同步操作完成狀態結果

順序 TC39 抽象操作 白話理解
1 PerformPromiseThen 登記 .then() 後要執行的 reaction。
2(視情況) TriggerPromiseReactions 如果登記時還是 pending,就在結果出現後取出 reactions。
3 NewPromiseReactionJob 把 handler 包成稍後執行的 Job。
4 HostEnqueuePromiseJob 把 Job 交給執行環境排程。

如果 Promise 已經 settled,第 2 步會被略過,路徑就是 1 → 3 → 4

thenable assimilation 是另一條獨立路徑。

它通常出現在 resolve() 傳入另一個 Promise 或 thenable 時:外層 Promise 不會立刻把它當成成功值,而是接管並跟隨它最後的結果。

TC39 抽象操作 何時出現 白話理解
NewPromiseResolveThenableJob resolve() 收到 thenable 把呼叫 thenable then 的工作排成 Job,再交給 HostEnqueuePromiseJob

一、建立 Promise 時 executor 是同步執行

先看這段:

console.log("A");

new Promise((resolve) => {
  console.log("B");
  resolve();
});

console.log("C");

輸出是:

A
B
C

new Promise() 接收的函式稱為 executor。它不是稍後才執行,而是在建立 Promise 的當下就立刻同步呼叫。

new Promise(executor)
        ↓
立刻執行 executor(resolve, reject)
        ↓
new Promise(...) 才回傳 Promise 物件

TC39 的 Promise constructor 流程也清楚寫出:先建立一個狀態為 pending 的 Promise,再透過 Call(executor, ...) 呼叫 executor,並把 resolvereject 傳進去。TC39:Promise constructor


二、那非同步工作到底是誰做的?

以 timer 為例:

const promise = new Promise((resolve) => {
  setTimeout(() => {
    resolve("時間到了");
  }, 1000);
});

實際上分成兩段:

JavaScript 現在同步執行
    ↓
executor 呼叫 setTimeout,註冊 callback
    ↓
瀏覽器或 Node.js 的執行環境負責計時 // 交棒給 host 提供的 api 執行 
    ↓
一秒後,執行環境呼叫 timer callback
    ↓
callback 呼叫 resolve("時間到了")
    ↓
Promise 改變狀態

Promise 沒有「等一秒」;它只是一開始處於 pending,直到 callback 呼叫 resolve() 才記錄結果。

同樣的概念也適用於:

API executor 裡做的事 未來由誰通知結果
setTimeout 註冊 timer callback runtime 的計時器機制
DOM event addEventListener() 瀏覽器事件系統
fetch 發起 request 瀏覽器/Node.js 網路實作
Web Worker postMessage() Worker 的 message event

fetch、DOM 與 timer 都不是 ECMAScript 語言規範本身定義的功能;它們由瀏覽器或 Node.js 等 host environment 提供。Promise 則提供一種一致的「未來結果」介面。


三、Promise 是一個只能往前走的狀態容器

一個 Promise 只有三種狀態:

pending
  ├─ resolve(value) → fulfilled
  └─ reject(error)  → rejected

一旦離開 pending,就不能再改變:

const promise = new Promise((resolve, reject) => {
  resolve("第一個結果");
  reject(new Error("太晚了"));
  resolve("也太晚了");
});

promise.then((value) => {
  console.log(value); // 第一個結果
});

TC39 用內部欄位描述這個模型:

內部欄位 白話意思
[[PromiseState]] pendingfulfilledrejected
[[PromiseResult]] 成功值或失敗原因
[[PromiseFulfillReactions]] 成功後要執行的 .then() handler 清單
[[PromiseRejectReactions]] 失敗後要執行的 .catch() handler 清單

這些不是你能直接讀取的 property,而是規範用來定義 Promise 行為的內部模型。TC39:Promise internal slots


四、resolve() 不代表 .then() 立刻執行

再看一個經典順序題:

console.log("A");

new Promise((resolve) => {
  console.log("B");
  resolve("完成");
}).then((value) => {
  console.log("C", value);
});

console.log("D");

輸出是:

A
B
D
C 完成

拆開來看:

1. 印出 A
2. executor 同步執行,印出 B
3. resolve("完成"),Promise 變 fulfilled
4. .then() 的後續工作被排程
5. 目前同步程式繼續,印出 D
6. 同步程式清空後,才執行 .then(),印出 C 完成

這個例子中,resolve("完成") 先讓 Promise 變成 fulfilled 並保存結果;後來呼叫 .then() 時,才建立並排程 Reaction Job。因此 handler 雖然是同步行為不會插進目前的 call stack,而會等同步程式結束後執行。

至於 Promise 還是 pending 時,reaction 會先存在哪裡,以及 settled 與 pending 的排程路徑有何不同,會在第七節展開。


五、為什麼 executor 裡的 throw 會變成 rejection?

const promise = new Promise(() => {
  throw new Error("出錯了");
});

promise.catch((error) => {
  console.log(error.message); // 出錯了
});

你沒有手動寫 reject(error),Promise constructor 仍會把它轉成 rejected Promise。

規範的流程是:呼叫 executor 後,檢查執行結果是否為 abrupt completion;如果是,就呼叫內部建立的 reject()TC39:Promise constructor

概念上像這樣:

function createPromise(executor) {
  return new Promise((resolve, reject) => {
    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  });
}

這表示 Promise constructor 建立的不只是 resolvereject 兩個入口;它也建立了一道邊界,將 executor 當下拋出的例外轉成這個 Promise 的失敗結果。


六、resolve(另一個 Promise) 又發生了什麼?

resolve()` 不只接受一般值,也能接受另一個 Promise:

const inner = Promise.resolve("商品資料");

const outer = new Promise((resolve) => {
  resolve(inner);
});

outer.then((value) => {
  console.log(value); // 商品資料
});

outer 不會立刻 fulfilled 成「一個 Promise 物件」;它會跟隨 inner 的最終狀態:

inner fulfilled  → outer fulfilled
inner rejected   → outer rejected

這個行為常被稱為 promise resolution/thenable assimilation。規範遇到帶有可呼叫 then 的物件時,會建立 NewPromiseResolveThenableJob,非同步處理這個跟隨流程。TC39:NewPromiseResolveThenableJob

從執行順序可以看出,assimilation 本身也會形成一次排程切分:

const thenable = {
  then(resolve) {
    console.log("thenable.then");
    resolve("完成");
  },
};

console.log("A");

Promise.resolve(thenable).then((value) => {
  console.log(value);
});

console.log("B");

// A
// B
// thenable.then
// 完成

Promise.resolve(thenable) 會稍後呼叫 thenable 的 then,但這不代表它正在等待網路或 timer。對照前面的外部事件,可以這樣區分:

情況 稍後執行的原因 Promise 的角色
Thenable assimilation 規格要求將陌生 thenable 的 then 包成 Job 接管另一個 Promise-like 結果
Timer、網路或 event host environment 正在等待未來事件 事件完成後保存結果,再排程 reaction

七、規格裡的兩條 Promise 排程路徑

現在已經知道 Promise 的狀態與 .then() 的作用,可以把前面的 TC39 地圖展開。呼叫 .then() 時,Promise 可能已經 settled,也可能還是 pending;兩種情況建立 reaction job 的時機不同。

呼叫 .then(),可以先把它想成向 Promise 登記:「結果出現時,請執行這個 handler。」,執行這個handler 任務也是需要排隊,不能亂插隊~

如果結果還沒出現,Promise 會先保存這筆登記;如果結果已經存在,就能直接把 reaction 包成 Job。兩條路最後都會把 Job 交給 host environment 排程:

https://ithelp.ithome.com.tw/upload/images/20260913/20145251y8trVE0hUy.png

對回規格名稱:

登記:PerformPromiseThen
取出:TriggerPromiseReactions
包成工作:NewPromiseReactionJob
交給執行環境:HostEnqueuePromiseJob

對回規格名稱:PerformPromiseThen 負責登記;pending Promise settled 後,由 TriggerPromiseReactions 取出 reactions;兩條路最後都透過 NewPromiseReactionJob 建立 Job。

常見的 event loop 調度後續 handler 工作

Job 建立後,HostEnqueuePromiseJob 才是 ECMAScript 交給 host 的橋接接口;event loop 則負責後續的調度:

因此,「.then() 將 handler 放進 microtask queue」只是簡寫。Promise 還是 pending 時,.then() 只會先保存 reaction;等結果出現後,才建立並排入 Promise Job。在瀏覽器中,HTML 規格會將 Promise Jobs 排入 microtask queue

https://ithelp.ithome.com.tw/upload/images/20260913/20145251FkxGlUa8Hi.png


八、實務上把 API 的完成訊號接進 Promise

把前面的三層模型放進實務情境,最常見的流程是:executor 當下註冊 callback,host environment 在外部等待 API 結果,callback 稍後再呼叫 resolve()reject()

const promise = new Promise((resolve, reject) => {
  apiRequest((error, data) => {
    if (error) {
      reject(error);
      return;
    }

    resolve(data); // API 回應後才呼叫
  });
});

這裡的 apiRequest 代表一個以 callback 回報結果的 API。時間順序是:

new Promise(...)
    ↓
executor 立刻同步執行
    ↓
註冊 API callback
    ↓
executor 執行完畢,Promise 維持 pending
    ↓
host environment 等待網路結果
    ↓
API callback 被呼叫
    ↓
resolve(data) 或 reject(error)
    ↓
Promise fulfilled 或 rejected
    ↓
排程 .then() 或 .catch() reaction

不過,這是「等待 API」的使用情境,不是 resolve() 本身的限制。resolve() 也可以在 executor 裡立刻同步呼叫:

const promise = new Promise((resolve) => {
  resolve(42); // 同步呼叫
});

console.log("A");

promise.then(console.log);

console.log("B");

// A
// B
// 42

resolve(42) 是同步呼叫,Promise 可以在當下變成 fulfilled;但 .then() handler 仍會透過 Promise Job 稍後執行。

fetch() 又稍微不同:

const promise = fetch("/api/products");

fetch() 會立刻回傳 pending Promise。網路回應抵達後,由瀏覽器或 Node.js 的 fetch 實作完成這個 Promise;應用程式通常看不到它內部何時呼叫對應的 resolve。

等 API 的不是 resolve(),也不是 Promise;是 host environment。API 結果抵達後,host 才透過 resolve() 或等效的內部機制,把結果交給 Promise


今日總結

重新記住 Promise 的流程:

1. new Promise() 立刻同步執行 executor
2. executor 註冊或啟動未來工作
3. 未來事件呼叫 resolve / reject
4. Promise 從 pending 走向 fulfilled / rejected
5. .then / .catch 的 reaction job 稍後執行
6. thenable assimilation 只是將陌生 `then` 排成 Job,不代表正在等待網路或 timer

因此:

Promise 不是非同步工作的執行者,Promise 是未來結果的狀態容器與通知機制

Promise 負責表示「未來會有的結果」,但我們還沒回答:等待這個結果時,JavaScript 究竟暫停了什麼?

下一篇就從 await 把 async function 切成前後兩段的控制流程開始。


參考資料


上一篇
Day 09|Iterator 改變我看待程式執行的方式:從整批處理到 Lazy 資料流
下一篇
Day 11|await 暫停的到底是什麼?async function 如何交還控制權
系列文
30 天新世代 JavaScript 自我學習指南14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言