iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
JavaScript

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

Day 02|為什麼 for...of 看得懂不同資料結構?

  • 分享至 

  • xImage
  •  

摘要

  • 一句摘要for...of 不需要認識 Array、Set 或字串的內部結構;它只找共同的 Symbol.iterator 入口。
  • 前置知識:會使用 Array、Object 與 for...of
  • 學習路線:理解「資料結構不同,為什麼仍能逐筆讀取」
  • 標籤ES2015 Symbol.iterator Iterable Iterator Protocol Array-like

今日學習目標

上次結尾有提到,為什麼下面三種看起來很不一樣的值,都可以交給 for...of

for (const value of ["A", "B"]) {}
for (const value of new Set(["A", "B"])) {}
for (const value of "AB") {}

答案不是它們「都像 Array」。ES6 以前,JavaScript 就有不少看起來像 Array 的值;它們有索引、有 length,卻不是 Array,也不能自然地共用處理方式。for...of 能看懂不同結構,是因為直到 ES6改用一個共同約定:iteration protocol(迭代協議)

https://ithelp.ithome.com.tw/upload/images/20260905/20145251zbD0tGJGep.png


先理解 ECMAScript 與 Web API 是不同層次

ECMAScript 是 JavaScript 的語言規範;Web API 則是宿主環境(例如瀏覽器)提供的額外 API。

for...ofSymbol.iterator、Array、Set 與字串的迭代規則,屬於 ECMAScript:也就是 JavaScript 語言本身的標準。

document.querySelectorAll() 回傳的 NodeList,則是瀏覽器提供的 Web API/DOM 物件,不是 ECMAScript 的 Array。

不過 Web API 可以選擇採用 ECMAScript 的迭代協議。現代瀏覽器中的 NodeList 提供了 [Symbol.iterator](),所以可以交給 for...of;但它不會因此變成 Array,也不保證有所有 Array 方法。

這正好說明迭代協議的價值:語言不需要知道資料來自 Array、Set,還是瀏覽器的 DOM 物件;但只要它們提供共同入口,就能被同一套 for...of 規則使用。

想查原始對應文件時,可以先用這個對照:

層次 制定/維護組織 本文對應文件
ECMAScript 語言 Ecma International 的 TC39 ECMAScript Language Specification
DOM/Web API WHATWG DOM StandardWeb IDL Standard

這張表是為了理解本文提到的 NodeList 與迭代協議。Web 平台的規格來源很多,並不是每一個 Web API 都只由 WHATWG 制定;實際查資料時,仍要以該 API 的原始規格頁為準。


ES6 之前的歷史:array-like 的麻煩

以函式中的 arguments 為例,它可以用索引取值,也有 length

function logArguments() {
  console.log(arguments[0]); // "Ada"
  console.log(arguments.length); // 2

  // arguments.map(...) // TypeError: arguments.map is not a function
}

logArguments("Ada", "Lin");

這類有數字索引與 length 的值,通常稱為 array-like

早期 DOM 的 NodeList 也常是這種情況。它們「像陣列」,但不會因此繼承 Array.prototype;例如 arguments 沒有 .map()。現代 NodeList 雖然有自己定義的 .forEach(),仍沒有完整的 Array methods。

在 ES5 時代,若想用 Array 的方法,常得先借用或轉換:

function names() {
  const list = Array.prototype.slice.call(arguments);
  return list.map((name) => name.toUpperCase());
}

names("ada", "lin"); // ["ADA", "LIN"]

問題在於:只有索引和 length,只能描述「我可以用第幾格讀取」。它沒有回答更通用的問題:要怎麼從這個資料來源取得下一筆?

不同資料結構的答案可能完全不同:Array 依索引前進、Set 依插入順序取值、字串要依 Unicode code point 逐個字元讀取。若每個 API 都各自判斷 Array、argumentsNodeList、Set……資料結構一多,規則就會分散且難以擴充。


ES6 統一規範:真正的迭代協議

ES6 沒有要求所有資料都變成 Array,而是定義共同的協議。先記兩個角色:

