iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
JavaScript

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

Day 03|for...of 怎麼拿資料?next()、break 與提前離開時的收尾

  • 分享至 

  • xImage
  •  

摘要

  • 一句摘要for...of 從 Day 02 的 iterable 取得 iterator 後,會一次呼叫一次 next(); 如果我們用 break 提早離開,iterator 內部會收到中斷流程通知。
  • 前置知識:知道 iterable 提供 [Symbol.iterator](),iterator 提供 next()
  • 學習路線:觀察 for...of 如何消費 iterator 與提前停止
  • 標籤ES2015 for...of IteratorResult IteratorClose

今日學習目標

平常寫 for...of 時,迴圈變數會在每一輪自動拿到資料:

for (const task of ["整理需求", "撰寫內容"]) {
  console.log(task);
}

task 是怎麼得到不同值的?

上次已經理解到 for...of 如何從 iterable 的 [Symbol.iterator]() 取得 iterator。

今天要接著追蹤 for...of 如何向它要下一筆、把回傳結果帶進迴圈本體;以及迴圈用 break 提早停止時,為何還需要通知來源收尾結束。

https://ithelp.ithome.com.tw/upload/images/20260906/20145251zfk2Ht4Hgi.png


先用 TC39 名稱對齊這條流程

TC39 是制定 ECMAScript(JavaScript 語言標準)的技術委員會。

GetIterator、IteratorResult、IteratorClose 是規格用來描述引擎行為的抽象操作名稱;它們不是開發者要手寫的 JavaScript 程式碼,也不是執行時會原封不動出現的函式呼叫。

它們的用途是讓不同 JavaScript 引擎對 for...of 實作出一致的語言行為,就好像開發一段需求時我們需要知道它如何啟動和結束。

先把這篇的心智地圖放在眼前。這些名稱不是另一套規則,而是 TC39 規格如何描述同一個 for...of 行為;接下來再逐步把它們展開:

讀者看到的行為 對應的規格概念 這篇要觀察的事
開始讀取 GetIterator 來源能否提供可呼叫的 [Symbol.iterator](),並交出 iterator。
要下一筆 iterator 的 next()IteratorResult 每次交付的 { value, done },如何把 value 帶進迴圈變數。
自然讀完 done: true iterator 如何表示已沒有下一筆,讓迴圈停止。
還沒讀完就離開 IteratorClose break 如何讓 for...of 嘗試通知來源收尾。

for...of 怎麼把每一筆帶進迴圈?

把上面的 for...of 想成這條流程即可。這是為了理解而寫的展開,不是 JavaScript 規格的逐字轉寫:

const tasks = ["整理需求", "撰寫內容"];
const iterator = tasks[Symbol.iterator]();

while (true) {
  const result = iterator.next();

  if (result.done) break;

  const task = result.value;
  console.log(task);
}

每次 next() 都交付一個 { value, done }done 還不是 true 時,for...of 才把 value 帶進該輪迴圈;概念上就像 const task = result.value。因此它不是先收集所有資料,而是「要一筆,才拿一筆」。

這也說明了 for...of 為什麼不能接受任意物件:它必須先取得能提供 next() 的 iterator。只有索引與 length、卻沒有可呼叫 [Symbol.iterator]() 的物件:

const taskLike = { 0: "整理需求", length: 1 };

// for (const task of taskLike) {
//   console.log(task);
// }
// TypeError: taskLike is not iterable

錯誤不是因為 taskLike 沒有資料,而是 for...of 找不到可呼叫的 [Symbol.iterator](),無法取得 iterator。接下來用可追蹤的來源,讓這些原本看不見的互動實際印出來,這也是 TC39 中看到的第一步 GetIterator 在描述處理的流程。


用一個可追蹤的 iterator 看完整流程

假設有一個工作佇列。tasks 只負責 iterable 的入口;它交出的另一個物件才保存讀取進度,並負責 next() 與可選的 return()。這個例子故意不先把資料轉成另一個 Array,方便我們看見每次互動。

