ES2024 的 Array.fromAsync() 把「用 for await...of 讀完整個來源並逐筆 push()」整理成標準 API,適合將最終會結束的 Async Iterable 收集成 Promise<Array>。
前置知識:有空可以先讀讀 Day 12|從 async Iterator 協議到理解 for await...of ,認識基本概念就行。
學習路線:承接 Day 12–16 已建立的 producer/consumer 與 stream lifecycle,從「逐筆處理」延伸到「完整收集成 Array」。
標籤:ES2024 Array.fromAsync Async Iterable Async Generator for await...of Promise
Array.fromAsync() 補上了哪一個標準庫缺口。for await...of 何時把控制權交回 consumer,而不是只比較語法長短。Array.fromAsync() 會逐筆取得、等待並收集資料。
前面幾天探討得比較多:資料來源方怎麼把「等待下一筆」交給 consumer 去 pull 獲得資料,甚至伺服器透過協議好的 SSE 事件主動 push 資料。補了一些基礎後,今天終於可以換個角度看幾個近期 ECMAScript 補上的 consumer 端工具。
而有時候 consumer 的需求非常單純:
我不需要在 loop 裡做特殊控制,只想把這個最終會結束的 Async Iterable 全部收成 Array。
const results = [];
for await (const value of asyncIterable) {
results.push(value);
}
到了 ES2024,可以把這個意圖直接寫成:
const results = await Array.fromAsync(asyncIterable);
這就是 Array.fromAsync() 最核心的定位。
它沒有發明新的非同步資料流,也不是要取代 for await...of。它只是把一個常見動作整理成標準 API:
把可以逐筆等待的來源
完整收集成 Array
Array.fromAsync 可以想成在補齊 Array.from 功能先回到同步世界。
如果有一個 Iterable:
const values = new Set([1, 2, 3]);
可以手動收集:
const result = [];
for (const value of values) {
result.push(value);
}
也可以直接使用:
const result = Array.from(values);
console.log(result);
// [1, 2, 3]
Array.from() 很清楚地表達:
請把這個來源轉成 Array。
但思考一下為什麼 Async Iterable 不能交給
Array.from()?
後面會一直用 async function* 造出這種來源,先花三行認識它:它是 async function 與 Generator 的合體,await 用來等下一筆資料到齊,yield 則把準備好的那筆交出去。呼叫它不會馬上執行,而是回傳一個 Async Iterable,等你用 for await...of 逐筆取。
async function* createValues() {
yield 1;
yield 2;
yield 3;
}
Array.from(createValues());
// []
createValues() 交出的是 Async Iterable,Array.from() 卻收不到東西。
這裡有個容易誤會的地方:你可能會以為 Array.from() 有去拿資料,只是拿到的是還沒 resolve 的 Promise。但結果不是 [Promise, Promise, Promise],而是空的 []——它連 next() 都沒呼叫過。
原因藏在 Array.from() 的判斷順序:
Symbol.iterator,找到才迭代;.length 和數字索引。Symbol.asyncIterator,沒有同步版:const gen = createValues();
gen[Symbol.iterator]; // undefined ← Array.from 要找的,沒有
gen[Symbol.asyncIterator]; // function ← 有的是這個,但 Array.from 不認
gen.length; // undefined ← 退回 array-like,length 當 0
所以 Array.from() 看不到同步 iterator,就把 gen 當成一個「length 是 0 的 array-like」,直接產出空陣列。不是「拿到 Promise 沒等」,而是根本沒去迭代非同步來源。
TC39 對兩組 API 的關係有一個很精準的整理:
Array.from() 對應 for...of
Array.fromAsync() 對應 for await...of
因此:
const result = await Array.fromAsync(createValues());
console.log(result);
// [1, 2, 3]
Array.fromAsync() 可以先理解成 Array.from() 在非同步資料來源上的補完。
Promise<Array>比較這兩行:
const syncResult = Array.from([1, 2, 3]);
const asyncResult = Array.fromAsync(createValues()); // Promise { <pending> }
第一行可以立刻取得 Array。
第二行呼叫時,來源可能還沒有產生任何資料,所以 Array.fromAsync() 會先回傳 Promise:
console.log(asyncResult instanceof Promise);
// true
這裡要特別分清楚:asyncResult 是一個 pending Promise,不是「3 個 Promise 的陣列」。
即使來源有 3 筆資料,逐筆等待都包在這一個
Promise<Array>內部,由Array.fromAsync()自己處理完,最後一次交出完整陣列。所以你只需要await一次,拿到的就是純值[1, 2, 3],不會是一堆還沒 resolve 的 Promise。
等到來源結束,Promise 才會 fulfilled,值才是完整 Array:
const values = await asyncResult;
console.log(values);
// [1, 2, 3]
它的回傳型態可以記成:
Async Iterable
│
│ Array.fromAsync()
▼
Promise<Array>
這也表示:如果來源永遠不結束,這個 Promise 就永遠不會得到完整 Array。
for await...of + push()假設有一個逐筆產生報表區塊的 Async Generator:
async function* generateReport() {
yield '摘要';
yield '圖表';
yield '結論';
}
用 for await...of 收集:
const sections = [];
for await (const section of generateReport()) {
sections.push(section);
}
console.log(sections);
// ['摘要', '圖表', '結論']
使用 Array.fromAsync():
const sections = await Array.fromAsync(
generateReport(),
);
console.log(sections);
// ['摘要', '圖表', '結論']
如果經常需要這個動作,可能會自己寫 helper:
async function toArray(asyncIterable) {
const result = [];
for await (const value of asyncIterable) {
result.push(value);
}
return result;
}
Array.fromAsync() 的價值之一,就是不需要每個專案都重新實作這個 helper。
不過「大致等於」不代表所有規範細節都只是一個簡單 loop。標準 API 還會統一處理來源取得、Promise rejection、mapping callback、iterator 關閉與不同輸入類型。
舉個上面那個 toArray helper 會踩到的例子:如果來源不是 iterable,而是array-like object(只有 length 和數字索引、沒有 iterator),for await...of 直接就 throw:
const arrayLike = { length: 2, 0: Promise.resolve('X'), 1: Promise.resolve('Y') };
await toArray(arrayLike);
// TypeError: src is not async iterable
await Array.fromAsync(arrayLike);
// ['X', 'Y'] ← 不但收得到,還幫你把元素的 Promise 也 await 了
這種「不同輸入類型」的相容,正是標準 API 幫你統一掉、helper 得自己補的細節之一。
初學階段先建立這個心智模型就夠了:
取得下一筆
→ 等待這一筆
→ 放進 Array
→ 再取得下一筆
→ 直到 done: true
和 Array.from() 一樣,Array.fromAsync() 可以接收 mapping callback:
const sections = await Array.fromAsync(
generateReport(),
async (section, index) => {
return `${index + 1}. ${section}`;
},
);
console.log(sections);
// ['1. 摘要', '2. 圖表', '3. 結論']
mapping callback 回傳 Promise 也可以;Array.fromAsync() 會等待 callback 的結果,再把它放進 Array。
這相當於:
const sections = [];
let index = 0;
for await (const section of generateReport()) {
const mapped = await formatSection(section, index);
sections.push(mapped);
index += 1;
}
可以簡化成:
const sections = await Array.fromAsync(
generateReport(),
formatSection,
);
這個 API 表達的是一條很明確的流程:
逐筆取得
→ 逐筆轉換
→ 等待轉換結果
→ 收進最終 Array
假設使用者一次選了多個圖片檔案;每一個檔案都要先壓縮,再上傳:
const uploadedFiles = await Array.fromAsync(
fileInput.files,
async (file) => {
const compressed = await compressImage(file);
return uploadFile(compressed);
},
);
這段程式會依序處理 mapping:
取得檔案 1
→ 等待壓縮與上傳
→ 放入結果 Array
→ 取得檔案 2
→ 等待壓縮與上傳
→ 放入結果 Array
→ ...
最後才得到完整的 uploadedFiles。
這種寫法適合:
但要注意,「依序」不一定永遠比較好。如果每個工作彼此獨立,而且需求就是盡快同時完成,後面會看到 Promise.all() 可能更合適。
如果需求只是完整收集:
const results = await Array.fromAsync(source);
它比手動 loop 更直接,因為程式碼清楚表達「我要 Array」。
但如果需要控制每一筆資料,for await...of 反而更自然。
可以把最大差異記成:
for await...of每一輪都把控制權交回 consumer;Array.fromAsync()要等來源結束,才把最終結果交給 consumer。
如果說在每一筆非同步資料處理流程中,需要做一些額外邏輯處理:
for await (const file of files) {
const result = await uploadFile(file);
showUploadedFile(result);
}
for await (const file of files) {
if (!isSupportedImage(file)) {
continue;
}
await uploadFile(file);
}
for await (const result of searchResults) {
if (result.score >= 90) {
console.log('找到足夠好的結果', result);
break;
}
}
這些控制流程不適合硬塞進 Array.fromAsync() 的 mapping callback。
所以不能只說 Array.fromAsync() 是「比較直觀的 for await...of」。更精準的說法是:
當需求只是完整收集時,
Array.fromAsync()對意圖的表達更直接;需要控制流程時,for await...of更清楚。
Day 15 看過 SSE 或 streaming SDK。這類來源可能持續很久,甚至資料沒有預定結束階段:
for await (const event of eventStream) {
renderEvent(event);
}
如果改成:
const events = await Array.fromAsync(eventStream);
可能會出現兩個問題:
大型檔案或大量資料也一樣,response body 即使最後會結束,把所有 chunk 留到最後仍可能浪費記憶體。
因此 Array.fromAsync() 適合的是 最終會結束的來源:不必事先知道資料數量,但它必須最終回傳 done: true,而且 consumer 確實需要完整結果。
一般 UI pagination 也通常不需要把所有頁面抓完:畫面只需要使用者目前要看的頁面時,就只抓那一頁;不要為了示範 Array.fromAsync() 而耗盡每一頁並增加不必要的 API 請求。
Promise.all() 的差異:一個逐筆 pull,一個整批等待先回顧 Day 12 的 consumer 視角:
for await...of 是消費者在每一筆到達時執行處理。Array.fromAsync() 保留這種逐筆取得的順序,但把每筆值累積到最後才交回。Promise.all() 適用於工作已經建立、彼此獨立,且需要一個整批結果的情況。使用 Array.fromAsync() 搭配 async mapping:
const results = await Array.fromAsync(
files,
uploadFile,
);
mapping 結果會逐筆等待。
使用 Promise.all():
const jobs = [...files].map(uploadFile);
const results = await Promise.all(jobs);
map(uploadFile) 會先走完整個 Array,通常代表多個上傳工作已經被建立;Promise.all() 再等待全部結果。
上面兩段程式碼長得很像,但執行方式完全不同,差別就藏在「mapping 什麼時候被呼叫」:
Array.fromAsync() 的 mapping 是逐筆呼叫的——前一筆 await 完,才呼叫下一筆的 uploadFile。所以多個上傳是續發(sequential):同一時間只有一個在跑。
Promise.all() 這一側,map(uploadFile) 會同步地一次走完整個 Array,等於瞬間把所有 uploadFile() 都呼叫、全部啟動;Promise.all() 只是接著等它們。所以多個上傳是並列(concurrent):同一時間全部在跑。
Array.fromAsync(files, uploadFile)
上傳 1 ──✅──▶ 上傳 2 ──✅──▶ 上傳 3 (續發:一次一個)
Promise.all(files.map(uploadFile))
上傳 1 ─────▶
上傳 2 ─────▶ (並列:同時啟動)
上傳 3 ─────▶
這個差別有實際後果:
換句話說:Array.fromAsync() 沒有 Promise.all() 那種「一次全開」的能力,因為它的 mapping 本來就是為了逐筆、確保順序而設計的。
使用上可能不是只問哪個比較快,需要針對不同流程去做考量:
Array.fromAsync() 的輸入不只 Async Iterable:它也可以接收同步 Iterable(例如 Array、Set、Generator,包含會 yield Promise 的來源)與具有 length、數字索引的 array-like object。
例如同步 Generator 產生 Promise:
function* createTasks() {
yield Promise.resolve('A');
yield Promise.resolve('B');
yield Promise.resolve('C');
}
const results = await Array.fromAsync(createTasks());
console.log(results);
// ['A', 'B', 'C']
這些 Promise 會依序被等待,再放進結果 Array。
完整理解不同輸入的規範細節並不是這篇的主線。最重要的使用情境仍然是:
最終會結束的可等待來源
→ 完整收成 Array
如果來源在迭代途中失敗:
async function* brokenSource() {
yield 'A';
throw new Error('讀取失敗');
}
const resultPromise = Array.fromAsync(brokenSource());
resultPromise 會 rejected,不會取得一個只包含 'A' 的部分 Array:
try {
const results = await resultPromise;
console.log(results);
} catch (error) {
console.error(error.message);
// '讀取失敗'
}
mapping callback 失敗也是一樣:
const results = await Array.fromAsync(
source,
async (value) => {
return transform(value);
},
);
若 transform(value) throw 或回傳 rejected Promise,整個 Array.fromAsync() 的 Promise 就會 rejected。
延續第八節的續發概念:Array.fromAsync() 是逐筆取得、逐筆等待的,所以某一筆失敗時,迭代就停在那個點——失敗點之後的工作根本不會啟動。
取 A → await ✅ → push
取 B → await ❌ throw → 立刻中止,reject
↑
後面的 C、D 永遠不會被取得
這正是續發的一個好處,也是它和 Promise.all() 的實際差別:Promise.all() 是並列的,失敗時已經啟動的請求還會在背景跑完(送出去收不回);Array.fromAsync() 則是還沒輪到的工作直接不做。這讓它天然適合「前一步失敗,後面就不該做」的交易式流程。
中止的同時,Array.fromAsync() 也會呼叫來源的 return() 把 iterator 正確關閉。所以如果來源的 generator 裡有 try/finally,finally 仍然會執行,清理不會被漏掉:
async function* source() {
try {
yield 'A';
throw new Error('boom');
} finally {
console.log('清理完成'); // 中止時仍會跑到
}
}
所以它提供的是「完整成功後得到 Array」的模型,不是自動保留部分成功結果的工具。
Array.fromAsync() 的價值不是取代 loop,而是補齊一個標準庫缺口——讓「把來源收成 Array」這件事,在非同步世界也有一個現成的答案:
Array.from() 把同步來源收成 Array
Array.fromAsync() 把非同步來源收成 Promise<Array>
而它和另外兩個常被拿來比較的 API,其實各自回答不同的問題。可以記成這張 consumer 地圖:
| 你的需求 | 該用 |
|---|---|
| 一組已經建立好、彼此獨立的工作,只要一個整批結果 | Promise.all() |
| 要在每一筆抵達時就處理,需要 skip/break/逐筆更新畫面 | for await...of |
| 一個會結束的 Async Iterable,要收成一份完整 Array | Array.fromAsync() |
三者的分野可以用兩個問題快速定位:工作是一次全開還是逐筆 pull?最後要的是完整 Array 還是每筆即時處理? 也別忘了——這三個 API 都不會自動替 producer 決定並行度:
Array.fromAsync() 與 for await...of 是續發Promise.all() 是並列,要不要限制同時進行的工作量,仍然是你自己的選擇。到這邊來看整段非同步流程中資料的流動看了滿多事情,從逐筆 pull、到完整收集,我們把「怎麼消費一個非同步來源」走過了一輪,慢慢建立自己的心智藍圖吧~繼續加油。
ECMAScript 2024 — Array.fromAsync()Array.fromAsync() 的正式規範與演算法。
TC39 Proposal — Array.fromAsync
API 的設計動機、與 for await...of/Promise.all() 的關係,以及實際使用案例。
MDN — Array.fromAsync()
語法、輸入類型、mapping callback 與使用範例。
Day 12|從 Async Iterator 協議到 for await...of
回顧 consumer 如何等待並取得下一筆資料。
挺有趣的,之前我沒特別看 Array.fromAsync 在做什麼 ,還以為它是像 Promise.all 那樣的平行執行,看來並不是