角色 責任 可以想成
Iterable 提供 [Symbol.iterator](),建立 iterator 一本可以從頭開始閱讀的書
Iterator 提供 next(),逐筆回傳 { value, done } 目前讀到哪一頁的書籤

也就是:iterable 提供入口;iterator 負責交出下一筆。

for...of 不必知道資料是 Array 還是 Set。它只做同一件事:呼叫值的 [Symbol.iterator](),拿到 iterator,然後持續呼叫 next(),直到收到 done: true

for...of
  ↓ 找 [Symbol.iterator]()
iterable
  ↓ 回傳 iterator
iterator
  ↓ next() → { value, done }
下一筆資料

這就是 for...of 看得懂不同資料結構的真正原因:不是它內建了每一種資料結構的特例,而是它們都接上同一份協議。


讓自己的資料可以被 for...of 讀取

Array、Set 與字串等內建 iterable,JavaScript 已經替它們定義好 [Symbol.iterator]()。以 Array 為例,這個方法定義在 Array.prototype,因此 Array instance 可以直接呼叫它,取得專門負責逐筆取值的 iterator:

const letters = ["A", "B"];
const iterator = letters[Symbol.iterator]();

console.log(iterator.next()); // { value: "A", done: false }
console.log(iterator.next()); // { value: "B", done: false }
console.log(iterator.next()); // { value: undefined, done: true }

呼叫 [Symbol.iterator]() 的結果不是原本的 Array,而是另一個 iterator;for...of 在背後正是依序呼叫這個 iterator 的 .next()

假設頁面有一份播放清單。它不是 Array,但可以明確表示自己知道怎麼逐首提供歌曲:

const playlist = {
  songs: ["Blue Train", "So What", "Take Five"],

  [Symbol.iterator]() {
    return this.songs[Symbol.iterator]();
  },
};

for (const song of playlist) {
  console.log(song);
}
// Blue Train
// So What
// Take Five

playlist 本身不是 Array;重點是它提供 [Symbol.iterator](),並回傳一個 Array iterator。於是 for...of 不需要知道歌曲放在哪個 property,也不需要新增一個 forPlaylist();只要遵守共同協議即可。

這不是隨便放同名欄位就行:

const broken = { [Symbol.iterator]: "not a function" };
// for (const value of broken) {}
// TypeError: broken is not iterable

要成為 iterable,[Symbol.iterator] 必須是可呼叫的 function;呼叫後還必須交出能提供 .next() 的 iterator。

迭代協議不只規定入口是 [Symbol.iterator],也規定它必須回傳具有 .next() 方法的 iterator,而 .next() 要交回 { value, done } 格式的結果。


array-like 不等於 iterable

回到一開始的困擾。這個物件有索引和 length,卻不能交給 for...of

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

console.log(arrayLike[0]); // "A"
// for (const value of arrayLike) {}
// TypeError: arrayLike is not iterable

它只提供了「第幾格」的外觀,沒有提供迭代協議入口。因此 for...of 不會猜測該怎麼走訪它。

不過 Array.from() 同時支援 iterable 和 array-like,正好是新舊世界的橋樑:

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

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

所以不要反推 for...of 也接受 array-like:兩個 API 的輸入規則不同。Array.from() 額外支援 length 與索引,是為了轉換舊式資料;for...of 則只依 iterable protocol 做通用迭代。

在現代瀏覽器中,NodeList 通常已提供 iterable 介面,所以可使用 for...of。它也有 Web API 自己定義的 .forEach();但它仍是 Web API 物件,不是 ECMAScript Array,因此沒有 .map().filter().reduce() 等完整的 Array methods。

若只是逐個處理 node,直接用 .forEach() 即可:

const nodes = document.querySelectorAll('li');

nodes.forEach((node) => {
  console.log(node.textContent);
});

若需要 map() 產生新的 Array,才先轉換:

const texts = Array.from(document.querySelectorAll('li'))
  .map((node) => node.textContent);

這不是因為 NodeList 不能逐筆處理,而是 .map() 屬於 Array 的 API;轉換後,才明確把 DOM collection 當成 Array 來操作。

有 length 與索引
→ 可能是 array-like:能依位置讀取,不保證可 for...of