const tasks = {
  [Symbol.iterator]() {
    console.log("[iterable] 交出一個 iterator");

    const items = ["整理需求", "撰寫內容", "發布文章"];
    let index = 0;

    return {
      next() {
        console.log("[iterator] 收到 next() 請求");

        if (index === items.length) {
          const result = { done: true };
          console.log("[iterator] 交付", result);
          return result;
        }

        const result = { value: items[index++], done: false };
        console.log("[iterator] 交付", result);
        return result;
      },

      return() {
        console.log("[iterator] 收到 return():不再需要工作,準備收尾");
        return { done: true };
      },
    };
  },
};

for (const task of tasks) {
  console.log("[for...of] 處理:", task);

  if (task === "撰寫內容") {
    break;
  }
}

輸出會像這樣:

[iterable] 交出一個 iterator
[iterator] 收到 next() 請求
[iterator] 交付 { value: '整理需求', done: false }
[for...of] 處理: 整理需求
[iterator] 收到 next() 請求
[iterator] 交付 { value: '撰寫內容', done: false }
[for...of] 處理: 撰寫內容
[iterator] 收到 return():不再需要工作,準備收尾

把這次執行對回本文開頭的 TC39 地圖,會得到這三步:

範例中的動作 對應的規格概念 本次輸出
for...oftasks 取得 iterator GetIterator [iterable] 交出一個 iterator
每輪索取下一筆,並依結果決定是否繼續 next()IteratorResult 收到 next()、交付 { value, done }
break 在尚未讀完時提早離開 IteratorClose [iterator] 收到 return()

這張表是把規格概念對回範例的閱讀地圖,不是規格演算法的逐字轉寫;接下來再拆開看每一步。

最容易忽略的地方是:for...of 把呼叫細節藏起來了。它開始執行時,會向 tasks 取得 iterator;每一輪再向該 iterator 要下一筆。這不是語言在解析程式碼時額外插入的呼叫,而是 for...of 在執行期遵循的行為。


for 迴圈本體的控制流程,如何影響 iterator?

for...of 不只負責向 iterator 要資料,也會觀察迴圈本體如何結束。

若本體正常跑完,它才呼叫下一次 next();若 iterator 回報 done: true,迴圈自然結束,不需要收尾通知。

反過來,只要在 iterator 尚未完成前以 break、函式內的 return 或例外離開,consumer 就表示不再需要後續資料;TC39 將 for...of 通知來源收尾的流程稱為 IteratorClose

這個範例中,第二筆進入本體後執行 break,代表不再需要第三筆。

for...of 會先嘗試取得 iterator 的 .return();若它存在且可呼叫,就會呼叫它,之後才真正離開迴圈。輸出中的 return() 正是這個由流程控制觸發的隱藏步驟,不是範例手動呼叫。

  • break 只離開迴圈(或 switch);

  • return 則不是 for...of 的控制指令,而是由外層函式 body 允許、用來結束函式的語句。因此 for...of 位於函式內時可以寫 return,在頂層則不能寫;這不是 for...of 特別禁止,而是 Script 的文法不允許 return

function findDraft(iterable) {
  for (const task of iterable) {
    if (task === "撰寫內容") {
      return task;
    }
  }
}

三種提前離開的去向不同,收尾步驟則一致:

break
→ IteratorClose
→ 若存在,呼叫 iterator.return()
→ 繼續執行迴圈後的程式

函式內 return
→ IteratorClose
→ 若存在,呼叫 iterator.return()
→ 函式回傳值

throw
→ IteratorClose
→ 若存在,呼叫 iterator.return()
→ 例外繼續往外傳

不過在閱讀 TC39 規範時會發現 IteratorClose,再檢查 iterator資料來源有沒有提供 .return() 這個實作方法。

但其實在實務上,我們常用的控制流程通常寫在 for...of 本體裡面就很夠用了:

break
→ IteratorClose
→ 讀取 iterator.return
  ├─ 不存在 → 直接完成收尾並離開迴圈
  ├─ 是可呼叫的 function → 呼叫 return()
  └─ 存在但不可呼叫 → 拋出 TypeError

for...of 想成一段資料消費過程

for...of 可以看成一個向資料來源逐筆索取資料的 consumer(消費端)
tasks 這類 iterable 則是資料來源的入口;它交出的 iterator 才負責保存進度,並在 consumer 每次提出請求時提供下一筆。

