iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
JavaScript

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

Day 18|Promise.withResolvers:理解從「資料抵達」到「資料流到達可用節點」的轉變

  • 分享至 

  • xImage
  •  

摘要

以前常把 Promise 想成「資料一回來、fulfilled 就結束」。但有一種模式,是希望完成 Promise 的開關(resolve / reject)能交給外部或未來的行為使用,而不是在建立 Promise 的當下就把完成權決定好——它可能要等一個未來的事件,或等一段資料流被處理到某個「可用節點」。Promise.withResolvers()(ES2024)把「承諾」和「兌現的開關」分開,讓等待結果的人與知道結果的人,可以是不同段程式。

  • 前置知識:理解 Promise、resolve / rejectasync / await; 可以讀讀 Day 10 文章 幫助記憶。

  • 學習路線:Day 10–17 從 Promise、Async Iterator、Web Streams 到 Array.fromAsync(),主軸都是「未來的資料怎麼等」;其中 Day 15 的 SSE 說明事件如何一筆一筆推來。今天補上一個常被忽略的角度:完成的時機,有時候不一定希望由「這個函式」決定

  • 標籤ES2024 Promise.withResolvers Deferred 非同步生命週期 PDF.js


今日學習目標

  • 破除「Promise = 等資料回來就結束」的直覺,理解它也能代表「流程到達可用節點」的完成訊號。
  • 認識 Deferred 模式:先把 promise 交出去,resolve / reject 留在外面等未來觸發。
  • 掌握 Promise.withResolvers() 的三個欄位與典型用法(UI 事件、資料流的 ready 訊號)。
  • 能判斷何時該用 withResolvers(),何時用 async / new Promise() 就夠。
  • 從 PDF.js 看 Deferred 在真實 library 的應用(主執行緒與 worker 的「一問一答」)。

資料都回來了,難道 Promise 還不能結束.....?

https://ithelp.ithome.com.tw/upload/images/20260921/20145251A4Kq30Kkej.png


一、對 Promise 的既定直覺:資料回來就結束

多數人第一次認識 Promise,是從 await fetch() 開始的:

const response = await fetch("/api/products");
const products = await response.json();

在這個直覺裡,Promise 的心智模型很單純,我就等待到你有結果出來:

發出請求
   ↓
等資料回來
   ↓
resolve(fulfilled)
   ↓
await 後面繼續

這裡有個容易被忽略的細節:「完成 Promise 的那個開關(resolve),藏在 fetch 內部」。你不需要自己呼叫它,資料到了,fetch 就替你 resolve 了。

大多數情況,這個直覺完全夠用。真正會卡住初學者的,是下一種情況。


二、第一個示範:完成的開關,不在函式裡

想像一個「確認刪除」的對話框。呼叫端希望這樣寫:

const confirmed = await openConfirmDialog({ title: "確定要刪除嗎?" });

if (confirmed) deleteItem();

問題來了:openConfirmDialog() 被呼叫的當下,使用者還沒按任何按鈕。結果要等未來某個 click 事件才會出現:

呼叫 openConfirmDialog()
        ↓
現在就要拿到一個 Promise(但還是 pending)
        ↓
使用者稍後按下「確認」或「取消」
        ↓
這時才知道結果 → resolve

用一般的 new Promise() 會遇到障礙:你沒辦法在 executor 裡「停下來等使用者點擊」。真正握有結果的,是之後才觸發的事件 callback,而它在 executor 外面。

過去大家的解法,是把 resolve / reject **從 executor 裡「拉出來」**存到外層:

function createDeferred() {
  let resolve;
  let reject;

  const promise = new Promise((res, rej) => {
    resolve = res; // 把開關接到外層變數
    reject = rej;
  });

  return { promise, resolve, reject };
}

這招之所以成立,關鍵在於 executor 是同步執行的(這點Day 10在談 Promise 創建時我們有談過):

new Promise(executor)
        ↓  executor 立刻同步跑完
resolve / reject 已被接到外層變數
        ↓
promise 回傳(此時仍 pending)
        ↓
未來任一事件呼叫 resolve() → 完成

回傳的這個 { promise, resolve, reject },就是一個 Deferred,延遲模式:

一個「待定的承諾」(promise),加上「兌現它的兩個開關」(resolve / reject),打包在一起。

它的價值是把兩種角色拆開:

等待結果的人:await deferred.promise
知道結果的人:click 事件裡呼叫 resolve() / reject()

三、早期套件的 Deferred Promise

「先交出 Promise、把開關留在手上」不是新需求。原生 Promise 還沒普及的年代,各大函式庫早就內建了對應的 helper:

