for...of 不需要認識 Array、Set 或字串的內部結構;它只找共同的 Symbol.iterator 入口。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(迭代協議)。

ECMAScript 是 JavaScript 的語言規範;Web API 則是宿主環境(例如瀏覽器)提供的額外 API。
for...of、Symbol.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 Standard、Web IDL Standard |
這張表是為了理解本文提到的 NodeList 與迭代協議。Web 平台的規格來源很多,並不是每一個 Web API 都只由 WHATWG 制定;實際查資料時,仍要以該 API 的原始規格頁為準。
以函式中的 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、arguments、NodeList、Set……資料結構一多,規則就會分散且難以擴充。
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 } 格式的結果。
回到一開始的困擾。這個物件有索引和 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
Symbol.iterator,不是一般的 .iterator()?如果每種資料各自命名 .nextItems()、.values() 或 .getCursor(),語言就不知道應該找哪一個。把入口定成 Symbol.iterator 後,for...of、spread、解構賦值等語法,以及認得 iterable 的 API,都可以沿用同一份規則。
Symbol 也避免一般欄位撞名。使用者資料可能有 iterator、items 或 values 這種字串 key;Symbol.iterator 則是語言保留的、獨一無二的 property key。它不是神祕語法,而是標示「這裡是迭代協議入口」的方式。
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...in和Object.entries()都不會列出它。
總而言之,只有在你的自訂資料結構確實有穩定的迭代順序與意義時,或者像我們舉的 playlist 範例,才需要對物件去實作 Symbol.iterator。
Symbol 是 ES2015 加入的 primitive value,可以當 object property key。Symbol.iterator 是語言預先定義的 well-known symbol;它不是每次呼叫 Symbol("iterator") 都重新建立的 Symbol,而是所有 iterable 共用的協議 key。
同一套設計也出現在 Symbol.toPrimitive、Symbol.toStringTag 等語言協議。實務上也可用 const internalKey = Symbol("internal") 避免內部欄位撞名;不過它不是私有化機制,持有該 Symbol 的程式仍可讀取。
length,不足以表達不同資料結構「下一筆怎麼拿」。for...of 看的是 iterable protocol,不是資料是否像 Array;iterable 提供入口,iterator 以 next() 逐筆交付 { value, done }。for...of 讀取;Array.from() 額外支援這類來源,協助舊式資料轉成 Array。現在我們知道,for...of 會先從 iterable 的 Symbol.iterator 取得 iterator。下一篇要把這段流程展開:它什麼時候呼叫 next()?讀到一半 break時,又如何通知 iterator 結束這次走訪?