一句摘要:Iterator 用 next() 讓 consumer 逐筆要求資料; 程式碼流程把工作延後到這個要求資料時才做時,才是真正的 lazy。
前置知識:知道 Generator function 呼叫後會得到可逐筆取值的 generator,並理解 for...of 會反覆取得下一筆值。
標籤:Iterator Lazy Evaluation Pull Model Generator Immutable.js
.next() 如何讓 consumer 主動拉取下一筆資料。上一天看到 Generator function 可以在 yield 交出一筆值並暫停。它會保存目前做到哪裡與區域變數的狀態,等 consumer 下一次呼叫 next() 才從這裡繼續。
這種「需要下一筆才往下做」有暫停狀態,對資料的拉取又很像 lazy 不連續動作違和感。
今天真正要問的是:資料與後續工作,何時才真的被建立或執行?
先暫時記住一個判斷方向:看到 .next(),只能確定 consumer 可以逐筆索取;要判斷是否 lazy,還得看 producer 把取得、建立或轉換資料的工作放在哪裡。

先用自然數 Generator 觀察最單純的情況。它可以無限產生正整數,但每次要交出數字前都會記錄一行訊息;consumer 讀到第五筆就停止。
function* naturalNumbers() {
let n = 1;
while (true) {
console.log(`produce: ${n}`);
yield n++;
}
}
for (const n of naturalNumbers()) {
console.log(`consume: ${n}`);
if (n === 5) {
break;
}
}
執行結果如下:
produce: 1
consume: 1
produce: 2
consume: 2
produce: 3
consume: 3
produce: 4
consume: 4
produce: 5
consume: 5
注意輸出交錯的順序。呼叫 naturalNumbers() 時,只建立一個 generator,函式本體尚未跑到第一個 console.log。接著每當 for...of 想處理一筆值,它才要求 generator 的下一筆;Generator 從上次的 yield 繼續,印出 produce,再把值交回迴圈。
這段程式可用白話拆成四步:
for...of 對 iterator 呼叫 next()。迴圈收到 5 後立即 break,因此不會再發出第六次要求;produce: 6 不會出現。重點不是「無限序列被神奇地存起來」,而是第六筆沒有被要求,所以 producer 沒有產生它。
若拿掉 break,for...of 就會持續呼叫 next(),Generator 也會持續交出 6、7、8……;程式會無限執行,但不代表它在開始時就把所有自然數建立到記憶體。只有 consumer 持續索取,來源才持續往前。若 consumer 又把每筆結果累積進 Array,記憶體才可能因這個累積行為而耗盡。
這個例子中資料索取的角色比較像:
for...of 的程式。因為 for...of 內部實作幫我們隱藏了 next() 的呼叫;若只看控制方向,可以把它想成:
consumer 要下一筆
↓ next()
producer 從 yield 繼續
↓ { value, done }
consumer 處理並決定是否繼續
這就是
pull 模式 (要求資料):資料流不是 producer 不斷主動塞過來,而是 consumer 保有何時往前走、何時停止的節奏。在同步 Iterator 中,這個請求就是next();它和事件監聽常見的 push 模型不同,後者是事件發生時由來源主動通知 listener。
pull 與 lazy 回答的是兩個不同問題:
| 概念 | 回答的問題 |
|---|---|
| pull | 誰決定何時取得下一筆? |
| lazy | 取得、建立或轉換資料的工作何時執行? |
Iterator 的 .next() 讓 consumer 主動索取資料,所以它提供的是 pull protocol;
但實際上,一段資料流整個處理流程還包含資料來源如何產生、資料如何被取得,以及中間轉換何時執行。判斷時可以分成三個彼此獨立的維度:
| 維度 | 問題 |
|---|---|
| pull/push | 誰發起資料傳遞? |
| eager/lazy | 產生或轉換工作何時執行? |
| sync/async | consumer 是否需要等待結果? |
本篇先聚焦 pull/push 與 eager/lazy;sync/async 是另一個維度,會在後面的 Async Iterator 再展開。
Array 最適合用來看出這個差異:它也有 iterator,for...of 也會逐次取得值,但 Array 的元素通常早已存在,用同一組訂單做比較,最容易看見「是否建立完整中間資料」的差異:
const orderIds = ["A-201", "A-202", "A-203"];
function createSummary(id) {
console.log(`建立摘要:${id}`);
return { id, label: "需人工審核" };
}
const eagerReports = orderIds.map(c);
// map() 已建立三筆摘要,eagerReports 是完整的新 Array。
const eagerIterator = eagerReports.values();
console.log(eagerIterator.next().value); // 第 1 次需求:讀取已建立的 A-201
console.log(eagerIterator.next().value); // 第 2 次需求:讀取已建立的 A-202
Array 的 map() 在 eagerIterator.next() 之前就走完整個 orderIds,並建立 eagerReports ,細微步驟拆解:
next() 第一次執行前,三次 createSummary() 都已完成。
eagerReports 已經包含三筆結果。
即使 consumer 只想取前兩筆,createSummary 這個中間任務仍被執行3次。
改用 Generator 寫一個 lazyMap() ,把流程稍微封裝一下,就能把 mapper 放到每次需求發生時才執行:
function* lazyMap(iterable, mapper) {
for (const value of iterable) {
yield mapper(value); // 需要提取資料時,才執行mapper
}
}
const lazyReports = lazyMap(orderIds, createSummary);
// 第 0 次需求:尚未呼叫 createSummary(),也沒有完整的結果 Array。
console.log(lazyReports.next().value); // 第 1 次需求:建立並交出 A-201
console.log(lazyReports.next().value); // 第 2 次需求:建立並交出 A-202
// 沒有第 3 次需求,所以 A-203 不會建立。
這裡刻意手動呼叫 .next(),是為了讓需求次數看得見。
若改用 for...of,它會替 consumer 隱藏這些呼叫:每次要進入下一輪,就再呼叫一次 .next();遇到 break 後便不再索取。
兩邊最後都能透過 .next() 逐筆讀取,但 producer 的工作時機不同:
| 比較面向 | Array map() + iterator |
Generator lazyMap() |
|---|---|---|
| consumer 如何讀取 | 用 .next() 逐筆讀取 |
用 .next() 逐筆讀取 |
| mapper 何時執行 | 呼叫 map() 時處理全部輸入 |
每次 .next() 時只推進到下一筆 |
| 中間結果 | 先建立完整的新 Array | 不先建立完整結果 Array |
| consumer 只要前兩筆 | 第三筆摘要已建立 | 第三筆摘要不會建立 |
這張表的關鍵是:兩者都是 pull 資料動作,但只有 lazyMap() 把工作安排成依目前需求推進。
Array 原始資料 orderIds 在兩邊都已存在;差別在於是否又預先建立完整的轉換結果。
Generator 仍不自動保證 lazy。如果在第一個 yield 前先執行 ids.map(createSummary),第一次 .next() 仍會建立全部摘要;判斷重點始終是工作放在哪裡,而不是是否看見 function*。
到這裡,程式碼個部分可以把整個流程拆成四層:
for...of:決定何時索取下一筆、總共要幾筆,以及何時停止。.next() 與 { value, done } 規定 consumer 和 producer 如何溝通。yield:yield 標記交出值與暫停的位置。lazyMap()):.next(),才由內層 for...of 向上游取得必要資料、執行 mapper(),並在 yield 交出結果後暫停;沒有下一次需求,就不繼續處理後面的資料。一句話濃縮:consumer 決定何時要、要幾筆;Iterator 規定如何要;Generator 提供暫停與恢復的寫法;lazy 去描述 producer 每次收到需求後才做多少工作。
lazy 的價值不只是不先建立中間資料,更是讓後續工作能依 consumer 的需求安排何時執行。
yield 與 Iterator 的 next() 提供暫停、恢復與索取的控制點;假設訂單審核畫面每次只取得一筆待處理任務:
畫面要一筆待處理任務
↓ next()
訂單來源逐筆檢查 → 依風險跳過、建立摘要或建立攔截任務
↓
畫面處理這一筆,決定是否再要下一筆
以下用已存在的 Array 模擬訂單來源。orders 本身不是 lazy;延後的是風險檢查與不同分支的處理工作:
const orders = [
{ id: "A-101", risk: "normal" },
{ id: "A-102", risk: "high" },
{ id: "A-103", risk: "fraud" },
{ id: "A-104", risk: "normal" },
{ id: "A-105", risk: "high" },
];
function* reviewTasks(orders) {
for (const order of orders) {
console.log(`檢查:${order.id}`);
switch (order.risk) {
case "normal":
continue;
case "high":
console.log(`建立審核摘要:${order.id}`);
yield { id: order.id, action: "交給人工審核" };
break;
case "fraud":
console.log(`建立攔截任務:${order.id}`);
yield { id: order.id, action: "立即攔截並通知" };
break;
}
}
}
const tasks = reviewTasks(orders);
console.log(tasks.next().value); // 檢查 A-101、A-102;產生 A-102 的審核任務
console.log(tasks.next().value); // 檢查 A-103;產生 A-103 的攔截任務
// 不再呼叫 next(),所以 A-104、A-105 不會被檢查或處理。
第一次 .next() 會略過正常的 A-101,直到 A-102 才交出一筆任務;第二次則在 A-103 走另一條處理路徑。這也說明 lazy 並非「一次需求只做一步」:為了交付一筆結果,producer 仍可能讀取多筆來源資料。它承諾的是只完成回應目前需求所必需的工作,而不是零成本。
初次細看 iterator 或 generator 又搭上程式流程上的組合拳,一定會抓不太住這其中的角色,先把三個容易混在一起的名詞好好釐清整理一下:
因此,producer 不一定 lazy,它可以先算好全部結果,再讓 consumer 逐筆讀取;也可以等 consumer 索取時,才執行產生下一筆所需的工作。
以前面的訂單範例來說:
const tasks = reviewTasks(orders);
tasks.next();
reviewTasks() 定義如何產生任務;呼叫它所得到的 tasks Generator 會向下游提供資料,因此扮演 producer。呼叫 tasks.next() 的畫面程式則是 consumer。
一次索取會經過以下過程:
consumer 呼叫 next()
↓
producer 從上次暫停處繼續
↓
讀取必要的來源資料並執行處理 // 通常這裡就是在這裡排入中間任務
↓
遇到 yield,交出一筆結果並暫停
↓
consumer 決定是否再次呼叫 next()
一次 .next() 不一定只讀取一筆來源資料。例如 producer 可能先略過數筆不符合條件的訂單,直到找到下一筆可以交付的任務。lazy 保證的不是「一次只做一個步驟」,而是:
producer 只做到足以回應目前需求;交出結果後便暫停,沒有下一次需求就不再往後處理。
就像 Day 04 所看到的,Generator 能在 yield 暫停並保存執行位置與區域變數,下一次 .next() 再從原處繼續。開發者不必另外手動保存目前讀到哪一筆、下一次應從哪裡恢復,因此 Generator 很適合實作 lazy producer。
不過,function* 本身不保證逐筆 lazy。如果 producer 在第一個 yield 前就先建立全部結果:
那麼第一次 .next() 仍會完成所有 createTask()。它只是把整批工作延後到第一次需求,並沒有把工作拆成逐筆按需執行。
function createTask(order) {
console.log(`建立任務:${order.id}`);
return { id: order.id };
}
function* eagerGenerator(orders) {
const allTasks = orders.map(createTask);
yield* allTasks;
}
所以,判斷 lazy producer 的關鍵不是有沒有 function*,而是:consumer 不索取下一筆時,producer 是否真的停止往後工作?
Seq 拆開來源、轉換與消費其實在開源專案中,Immutable.js 的 Seq 就有把 lazy pipeline 流程做成集合 API 的函式庫。
對 Seq 呼叫 filter() 或 map() 時,函式庫可以先記下「來源 + 這一步操作」;直到 first()、迭代或 toArray() 等 consumer 真正索取結果,才依需求從上游推進。
import { Seq } from "immutable";
const firstReport = Seq(orders)
.filter((order) => order.risk === "high")
.map(createSummary)
.first();
這段流程可以分成三層來看:
orders 是已經完整存在的 Array,因此來源本身不是 lazy。Seq(orders).filter(...).map(...) 先描述篩選與轉換規則,不會立即建立完整的報表集合。first() 只要求第一筆符合條件的結果,因此流程找到 A-102 後即可停止。以目前的 orders 來說,filter() 會先檢查 A-101,再檢查 A-102;找到第一筆 high 訂單後,才呼叫 createSummary() 建立 A-102 的摘要。後面的 A-103、A-104、A-105 不需要繼續處理。
這也說明一條流程不必從頭到尾都是 lazy。原始資料可以早已存在,但篩選、轉換與結果建立仍能延後到 consumer 真正提出需求時。
這段程式不會讓原本已存在的 orders 消失;它省下的是尚未被需要的轉換與中間集合。
Seq是 Immutable.js 提供的 lazy collection abstraction。這裡引用它,只是為了觀察相同的設計思路模型:先組合處理規則,需求出現後才執行必要工作。
.next() 決定何時索取下一筆與何時停止,但不承諾資料是否延後建立。現在我們知道 lazy 的條件不是某個魔法語法,而是由 consumer 發起需求、producer 依需求往前工作。若每次都手寫 Generator 或 wrapper 來組合 map、filter、take,仍然很麻煩。
接下來要看的 Iterator Helpers,就是 JavaScript 為 iterator 補上的標準 chainable API。它會以 Iterator.from() 建立第一條 pipeline; 帶著 Day 1 ~ Day 5 的觀念繼續前進吧!