iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0
JavaScript

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

Day 05|資料何時才真的被產生?Iterator 的 pull 與 lazy 模式

  • 分享至 

  • xImage
  •  

摘要

一句摘要:Iterator 用 next() 讓 consumer 逐筆要求資料; 程式碼流程把工作延後到這個要求資料時才做時,才是真正的 lazy。

前置知識:知道 Generator function 呼叫後會得到可逐筆取值的 generator,並理解 for...of 會反覆取得下一筆值。

標籤Iterator Lazy Evaluation Pull Model Generator Immutable.js


今天學習目標

  1. 說明 .next() 如何讓 consumer 主動拉取下一筆資料。
  2. 分辨 Iterator 的 pull protocol 與 lazy evaluation 分別保證什麼。
  3. 判斷一個資料來源或轉換流程是否真的把工作延後至需求出現時。

上一天看到 Generator function 可以在 yield 交出一筆值並暫停。它會保存目前做到哪裡與區域變數的狀態,等 consumer 下一次呼叫 next() 才從這裡繼續。

這種「需要下一筆才往下做」有暫停狀態,對資料的拉取又很像 lazy 不連續動作違和感。

今天真正要問的是:資料與後續工作,何時才真的被建立或執行?

先暫時記住一個判斷方向:看到 .next(),只能確定 consumer 可以逐筆索取;要判斷是否 lazy,還得看 producer 把取得、建立或轉換資料的工作放在哪裡。

https://ithelp.ithome.com.tw/upload/images/20260907/20145251Bp7jpU103q.png


用自然數 Generator 觀察需求何時發生

先用自然數 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,再把值交回迴圈。

這段程式可用白話拆成四步:

  1. consumer 要下一筆。
  2. for...of 對 iterator 呼叫 next()
  3. Generator 從暫停點繼續,產生並交出一個值。
  4. consumer 處理該值,決定是否再要一筆。

迴圈收到 5 後立即 break,因此不會再發出第六次要求;produce: 6 不會出現。重點不是「無限序列被神奇地存起來」,而是第六筆沒有被要求,所以 producer 沒有產生它

若拿掉 breakfor...of 就會持續呼叫 next(),Generator 也會持續交出 678……;程式會無限執行,但不代表它在開始時就把所有自然數建立到記憶體。只有 consumer 持續索取,來源才持續往前。若 consumer 又把每筆結果累積進 Array,記憶體才可能因這個累積行為而耗盡。

這個例子中資料索取的角色比較像:

  • producer/來源:naturalNumbers() 建立的 Generator。
  • consumer:執行 for...of 的程式。
  • 索取動作:for...of 每次準備進入下一輪時,內部呼叫 iterator 的 .next()。

因為 for...of 內部實作幫我們隱藏了 next() 的呼叫;若只看控制方向,可以把它想成:

consumer 要下一筆
      ↓ next()
producer 從 yield 繼續
      ↓ { value, done }
consumer 處理並決定是否繼續

這就是 pull 模式 (要求資料):資料流不是 producer 不斷主動塞過來,而是 consumer 保有何時往前走、何時停止的節奏。在同步 Iterator 中,這個請求就是 next();它和事件監聽常見的 push 模型不同,後者是事件發生時由來源主動通知 listener。


有 Iterator 不等於一定 lazy 的保證

pull 與 lazy 回答的是兩個不同問題:

概念 回答的問題
pull 誰決定何時取得下一筆?
lazy 取得、建立或轉換資料的工作何時執行?

Iterator 的 .next() 讓 consumer 主動索取資料,所以它提供的是 pull protocol;

但實際上,一段資料流整個處理流程還包含資料來源如何產生資料如何被取得,以及中間轉換何時執行。判斷時可以分成三個彼此獨立的維度:

維度 問題
pull/push 誰發起資料傳遞?
eager/lazy 產生或轉換工作何時執行?
sync/async consumer 是否需要等待結果?

本篇先聚焦 pull/pusheager/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*