有可呼叫的 [Symbol.iterator](),且回傳 iterator
→ 是 iterable:可交給 for...of、spread 等協議 consumer

為什麼入口的屬性鍵值 key 是 Symbol.iterator,不是一般的 .iterator()

如果每種資料各自命名 .nextItems().values().getCursor(),語言就不知道應該找哪一個。把入口定成 Symbol.iterator 後,for...of、spread、解構賦值等語法,以及認得 iterable 的 API,都可以沿用同一份規則。

Symbol 也避免一般欄位撞名。使用者資料可能有 iteratoritemsvalues 這種字串 key;Symbol.iterator 則是語言保留的、獨一無二的 property key。它不是神祕語法,而是標示「這裡是迭代協議入口」的方式。


普通 Object 為什麼預設不能 for...of

這不是 JavaScript 認為 Object 不能逐筆處理,而是普通 Object 沒有替你決定「下一筆」應該是什麼。若有:

const user = { name: "Ada", role: "admin" };

你可能想逐筆取得 property name、value,還是 [key, value] pair?三種選擇都合理,因此 Object 不會私自指定 [Symbol.iterator]() 的規則。

不過 Object 有另一套常見操作:property enumeration(屬性列舉)。例如,當你的意圖是讀取物件自身的欄位時,可以明確選擇:

for (const [key, value] of Object.entries(user)) {
  console.log(key, value);
}

Object.entries() 會取得 user 自身、可列舉(enumerable)的字串 key,並產生:

[["name", "Ada"], ["role", "admin"]]

這個結果是 Array;Array 本身才是 iterable,接著才由 for...of 逐筆讀取。

Object 的可列舉 properties
→ Object.entries()
→ Array of [key, value]
→ for...of

所以,這裡不是讓 Object 本身直接遵守 iteration protocol,而是先用列舉規則選出要處理的 properties,白話來說有點像是讓開發者決定取 key 或 value 值,再把結果交給可迭代的 Array。

常見的 for...in 也會列舉 property name,但它還可能包含繼承而來的 可列舉屬性 ;處理一般資料物件時,Object.entries() 通常更符合「只讀取這筆資料自己的欄位」的意圖。

所謂「可列舉」是 property 的一種設定。一般用物件字面值建立的欄位預設可列舉;但若某個 property 的 enumerable 為 false,for...inObject.entries() 都不會列出它。

總而言之,只有在你的自訂資料結構確實有穩定的迭代順序與意義時,或者像我們舉的 playlist 範例,才需要對物件去實作 Symbol.iterator


(補充)Symbol 不只用在迭代協議

Symbol 是 ES2015 加入的 primitive value,可以當 object property key。Symbol.iterator 是語言預先定義的 well-known symbol;它不是每次呼叫 Symbol("iterator") 都重新建立的 Symbol,而是所有 iterable 共用的協議 key。

同一套設計也出現在 Symbol.toPrimitiveSymbol.toStringTag 等語言協議。實務上也可用 const internalKey = Symbol("internal") 避免內部欄位撞名;不過它不是私有化機制,持有該 Symbol 的程式仍可讀取。


今日總結

  • ES6 前的 array-like 只有索引與 length,不足以表達不同資料結構「下一筆怎麼拿」。
  • for...of 看的是 iterable protocol,不是資料是否像 Array;iterable 提供入口,iterator 以 next() 逐筆交付 { value, done }
  • 協議讓 Array、Set、字串與自訂資料可由相同 consumer 處理,而不必把每一種資料結構寫成特例。
  • array-like 不保證可被 for...of 讀取;Array.from() 額外支援這類來源,協助舊式資料轉成 Array。

下一篇預告

現在我們知道,for...of 會先從 iterable 的 Symbol.iterator 取得 iterator。下一篇要把這段流程展開:它什麼時候呼叫 next()?讀到一半 break時,又如何通知 iterator 結束這次走訪?


參考資料


上一篇
Day 01|為什麼想重新學一次 JavaScript?
下一篇
Day 03|for...of 怎麼拿資料?next()、break 與提前離開時的收尾
系列文
30 天新世代 JavaScript 自我學習指南7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言