iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
JavaScript

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

Day 07|Iterator Helpers:Lazy Pipeline 如何逐筆處理資料?

  • 分享至 

  • xImage
  •  

摘要

  • 一句摘要:Iterator Helpers 會先接成處理管線;直到 consumer 要一筆資料,請求才會一路往上游 pull,值再逐步回到 consumer。
  • 前置知識:知道 Iterator 的 next() 與 看過基本 Iterator Helper API;。
  • 學習路線:理解什麼是 lazy pipeline 和如何逐筆運作;。
  • 標籤ES2025 Iterator Helpers Lazy Evaluation Pipeline

今日學習目標

  1. 說明 lazy pipeline 建立時,為什麼還不會讀取 source。
  2. 描述 consumer、helpers 與 source 之間 pull request 和 value flow 的方向。
  3. 解釋為什麼 consumer 只要求一筆結果,filter() 等 helper 仍可能讀取多筆來源資料。
  4. 比較 map()filter()take()drop()flatMap() 的 pull 行為。

https://ithelp.ithome.com.tw/upload/images/20260909/20145251FTkumRGkC0.png

Day 06 我們已經看過基本的 Iterator Helper 寫法:

Iterator.from(source)
  .map(...)
  .filter(...)
  .take(3)
  .toArray();

今天不再逐一背 map()filter()take() 這些 API 的名稱。

而是真正要探索的是:

當 consumer 想要一筆結果時,這條 pipeline 裡程式碼大概發生了什麼事?

理解這件事後,才會知道 lazy 的意思不是「完全不做事」,而是「沒有人要資料時,不主動做事」。


先建立一個心智圖:consumer請求往上游,資料則往下游走

先看一條還沒有被消費的 pipeline:

function* numbers() {
  for (let i = 1; i <= 10; i++) {
    yield i;
  }
}

const result = numbers()
  .filter((x) => x % 2 === 0)
  .map((x) => x * 10)
  .take(3);

Lazy pipeline:consumer 的 pull request 往 source 前進,value 再從 source 回到 consumer

這裡其實已經呼叫了 numbers(),但呼叫 Generator 函式只會先取得一個 iterator,不會立刻執行函式本體。因此 for 迴圈還沒開始、還沒有任何 yield 出來的數字;filter()map() 的 callback 也都還沒執行。

它們只是先組裝這個流程管線:

source → filter → map → take → consumer

但資料真正開始流動時,有兩個相反方向:

consumer ──要求一筆結果──▶ take ──▶ map ──▶ filter ──▶ source
consumer ◀──交付一筆值──── take ◀── map ◀── filter ◀── source

第一條是 pull:下游 consumer 要求資料時,每個節點才向自己的上游要一筆。

第二條是 value flow:source 有值後,值才依序通過各個 helper,回到 consumer。

這就是整篇文章最重要的心智圖:

請求由下游往上游走;資料由上游往下游走。

可以把 map()filter()take() 理解成一層層等待 pull 的 lazy iterator wrapper。建立 pipeline 時,它們只記住各自的上游與處理規則;直到 consumer 呼叫最外層 iterator 的 next(),才逐層要求資料並執行 callback。


用 Generator 手寫一層 lazy wrapper 來思考

標準的 Iterator Helpers 已經幫我們提供 filter()map()take()。不過,先用 Generator 寫出概念版,當然實際內部引擎的實作不一定是這樣,但可以更清楚看見每一層 wrapper 怎麼連接 source 與 consumer,還有程式碼實際的運作思維:

先假裝我第一次看到這些串接步驟,我會用什麼思路開始設計:

  1. 需要組裝把「上游資料」與「處理規則」接起來;只有下游呼叫 next() 時,才讀一筆、處理一筆,看起回傳值需要是 iterator,不然資料就會被一次取完
lazyMap(source, mapFn)     // 回傳 iterator
lazyFilter(source, filterFn) // 回傳 iterator
take(source, countFn)         // 回傳 iterator
  1. 依照啟動順序來組裝 pipeline take->filter->map->source
    組起來大概會像這樣:
take(
  lazyMap(
    lazyFilter(iterator, filterFn),
    mapFn,
  ),
  3,
);
  1. 每個 wrapper 只處理自己的一件事的規則很單純:向上游拿一筆、轉換、交付。

filter() 的規則是:可能向上游拿很多筆,直到找到一筆能交付的值。

function* lazyFilter(source, filterFn) {
  for (const value of source) {
    if (filterFn(value)) {
      yield value;
    }
  }
}

map() 的規則很單純:向上游拿一筆、轉換、交付。

function* lazyMap(source, mapper) {
  for (const value of source) {
    yield mapper(value);
  }
}

take() 的規則是:交付到指定筆數後,停止向上游拿新資料。

function* take(source, count) {
  if (count <= 0) {
    return;
  }

  let taken = 0;

  for (const value of source) {
    yield value;

    taken += 1;
    if (taken === count) {
      return;
    }
  }
}

最後把它們接起來:

const pipeline = take(
  lazyMap(
    lazyFilter(numbers(), (x) => x % 2 === 0),
    (x) => x * 10,
  ),
  3,
);

這幾個 function 雖然已經被呼叫,但它們都是 Generator function;呼叫後只會各自回傳一個 iterator,函式本體與裡面的 for...of 都還沒開始執行。

直到 consumer 要第一筆結果:

console.log(pipeline.next());

最外層的 take() 才會開始執行它的 for...of,向 lazyMap() 要資料;lazyMap() 再向 lazyFilter() 要資料;最後 lazyFilter() 才向 numbers() 要資料。

pipeline.next() // consumer 調用幾次 跑幾次 pipeline
→ take 的 for...of 開始讀 lazyMap
→ lazyMap 的 for...of 開始讀 lazyFilter
→ lazyFilter 的 for...of 開始讀 numbers
→ numbers yield 一筆值

當值回來時,predicatemapper 才會依序執行,結果最終由 take() 交付給 consumer。


讓第一個 next() 跑一次啟動拉取資料

把剛才的例子加上 log:

function* numbers() {
  for (let i = 1; i <= 10; i++) {
    console.log("source produce:", i);
    yield i;
  }
}

const result = numbers()
  .filter((x) => {
    console.log("filter:", x);
    return x % 2 === 0;
  })
  .map((x) => {
    console.log("map:", x);
    return x * 10;
  })
  .take(3);

console.log(result.next());

輸出會是:

source produce: 1
filter: 1
source produce: 2
filter: 2
map: 2
{ value: 20, done: false }

把這段輸出放回剛才的心智圖,就能看見完整過程:

1. consumer 呼叫 result.next()
2. take 向 map 要一筆結果
3. map 向 filter 要一筆結果
4. filter 向 source 要一筆值

5. source 產生 1
6. filter 拒絕 1,還不能交付結果
7. filter 再向 source 要一筆值

8. source 產生 2
9. filter 放行 2
10. map 把 2 轉成 20
11. take 交付 20
12. consumer 得到 { value: 20, done: false }

注意第 6 步:consumer 雖然只呼叫了一次 next(),但 filter() 為了找出第一個符合條件的值,已經向 source 拉了兩筆資料。

所以 lazy 不能理解成「一次只跑一個步驟」,而是:

沒有下游請求時不執行;有一個下游請求時,只做足以交付那一筆結果的工作。


為什麼 filter() 特別容易讓人看見 pull?

map() 的情況通常很直覺:從上游拿一筆、轉換一筆、交付一筆。

filter() 不一樣。前一節已看到它可能略過 1、直到 2 才交付第一筆結果。如果條件更晚才成立,這個差異會更明顯:

const onlyFive = Iterator.from([1, 2, 3, 4, 5])
  .filter((x) => x === 5);

console.log(onlyFive.next());
// { value: 5, done: false }

外層只有一次 onlyFive.next(),但 filter 必須讀完 15 找到符合條件的值,才能交付第一筆結果。