函式庫 API
Q Q.defer()
jQuery $.Deferred()
AngularJS $q.defer()

用法幾乎一致:拿到一個 deferred、交出 promise、把 resolve / reject 留著等未來觸發。需求一直都在,只是過去得靠上一節那段樣板碼手動把開關取出。

ES2024 把這個模式標準化成一行:

const { promise, resolve, reject } = Promise.withResolvers();
欄位 責任
promise 交給呼叫端用 await.then() 等待
resolve(value) 讓 Promise 跟隨 value 完成
reject(reason) 讓 Promise 以錯誤結束

它沒有發明新的非同步模型,只是把「Deferred」這個老樣板碼變成語言內建。回到確認視窗,就能寫得很乾淨:

function createConfirmDialog() {
  const { promise, resolve } = Promise.withResolvers();

  return {
    promise,
    confirm: () => resolve(true),
    cancel: () => resolve(false),
  };
}

const dialog = createConfirmDialog();
confirmButton.addEventListener("click", dialog.confirm);
cancelButton.addEventListener("click", dialog.cancel);

const confirmed = await dialog.promise; // 等使用者按下才有結果

小提醒:如果 resolve 不需要離開建立處(像 setTimeout 那種),普通 new Promise() 就夠了,用不到 Deferred。Deferred 是為「完成權要交給別人、別的時間點」而存在。


四、思維升級:resolve 有時等的不只是「資料抵達」,而是需要告訴使用者「到達可用節點」

剛開始看 Promise.withResolvers(),很容易覺得它只是一個「把控制權(resolve / reject)暴露出來」的新 API——好像只是讓語法寫起來方便一點。但多讀幾次、回頭反思就會發現:一個流程要完成的,不一定只是「等一筆非同步資料回來」

實務上常常同時有好幾件非同步的事在跑,而且不見得都跟「收資料」有關——資料解析、轉碼、格式驗證……這些「處理」同樣要花時間,也同樣是完成的一環。

資料傳輸生命週期
────────────────────────►
chunk → chunk → chunk → chunk...


文件生命週期
────────────────────────►
loading → parsing → initializing → ready

我們真正想交給使用者的,往往是「這些處理都做完、資料進入可用狀態」的那一刻,而不是「第一批 bytes 抵達」的那一刻。

所以換個角度看,resolve() 等的有時不是「資料抵達」,而是「整段流程被處理到一個可用節點」

確認視窗等的是「一個未來事件」。再往前一步,把它換成一串陸續到達、還要再加工的資料,就能看到這篇真正想講的角度。

Day 15 的 SSE 會讓事件一筆一筆推來。如果你要逐筆處理,用 for await...of 很自然。但有時候你要的不是每一筆,而是「這串資料處理到某個節點時,給我一個『可以開始了』的訊號」。這正是 withResolvers() 的舞台:

function whenConfigReady(source) {
  const { promise, resolve, reject } = Promise.withResolvers();

  let raw = "";
  source.addEventListener("message", (e) => {
    raw += e.data;                      // ① chunk 一段一段收進來(原始、還不能用)

    const config = tryParseConfig(raw); // ② 累積夠了,才解析/轉換得出來
    if (config) {
      resolve(config);                  // ③ 轉換完成、資料可用 → 這時才 ready
    }
  });
  source.addEventListener("error", reject);

  return promise; // 先把「可用訊號」交出去
}

// 拿到的是「已經處理好、可以直接用」的結果,不是原始 chunk
const config = await whenConfigReady(source);
chunk ─ chunk ─ chunk ...        資料一直來(原始、還不能用)
                 │
     累積到能解析出完整 config(轉換完成)
                 ↓
        resolve(config)  ← 這時才把「可用的結果」交給使用者

注意兩件事:resolve() 交出去的不是原始 chunk,而是轉換完成、可直接使用的 config;而它等的也不是「有沒有資料」——chunk 一直在來——是「資料被處理到一個可用節點」。這就是心智模型的升級:

Promise 不只是「等一筆資料回來」,它也可以代表「一段資料流處理到某個可用狀態」的完成訊號。

也因為如此,這種寫法不能簡化成一條 await load()。有三個訊號,只要出現就代表「該用 Deferred」:

  1. resolve 由事件 / callback 觸發,不是某個函式的 return
  2. promise 必須在資料開始進來之前就交出去。
  3. 「到達可用節點」的判斷,散在資料流的處理邏輯裡。

五、真實世界的樣子:PDF.js 為什麼不是一次 await 就結束

PDF.js 是 Mozilla 開源的 JavaScript PDF 引擎,用純瀏覽器技術(主要是 <canvas>)把 PDF 畫出來,不需要任何外掛——你在 Firefox 打開 PDF 看到的檢視器,底層就是它。

