iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
JavaScript

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

Day 09|Iterator 改變我看待程式執行的方式:從整批處理到 Lazy 資料流

  • 分享至 

  • xImage
  •  

摘要

  • 一句摘要:Iterator 真正改變的不只是處理資料的 API,而是程式的執行方式:先描述資料怎麼流動,等 consumer 需要時才推進一步。

  • 前置知識:知道 Generator 與 Iterator Helpers 的基本用法,並理解 Day 07、08 介紹的 lazy pipeline 與 consuming operation。

  • 學習路線:回看 Iterator 的 lazy 特性如何改變執行時機、停止時機與記憶體使用;下一篇進入 Promise,準備處理「結果還沒出現」的情況。

  • 標籤ES2025 Array Generator Iterator Helpers Pull Model


回顧目前的自我學習

剛接觸 Iterator 時,我以為它只是另一種走訪資料的方法:Array 用 index,Iterator 用 next()

但一路看到 Generator、Iterator Helpers 與 lazy pipeline 之後,真正讓我改觀的不是 API 寫法,而是這件事:

程式碼寫下來,不代表所有工作都必須立刻做完,或者説程式碼讀下來不是一次直執行到底。

過去使用 Array methods 時,我很自然地把程式想成「這一行做完整批 map(),下一行再做完整批 filter()」。
Iterator 換了一個角度:先把資料如何流動的規則接起來,等 consumer 索取或開始調用結果時,才做當下必要的工作。

先用一張白話圖抓住這個改變:

Array
→ 先把整批資料處理完
→ 再從結果中拿需要的部分

Iterator
→ consumer 要一筆
→ pipeline 才做足以交出這一筆的工作
→ 拿夠了就能停

一、第一個改觀:寫下 pipeline,不等於工作已經完成

假設我們有五個數字,要「乘以 2、大於 5、取前兩筆」。

Array 寫法:

const result = [1, 2, 3, 4, 5]
  .map((x) => x * 2)
  .filter((x) => x > 5)
  .slice(0, 2);

console.log(result);
// [6, 8]

它像三位各自完成整批工作的工人:

[1, 2, 3, 4, 5]
        ↓ map:五筆都乘以 2
[2, 4, 6, 8, 10]
        ↓ filter:五筆都檢查
[6, 8, 10]
        ↓ slice:最後才知道只要兩筆
[6, 8]

Iterator Helpers 寫法很像:

const result = Iterator.from([1, 2, 3, 4, 5])
  .map((x) => x * 2)
  .filter((x) => x > 5)
  .take(2)
  .toArray();

console.log(result);
// [6, 8]

但它不是先做完 map(),再做完整的 filter()toArray() 開始索取資料後,每個值才逐筆通過 pipeline:

1 → 2 → 不符合,繼續找
2 → 4 → 不符合,繼續找
3 → 6 → 收下第 1 筆
4 → 8 → 收下第 2 筆
              ↓
        take(2) 已滿,停止

5 → 不必處理

這就是 Day 07、08 的 pull 與 consuming operation 放回整體後的意義:程式可以先描述流程,執行則由 consumer 的需求啟動。

Lazy 不是保證比較快;它先讓還不需要的工作有機會不要發生。

https://ithelp.ithome.com.tw/upload/images/20260912/201452519CrVNnRKFn.png


二、第二個改觀:程式不一定要把全部做完

如果有 1,000 萬筆資料,最後只需要:

乘以 2 之後,大於 1000 的前 10 筆。

先讓兩種寫法共用同一個 Array,只比較 pipeline 做了多少工作:

const SIZE = 10_000_000;
const numbers = Array.from(
  { length: SIZE },
  (_, index) => index + 1,
);

let arrayMapCount = 0;
let arrayFilterCount = 0;

const arrayResult = numbers
  .map((x) => {
    arrayMapCount++;
    return x * 2;
  })
  .filter((x) => {
    arrayFilterCount++;
    return x > 1000;
  })
  .slice(0, 10);

let iteratorMapCount = 0;
let iteratorFilterCount = 0;

const iteratorResult = numbers
  .values()
  .map((x) => {
    iteratorMapCount++;
    return x * 2;
  })
  .filter((x) => {
    iteratorFilterCount++;
    return x > 1000;
  })
  .take(10)
  .toArray();