這不是 eager;它仍然是 lazy。因為那些讀取都發生在 consumer 真的要求第一筆結果之後,不是建立 onlyFive 的時候。


take 的功能

回到前面的 result

console.log(result.next());
console.log(result.next());

接下來會依序找出來源中的 46,並交付 4060

第二次 next():source 3 被 filter 丟掉 → source 4 通過 → 得到 40
第三次 next():source 5 被 filter 丟掉 → source 6 通過 → 得到 60

現在 take(3) 已經交付三筆結果。即使 source 還能繼續產生 789,後續的 result.next() 只會得到:

{ value: undefined, done: true }

它不必再向 source 要資料。這也是 take() 有價值的原因:它把「最多需要幾筆」放進 pipeline,讓上游不需要白做更多工作。


同樣都是 lazy,但每個 helper 的 pull 行為不同

以下 ES2025 Iterator helpers 在呼叫當下都不會讀取 source iterator;它們都回傳新的 iterator,等下游真正要資料時才開始工作。

Helper consumer 要一筆時,可能發生什麼事?
map() 向上游取一筆,轉換後交付一筆。
filter() 可能向上游取多筆,直到找到符合條件的值。
drop(n) 第一次交付前,會先取走並丟掉最多 n 筆。
take(n) 最多交付 n 筆;額度用完後不必再向上游取值。
flatMap() 取到一筆上游資料後,可能把它展開成多筆下游資料。

例如 drop(2) 不會在建立時立刻跳過資料:

const afterTwo = Iterator.from([1, 2, 3, 4]).drop(2);

直到 afterTwo.next(),它才會真的讀取並捨棄 12,然後交付 3

因此這些 API 的共同點不是「每次只讀一筆」,而是:

先描述資料怎麼處理;等有人要結果,才只讀取當下必需的資料。


Helper 的順序也會影響工作量?

Lazy 不代表 pipeline 的排列順序無關緊要。例如先 filter()map(),mapper 只會處理通過條件的值:

const result = source
  .filter(isEligible)
  .map(expensiveTransform)
  .take(3);

如果先 map()filter(),被 filter 丟掉的值也已做過轉換。兩種順序的結果是否相同,仍取決於 callback 的語意;不能只為了效能任意交換,但可以把較能縮小資料量的步驟放在前面評估。


Lazy 的實際價值:處理無限資料來源

這個特性在來源很大、昂貴,或根本沒有盡頭時特別重要。

function* ids() {
  let id = 1;

  while (true) {
    yield id++;
  }
}

const firstThreeUsers = ids()
  .filter((id) => id % 2 === 0)
  .map((id) => `user-${id}`)
  .take(3)
  .toArray();

console.log(firstThreeUsers);
// ["user-2", "user-4", "user-6"]

ids() 可以永遠產生資料,但這段程式只需要讀到 6

不是因為 JavaScript 先建立了一個無限大的陣列再切前三筆;而是 toArray() 開始要求結果後,take(3) 在拿到三筆資料時就讓整條 pipeline 停下來。


今日總結

可以不用死背每個 Helper 的內部細節,先記住這張圖:

consumer ──pull request──▶ lazy helpers ──▶ source
consumer ◀───value──────── lazy helpers ◀── source

map()filter()take()drop()flatMap() 都是 lazy transformations:

  • 建立 pipeline 時,不會立即讀取 source。
  • 下游要求一筆時,才向上游 pull。
  • 為了交付那一筆結果,某些 helper 可能需要讀取多筆上游資料。
  • 讀完 consumer 真正需要的數量後,來源不必再被繼續消費。

下一篇要補上最後一塊:既然 transformation 只是等待下游 pull,那 toArray()reduce()some()find() 這些操作,為什麼一呼叫就會讓整條 pipeline 真的跑起來?


參考資料


上一篇
Day 06|Iterator 也有 map / filter 了:ES2025 Iterator Helpers
系列文
30 天新世代 JavaScript 自我學習指南7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言