https://ithelp.ithome.com.tw/upload/images/20260921/201452517j8B3T6ILR.png

它解決的流程滿龐大,看原始碼應該會嚇跑大家🤣,用白話簡單來說:

PDF 是複雜的二進位格式,裡面有交叉引用表(xref)、物件、字型、影像與各種壓縮。

交叉引用表比較像「一個帶索引的物件資料庫 + 一份繪圖腳本」,而不是一張現成的圖。而且這些資料在網路上就是一串原始 bytes——瀏覽器拿到的可能是 BlobArrayBuffer,但真正要逐 byte 解析時,得先轉成 Uint8Array ,把這些原始 bytes 解析、解碼成畫面很吃 CPU,若全放在主執行緒做,頁面會卡住。

於是 PDF.js 把重活丟到背景的 Web Worker,主執行緒只留下 API:

主執行緒(你的程式碼)             Web Worker(pdf.worker.js)
getDocument / getPage / render  ←──►  解析 xref、解碼字型/影像、
        (postMessage 訊息往返)        產生繪圖指令

再加上檔案本身可以用 streaming / range request 分段抓,於是同一時間有好幾條非同步的線在跑。這也是為什麼它的 API 不是「一次 await 就好」。

初學者看到 PDF 預覽,直覺會想:不就 const pdf = await loadPdf() 嗎?但 PDF.js 的實際 API 一次就有三個階段:

const loadingTask = pdfjsLib.getDocument(url);
const pdf = await loadingTask.promise;      // ① 文件載入完成

const page = await pdf.getPage(1);          // ② 取得某一頁

const renderTask = page.render(renderContext);
await renderTask.promise;                    // ③ 這一頁畫完
document loading  →  page loading  →  page rendering

這三個階段各做不同的事,別把它們混為一談——真正「轉成畫面」的其實只有 render

呼叫 它做的事 是不是「轉成畫面」
getDocument(url) worker 抓 bytes、解析文件骨架(xref、目錄、頁樹),知道整份文件有哪些頁 否(解析結構)
pdf.getPage(n) 取得某一頁的「描述資料」(尺寸、資源引用、內容流位置),回一個 PDFPageProxy 否(定位並載入這頁的目錄資料)
page.render(…) 把這頁內容流解壓成繪圖指令、解碼字型/影像,照指令畫到 <canvas> ✅ 真正成像在這裡

換句話說,getPage 只是「翻到並讀取這一頁的頁首資料」,還沒把它畫出來;吃 CPU 的解碼與成像主要落在 render


為什麼要拆這麼細?因為一份 PDF 的載入不是「整包下載完再用」,而是好幾條各自獨立的非同步流程同時在跑:

  • 檔案用 streaming / range request 分段抓
  • worker 在背景解析、某一頁才被 render。
  • 於是「完成」不再只有一個時刻,而是好幾層 checkpoint
整份文件可用了       ← loadingTask.promise
某一頁可用了         ← getPage() 的 Promise
某一次 render 畫完了  ← renderTask.promise

這帶出關鍵一句:

「資料到了」不等於「整個工作 ready」。底層某段資料抵達,不代表上層生命週期完成。

這就是第四節那兩條並行生命週期的真實案例——只是這次把 PDF.js 真正 resolve 的點也標出來:

資料傳輸生命週期
────────────────────────►
chunk → chunk → chunk → chunk ...

文件生命週期
────────────────────────►
loading → parsing → initializing → ready
                                    ▲
                         loadingTask.promise
                         在這裡才 resolve

上面那條一直有 chunk 進來,不代表下面那條就到站;loadingTask.promise 等的是文件生命週期跨過 ready,而不是傳輸生命週期又收到一段 bytes。

這正是第四節那句話的具體版本:resolve 等的是「到達可用節點」,不是「資料抵達」。

而且這些工作在還沒完成前,外部可能就要介入取消打斷,有點像上面舉的事件呼叫控制 Promise,~最典型的是翻頁:

使用者正在看第 1 頁 → 開始 render page 1
        ↓
突然跳到第 30 頁
        ↓
第 1 頁的 render 已經沒有價值 → renderTask.cancel()

所以 PDF.js 不會只回一個 Promise,而是回一個 task 物件,讓呼叫端在完成前也能觀察、干預:

task 物件
├─ promise        ← 等這項工作完成
├─ onProgress     ← 觀察進度
└─ cancel / destroy ← 決定「還要不要繼續」
  • renderTask.cancel():取消目前這次 render。
  • loadingTask.destroy():中止相關的 network request 並銷毀 worker。