console.log(arrayResult);
console.log(arrayMapCount, arrayFilterCount);
// [1002, 1004, ..., 1020]
// 10000000 10000000

console.log(iteratorResult);
console.log(iteratorMapCount, iteratorFilterCount);
// [1002, 1004, ..., 1020]
// 510 510

兩邊得到相同的 10 筆結果,但把 callback 次數畫開來,工作量完全不同:

Array pipeline
→ map 10,000,000 次
→ filter 10,000,000 次
→ 最後 slice(0, 10)

Iterator pipeline
→ 前 500 筆都未通過 filter
→ 第 501~510 筆得到 10 個結果
→ map 510 次、filter 510 次後停止
1   → 2     → 不符合
...
500 → 1000  → 不符合

501 → 1002  → 第 1 筆
...
510 → 1020  → 第 10 筆,停止

有趣的地方:下游條件會改變上游工作量

為什麼是 510 次?,我不是只用 take 只取我要的 10 筆,程式碼跑了 500 多次~

可以把 take(10) 想成在最下游提出需求(要求又特別多的(誤))的 PM:

「我要 10 筆完成整段流程、符合條件的資料。」

PM 要的是 10 筆合格成品,不是叫上游「只讀 10 筆原始資料」。如果中間的品管部門 filter() 淘汰了一筆,就要繼續請加工部門 map() 處理下一筆,再向 source 補一筆原料。
這個需求會沿著 pipeline 一路往上傳。:

https://ithelp.ithome.com.tw/upload/images/20260912/20145251lBWb0rGVC3.png

toArray:我要結果
  ↓
take(10):我還缺合格結果
  ↓
filter:我得繼續找下一筆 x > 1000
  ↓
map:請給我下一個來源值來轉換 
// 找到符合下游單位要的才停止 當然如果不是連續型資料 有可能要再向資料源 pull 更多次
  ↓
source:交出下一筆

因此,為了交出第一筆合格結果,filter() 會淘汰前 500 筆,連帶讓上游的 map() 執行 501 次。再找到後面 9 筆,總數才來到 510 次。

每一層 Helper 都可能改變上下游之間的筆數關係:

操作 對上游 pull 次數的影響
map() 一筆輸入產生一筆輸出,通常是 1:1
filter() 可能淘汰資料,為了一筆輸出可能 pull 很多次
take(10) 收到 10 筆輸出後,讓整條 pipeline 停止
drop(10) 必須先 pull 並丟掉 10 筆,之後才開始輸出
flatMap() 一筆輸入可能展開成多筆輸出,來源筆數可能反而較少

Helper 的順序也會影響各層的工作量。這個例子可以在不改變結果的前提下,先用原始值篩選:

let filterFirstCount = 0;
let mapAfterCount = 0;

const result = numbers
  .values()
  .filter((x) => {
    filterFirstCount++;
    return x > 500;
  })
  .map((x) => {
    mapAfterCount++;
    return x * 2;
  })
  .take(10)
  .toArray();

console.log(result);
console.log(filterFirstCount, mapAfterCount);
// [1002, 1004, ..., 1020]
// 510 10

這時 filter() 仍要檢查 510 筆,但只有通過的 10 筆會進入 map()

下游決定需要多少輸出;每一層 Helper 的行為與排列順序,決定上游要為此 pull 多少次,這裡的上游是指每一段處理過程所對接的上游。

最大跟 Array methods 差異關鍵不是某台電腦跑了幾毫秒,而是後面 9,999,490 筆根本沒有進入 Iterator pipeline,只是剛好在閱讀上面程式碼時看到對於中間 pull 過程跟腦袋直覺不一樣,解惑一下~。

不過,這不代表任何情況都該換成 Iterator。若資料只有幾十筆,而且最後仍需要完整 Array,直接使用 Array methods 通常更清楚。


三、第三個改觀:資料怎麼流動,也會影響記憶體

假設那個 1,000 萬筆的 numbers Array 已經存在:

const numbers = Array.from(
  { length: 10_000_000 },
  (_, index) => index + 1,
);

對它呼叫 numbers.values(),不會讓原始 Array 消失。