整個過程是:

  1. consumer(for...of)先向 iterable 取得 iterator(規格中的 GetIterator)。
  2. consumer 每需要一筆資料,就呼叫 iterator.next();iterator 回傳 IteratorResult { value, done }
  3. done 不是 true 時,consumer 把 value 交給迴圈本體處理,接著再決定要不要索取下一筆。
  4. iterator 回報 done: true 時,代表資料自然讀完;若 consumer 先用 break、函式內 return 或 throw 離開,則進入 IteratorClose,通知來源不再需要後續資料。

因此 for...of 不會先把所有資料複製成 Array;它是「需要一筆,才拿一筆」。資料可以早已存在,也可以等到 next() 被呼叫時才產生。


還有哪些常見語法會調用 iterator?

主線已看見:for...of 需要 iterable,只有 length 和索引的物件不能直接用它。還有幾種常見語法,也會先向來源取得 iterator,再逐次讀取:

語法 如何使用 iterator
[...iterable] 讀取所有值,建立一個新的 Array。
fn(...iterable) 讀取每一筆,依序作為函式引數。
const [first, second] = iterable 讀取解構所需的值;若尚未讀完就停止,會嘗試關閉 iterator。

這些語法都不能直接接受只有 length 和索引的 array-like。

物件展開 { ...object } 則是另一條規則:它複製自身可列舉屬性,與 iterator 無關。

早期學JS展開運算子應該多少看過下面錯誤,終於知道原因爲何:

const arrayLike = { 0: "A", 1: "B", length: 2 };

// [...arrayLike];        // TypeError: arrayLike is not iterable
// console.log(...arrayLike); // TypeError: arrayLike is not iterable

const copiedObject = { ...arrayLike };
// { 0: "A", 1: "B", length: 2 }

Array.from(arrayLike); // ["A", "B"]

Array.from() 是這裡的特例:它的契約更寬。若來源有 iterable 入口,就依 iterator 逐筆讀取;若沒有 iterable 入口,才可改以 array-like 的 length 與索引建立 Array。

Array.from({ 0: "A", 1: "B", length: 2 });
// ["A", "B"]

不論走哪一條路徑,Array.from() 都會建立一個新的 Array;差別只在它怎麼取來源的值:

  • 來源是 iterable 時,它會取得 iterator 並逐次取值。若來源本身是只能讀一次的 iterator,這個過程會推進、甚至讀完它。
  • 來源只是 array-like 時,它不需要 iterator,而是依 length 讀取 0length - 1 的索引。

[...iterable]Array.from(iterable) 都會建立新的 Array,但都只是淺層處理:遇到物件值時,放進新 Array 的仍是同一個物件參考。fn(...iterable) 也會逐次讀取,但只是把每一筆交成函式引數,語意上不是建立一個可供使用的新 Array。

這不是 for...of 的例外規則,也不表示 array-like 自動變成 iterable;只是 Array.from() 額外支援另一條來源路徑。若物件明確提供了非 function 的 [Symbol.iterator],那是壞掉的 iterable 入口,Array.from() 會拋出錯誤,而不是悄悄退回 array-like 路徑。


今日總結

  • for...of 作為 consumer,會先取得 iterator(GetIterator),再逐次呼叫 next();每個 IteratorResult { value, done } 決定是否把 value 交給迴圈本體。
  • done: true 表示 iterator 自然完成;若在此之前用 break 或從函式 return 離開,for...of 會進入 IteratorClose,嘗試呼叫可選的 .return(),通知來源可自行收尾。
  • spread 與解構需要 iterable;Array.from() 除了 iterable,也有 array-like 的來源路徑。

下一篇預告

我們已經看見手寫 iterator 要自己保存位置、組出 { value, done },還可能實作收尾通知。

下一篇會改從程式碼的資料源頭角度出發:Generator 如何把「暫停在這裡、下一次再繼續」寫得像一般的程式?


參考資料


上一篇
Day 02|為什麼 for...of 看得懂不同資料結構?
下一篇
Day 04|Generator 的雙重身分:它同時是 Iterator 也是 Iterable
系列文
30 天新世代 JavaScript 自我學習指南7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言