一句話總結這個情境:

Deferred 特別適合「工作已經開始,但在完成以前,外部還需要觀察或干預它」的場景。


那跟 Worker Deferred Promise 交互到底用在哪?

https://ithelp.ithome.com.tw/upload/images/20260921/20145251cxY9y2HDcl.png

PDF.js 最常用它的地方,是主執行緒和 worker 的「一問一答」:

主執行緒請 worker「幫我解析第 1 頁」,worker 要算一下才有結果,但呼叫端現在就想要一個能 await 的 Promise。

這就像去餐廳點餐、拿號碼牌:

你(主執行緒)              廚房(worker)
點餐「第 1 頁」
  → 拿到號碼牌 promise         …做菜中…
  → 先拿著等 await
                       ← 叫號「1 號好了!」
  → 對號取餐 resolve()
  • 號碼牌 = 先交到你手上的 promise(現在就拿得到)
  • 叫號 = worker 之後回覆的訊息
  • 對號取餐 = 找到對應的 resolve,把結果交給你

這個「發號碼牌」大致長這樣(簡化示意):

// ── 主執行緒:發號碼牌的小工具 ──
let nextId = 1;
const tickets = new Map(); // id → { resolve, reject }(號碼牌存根)

function sendWithPromise(worker, action, payload) {
  const { promise, resolve, reject } = Promise.withResolvers();

  const id = nextId++;
  tickets.set(id, { resolve, reject });        // ① 記下這張號碼牌
  worker.postMessage({ id, action, payload }); // ② 點餐(送去廚房)

  return promise;                              // ③ 先把號碼牌交出去
}

// ── 廚房叫號(worker 回覆)→ 對號取餐 ──

worker.addEventListener("message", (e) => {
  const { id, result, error } = e.data;
  const ticket = tickets.get(id);
  if (!ticket) return;
  tickets.delete(id);  // 取得之前 Promise.withResolvers 發出來的 reject/reslove

  if (error) ticket.reject(error);
  else ticket.resolve(result);                 // ④ 把結果交給當初 await 的人
});

呼叫端用起來就跟一般 async 一樣自然:

const page = await sendWithPromise(worker, "GetPage", { pageNumber: 1 });

那張 tickets 表是必要的:同時可能點很多餐(多個請求並行),回覆又不一定按順序回來,靠 id 才能把每則回覆對回當初的 promise。這也是為什麼不能只用一個函式的 return——resolve 是被「叫號事件」觸發的,而且號碼牌得先交出去。

是不是很熟悉這類模式:Deferred 特別適合「工作已經開始,但在完成以前,外部還需要觀察或干預它」的場景,worker 需要某段非同步流程的控制權,操作完畢後交還資料給它。

這招也不是 PDF.js 自己發明的。它以前用手寫版本,2024 年直接換成原生的 Promise.withResolvers()PR #17854),正好說明:原生 API 就是把大家寫了很多年的老寫法收進語言。

小提醒:Promise.withResolvers() 是 ES2024 的新語法,較舊的 Safari/Node 可能出現 is not a function,上線前記得確認執行環境(替代寫法見文末)。


六、什麼時候該用、什麼時候不要

不要因為它是新 API 就到處改寫。先問「完成點在哪裡」:

情境 較適合的做法
自己控制的線性非同步流程 async / await
建立 Promise 時就能註冊完成 callback(如 setTimeout new Promise()
Promise 必須先交出,完成權由未來的事件 / 資料流節點決定 Promise.withResolvers()
要逐筆處理持續到達的多筆資料 Async Iterable / Stream(Day 12–16)

一句話記住 Deferred:

async 適合描述「怎麼一步步走到完成」;Deferred 適合描述「完成這個狀態,何時、由誰觸發」。


今日總結

今天把 Promise 的心智模型往前推了一步:

初階直覺:資料回來 → fulfilled
        ↓
升級後:  資料流處理到「可用節點」 → fulfilled

Promise.withResolvers() 沒有魔法,它只是把一個舊的處理模式——Deferred——收進語言:

先交出承諾,完成權留給真正掌握生命週期的地方。 從一個確認視窗,到 SSE 的 ready 訊號,再到 PDF.js 的 loading / render / cancel,都是同一個問題的不同規模:

完成的時機,不一定由建立 Promise 的那個函式決定。

下一篇希望能稍微暫緩腳步,把 Day 10–18 提到很多非同步和資料流動概念的心智模型放回同一張地圖。


參考資料


上一篇
Day 17|已經有 for await...of,為什麼還需要 Array.fromAsync?
系列文
30 天新世代 JavaScript 自我學習指南18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言