Iterator Helpers 主要省下的是轉換過程中的完整中間 Array,並避免處理拿不到的後段資料:

Array pipeline

原始 Array
  ↓
map 產生完整新 Array
  ↓
filter 再產生完整新 Array
  ↓
slice 產生最後 10 筆
Iterator pipeline

原始 Array(仍然存在)
  ↓
map → filter → take(逐筆通過)
  ↓
toArray 收下最後 10 筆

所以不要把它記成「Iterator 不占記憶體」。比較準確的是:

Iterator 不必為每個轉換階段建立完整 Array,也能在拿到足夠結果後停下來。

實際省下多少 bytes 仍取決於 JavaScript 引擎、資料內容與垃圾回收,不能只靠這段程式算出固定答案。


四、第四個改觀:連資料來源都可以需要時才產生

這時 Generator 才登場。

function* numbers(max) {
  for (let value = 1; value <= max; value++) {
    yield value;
  }
}

const result = numbers(10_000_000)
  .map((x) => x * 2)
  .filter((x) => x > 1000)
  .take(10)
  .toArray();

它們的分工可以想成一間餐廳:

Generator
→ 廚房:需要時才做出下一筆來源資料

Iterator Helpers
→ 流程:這一筆要經過 map、filter 還是 take

toArray()
→ 接餐的人:開始取資料,並收集最後結果

這次不但沒有建立轉換用的完整中間 Array,也不必先建立含有 1,000 萬筆資料的來源 Array。取得第 10 筆結果後,Generator 就不必繼續產生。

因此 Generator 與 Iterator Helpers 不是二選一:

Generator 管「資料怎麼一筆筆出現」;Iterator Helpers 管「每一筆要怎麼處理」。


五、把這些改觀放回日常選型

可以先問自己:「我手上是一整批資料,還是一條可能會持續出現的資料流?」

情況 比較適合 白話理由
資料量不大,最後需要完整結果 Array methods 資料本來就在手上,直接處理最清楚
需要 length、index 或重複遍歷 Array Iterator 像只能向前走的游標,不適合一直回頭
資料很大,但只需要前幾筆 Iterator Helpers 拿夠就停,後面的轉換不必做
不想為每個 map()filter() 建立完整中間 Array Iterator Helpers 值可以逐筆通過 pipeline
來源可以現做,不必先全部存在 Generator consumer 要下一筆時才產生下一筆
最後確實需要完整 Array 在邊界呼叫 toArray() 前面保持逐筆處理,最後再一次收集

再濃縮成一張圖:

https://ithelp.ithome.com.tw/upload/images/20260913/20145251oPx2ZyuXRS.png

理解 lazy 之後慢慢改變我的想法,不再只問「哪一個 API 比較快」,而會先問:

  • 哪些工作會發生?
  • 什麼時候發生?
  • 做到哪裡可以停?
  • 同時需要保留多少資料?

六、從「需要時才執行」走向「下一筆還在未來」

到目前為止,同步 Iterator 的 next() 都能立刻回傳答案:

consumer:給我下一筆
iterator:拿去,{ value, done }

但網路回應、串流 stream chunk 或後端發送的 SSE 事件等可能還沒抵達:

consumer:給我下一筆
資料來源:現在還沒有,未來才知道成功或失敗

程式也會從:

iterator.next();

延伸成 JavaScript 常見的非同步世界:

await asyncIterator.next();

https://ithelp.ithome.com.tw/upload/images/20260912/2014525153OsaLdANL.png

要理解這個 await 等..,我們還需要回顧一些基礎:JavaScript 如何表示「現在沒有、未來才會出現」的結果?

下一篇開始重新溫習一下非同步的一些基礎吧。


參考資料

  1. ECMAScript 2025 — Array.prototype.map()
  2. ECMAScript 2025 — Array.prototype.filter()
  3. ECMAScript 2025 — Array.prototype.slice()
  4. ECMAScript 2025 — Iterator Objects and Iterator Helpers

上一篇
Day 08|哪些 Iterator Helpers 真的會消費 Iterator?
下一篇
Day 10|重新看 Promise:它不是非同步魔法,而是結果的狀態容器
系列文
30 天新世代 JavaScript 自我學習指南14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言