到這裡,程式碼個部分可以把整個流程拆成四層:

  • consumer/for...of:決定何時索取下一筆、總共要幾筆,以及何時停止。
  • Iterator protocol
    .next(){ value, done } 規定 consumer 和 producer 如何溝通。
  • Generator/yield
    提供 producer 的一種寫法;Generator 保存執行狀態,yield 標記交出值與暫停的位置。
  • lazy producer(本例是 lazyMap()
    每收到一次 .next(),才由內層 for...of 向上游取得必要資料、執行 mapper(),並在 yield 交出結果後暫停;沒有下一次需求,就不繼續處理後面的資料。

一句話濃縮:consumer 決定何時要、要幾筆;Iterator 規定如何要;Generator 提供暫停與恢復的寫法;lazy 去描述 producer 每次收到需求後才做多少工作。


把 lazy 看成工作流的一段排程

lazy 的價值不只是不先建立中間資料,更是讓後續工作能依 consumer 的需求安排何時執行。

  • Generator 的 yield 與 Iterator 的 next() 提供暫停、恢復與索取的控制點;
  • lazy producer 利用這些控制點,把尚未被需要的工作延後。

假設訂單審核畫面每次只取得一筆待處理任務:

畫面要一筆待處理任務
          ↓ 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 仍可能讀取多筆來源資料。它承諾的是只完成回應目前需求所必需的工作,而不是零成本。


釐清 producer、consumer 與 lazy 的關係

初次細看 iterator 或 generator 又搭上程式流程上的組合拳,一定會抓不太住這其中的角色,先把三個容易混在一起的名詞好好釐清整理一下:

  • producer:負責提供下一筆資料的一方。
  • consumer:決定何時索取資料、是否繼續的一方。
  • lazy:描述工作何時執行;尚未出現下一筆需求時,就不繼續往後處理。

因此,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 是否真的停止往後工作?


Immutable.js 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();

這段流程可以分成三層來看:

  • 來源(producer/source)orders 是已經完整存在的 Array,因此來源本身不是 lazy。
  • 轉換(lazy pipeline)Seq(orders).filter(...).map(...) 先描述篩選與轉換規則,不會立即建立完整的報表集合。
  • 消費(consumer)first() 只要求第一筆符合條件的結果,因此流程找到 A-102 後即可停止。

以目前的 orders 來說,filter() 會先檢查 A-101,再檢查 A-102;找到第一筆 high 訂單後,才呼叫 createSummary() 建立 A-102 的摘要。後面的 A-103A-104A-105 不需要繼續處理。

這也說明一條流程不必從頭到尾都是 lazy。原始資料可以早已存在,但篩選、轉換與結果建立仍能延後到 consumer 真正提出需求時。

這段程式不會讓原本已存在的 orders 消失;它省下的是尚未被需要的轉換與中間集合。

Seq 是 Immutable.js 提供的 lazy collection abstraction。這裡引用它,只是為了觀察相同的設計思路模型:先組合處理規則,需求出現後才執行必要工作。


今日總結

  • Iterator 的 pull protocol 讓 consumer 用 .next() 決定何時索取下一筆與何時停止,但不承諾資料是否延後建立。
  • lazy 是 producer 把取得、建立或轉換工作延後到需求出現時才做;consumer 決定何時提出需求與何時停止,producer 的實作則決定每次需求會觸發多少工作。
  • 一條流程的來源、轉換與消費不一定同時 lazy;原始 Array 可以早已存在,後續轉換仍能按需求執行。
  • 無限 Generator 在 consumer 停止時可以不再往前;若 consumer 持續索取,它仍會無限執行。lazy 不等於自動停止,也不等於零成本。

下一篇預告

現在我們知道 lazy 的條件不是某個魔法語法,而是由 consumer 發起需求、producer 依需求往前工作。若每次都手寫 Generator 或 wrapper 來組合 mapfiltertake,仍然很麻煩。

接下來要看的 Iterator Helpers,就是 JavaScript 為 iterator 補上的標準 chainable API。它會以 Iterator.from() 建立第一條 pipeline; 帶著 Day 1 ~ Day 5 的觀念繼續前進吧!


參考資料


上一篇
Day 04|Generator 的雙重身分:它同時是 Iterator 也是 Iterable
下一篇
Day 06|Iterator 也有 map / filter 了:ES2025 Iterator Helpers
系列文
30 天新世代 JavaScript 自我學習指南7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言