一文帶你理解:可以迭代大部分資料型別的 for…of 為什麼不能遍歷普通物件?

孤篷發表於2020-12-07

for…of 及其使用

  我們知道,ES6 中引入 for...of 迴圈,很多時候用以替代 for...inforEach() ,並支援新的迭代協議。for...of 允許你遍歷 Array(陣列), String(字串), Map(對映), Set(集合),TypedArray(型別化陣列)、arguments、NodeList物件、Generator等可迭代的資料結構等。for...of語句在可迭代物件上建立一個迭代迴圈,呼叫自定義迭代鉤子,併為每個不同屬性的執行語句。

for...of的語法:

for (variable of iterable) {
    // statement
}
// variable:每個迭代的屬性值被分配給該變數。
// iterable:一個具有可列舉屬性並且可以迭代的物件。

常用用法

{
  // 迭代字串
  const iterable = 'ES6';
  for (const value of iterable) {
    console.log(value);
  }
  // Output:
  // "E"
  // "S"
  // "6"
}
{
  // 迭代陣列
  const iterable = ['a', 'b'];
  for (const value of iterable) {
    console.log(value);
  }
  // Output:
  // a
  // b
}
{
  // 迭代Set(集合)
  const iterable = new Set([1, 2, 2, 1]);
  for (const value of iterable) {
    console.log(value);
  }
  // Output:
  // 1
  // 2
}
{
  // 迭代Map
  const iterable = new Map([["a", 1], ["b", 2], ["c", 3]]);
  for (const entry of iterable) {
    console.log(entry);
  }
  // Output:
  // ["a", 1]
  // ["b", 2]
  // ["c", 3]

  for (const [key, value] of iterable) {
    console.log(value);
  }
  // Output:
  // 1
  // 2
  // 3
}
{
  // 迭代Arguments Object(引數物件)
  function args() {
    for (const arg of arguments) {
      console.log(arg);
    }
  }
  args('a', 'b');
  // Output:
  // a
  // b
}
{
  // 迭代生成器
  function* foo(){ 
    yield 1; 
    yield 2; 
    yield 3; 
  }; 

  for (let o of foo()) { 
    console.log(o); 
  }
  // Output:
  // 1
  // 2
  // 3
}

Uncaught TypeError: obj is not iterable

// 普通物件
const obj = {
  foo: 'value1',
  bar: 'value2'
}
for(const item of obj){
  console.log(item)
}
// Uncaught TypeError: obj is not iterable

  可以看出,for of可以迭代大部分物件甚至字串,卻不能遍歷普通物件。

如何用for...of迭代普通物件

  通過前面的基本用法,我們知道,for...of可以迭代陣列、Map等資料結構,順著這個思路,我們可以結合物件的Object.values()Object.keys()Object.entries()方法以及解構賦值的知識來用for...of遍歷普通物件。

  • Object.values()Object.keys()Object.entries()用法及返回值
const obj = {
  foo: 'value1',
  bar: 'value2'
}
// 列印由value組成的陣列
console.log(Object.values(obj)) // ["value1", "value2"]

// 列印由key組成的陣列
console.log(Object.keys(obj)) // ["foo", "bar"]

// 列印由[key, value]組成的二維陣列
// copy(Object.entries(obj))可以把輸出結果直接拷貝到剪貼簿,然後黏貼
console.log(Object.entries(obj)) // [["foo","value1"],["bar","value2"]]
  • 因為for...of可以迭代陣列和Map,所以我們得到以下遍歷普通物件的方法
const obj = {
  foo: 'value1',
  bar: 'value2'
}
// 方法一:使用for of迭代Object.entries(obj)形成的二維陣列,利用解構賦值得到value
for(const [, value] of Object.entries(obj)){
  console.log(value) // value1, value2
}

// 方法二:Map
// 普通物件轉Map
// Map 可以接受一個陣列作為引數。該陣列的成員是一個個表示鍵值對的陣列
console.log(new Map(Object.entries(obj)))

// 遍歷普通物件生成的Map
for(const [, value] of new Map(Object.entries(obj))){
  console.log(value) // value1, value2
}

// 方法三:繼續使用for in
for(const key in obj){
  console.log(obj[key]) // value1, value2
}

{
  // 方法四:將【類陣列(array-like)物件】轉換為陣列
  // 該物件需具有一個 length 屬性,且其元素必須可以被索引。
  const obj = {
    length: 3, // length是必須的,否則什麼也不會列印
    0: 'foo',
    1: 'bar',
    2: 'baz',
    a: 12  // 非數字屬性是不會列印的
  };
  const array = Array.from(obj); // ["foo", "bar", "baz"]
  for (const value of array) { 
      console.log(value);
  }
  // Output: foo bar baz
}
{
  // 方法五:給【類陣列】部署陣列的[Symbol.iterator]方法【對普通字串屬性物件無效】
  const iterable = {
    0: 'a',
    1: 'b',
    2: 'c',
    length: 3,
    [Symbol.iterator]: Array.prototype[Symbol.iterator]
  };
  for (let item of iterable) {
    console.log(item); // 'a', 'b', 'c'
  }
}

注意事項

  • 有別於不可終止遍歷的forEachfor...of的迴圈可由breakthrowcontinuereturn終止,在這些情況下,迭代器關閉。
  const obj = {
    foo: 'value1',
    bar: 'value2',
    baz: 'value3'
  }
  for(const [, value] of Object.entries(obj)){
    if (value === 'value2') break // 不會再執行下次迭代
    console.log(value) // value1
  };
  [1,2].forEach(item => {
      if(item == 1) break // Uncaught SyntaxError: Illegal break statement
      console.log(item)
  });
  [1,2].forEach(item => {
      if(item == 1) continue // Uncaught SyntaxError: Illegal continue statement: no surrounding iteration statement
      console.log(item)
  });
  [1,2].forEach(item => {
      if(item == 1) return // 仍然會繼續執行下一次迴圈,列印2
      console.log(item) // 2
  })
  • For…ofFor…in對比

    • for...in 不僅列舉陣列宣告,它還從建構函式的原型中查詢繼承的非列舉屬性;
    • for...of 不考慮建構函式原型上的不可列舉屬性(或者說for...of語句遍歷可迭代物件定義要迭代的資料。);
    • for...of 更多用於特定的集合(如陣列等物件),但不是所有物件都可被for...of迭代。
      Array.prototype.newArr = () => {};
      Array.prototype.anotherNewArr = () => {};
      const array = ['foo', 'bar', 'baz'];
      for (const value in array) { 
        console.log(value); // 0 1 2 newArr anotherNewArr
      }
      for (const value of array) { 
        console.log(value); // 'foo', 'bar', 'baz'
      }

普通物件為何不能被 for of 迭代

  前面我們有提到一個詞叫“可迭代”資料結構,當用for of迭代普通物件時,也會報一個“not iterable”的錯誤。實際上,任何具有 Symbol.iterator 屬性的元素都是可迭代的。我們可以簡單檢視幾個可被for of迭代的物件,看看和普通物件有何不同:

iterator1

iterator2

iterator3

  可以看到,這些可被for of迭代的物件,都實現了一個Symbol(Symbol.iterator)方法,而普通物件沒有這個方法。

  簡單來說,for of 語句建立一個迴圈來迭代可迭代的物件,可迭代的物件內部實現了Symbol.iterator方法,而普通物件沒有實現這一方法,所以普通物件是不可迭代的。

Iterator(遍歷器)

  關於Iterator(遍歷器)的概念,可以參照阮一峰大大的《ECMAScript 6 入門》——Iterator(遍歷器)的概念

iterator

  簡單來說,ES6 為了統一集合型別資料結構的處理,增加了 iterator 介面,供 for...of 使用,簡化了不同結構資料的處理。而 iterator 的遍歷過程,則是類似 Generator 的方式,迭代時不斷呼叫next方法,返回一個包含value(值)和done屬性(標識是否遍歷結束)的物件。

如何實現Symbol.iterator方法,使普通物件可被 for of 迭代

  依據上文的指引,我們先看看陣列的Symbol.iterator介面:

const arr = [1,2,3];
const iterator = arr[Symbol.iterator]();
console.log(iterator.next()); // {value: 1, done: false}
console.log(iterator.next()); // {value: 2, done: false}
console.log(iterator.next()); // {value: 3, done: false}
console.log(iterator.next()); // {value: undefined, done: true}

  我們可以嘗試給普通物件實現一個Symbol.iterator介面:

// 普通物件
const obj = {
  foo: 'value1',
  bar: 'value2',
  [Symbol.iterator]() {
    // 這裡Object.keys不會獲取到Symbol.iterator屬性,原因見下文
    const keys = Object.keys(obj); 
    let index = 0;
    return {
      next: () => {
        if (index < keys.length) {
          // 迭代結果 未結束
          return {
            value: this[keys[index++]],
            done: false
          };
        } else {
          // 迭代結果 結束
          return { value: undefined, done: true };
        }
      }
    };
  }
}
for (const value of obj) {
  console.log(value); // value1 value2
};

  上面給obj實現了Symbol.iterator介面後,我們甚至還可以像下面這樣把物件轉換成陣列:

console.log([...obj]); // ["value1", "value2"]
console.log([...{}]); // console.log is not iterable (cannot read property Symbol(Symbol.iterator))

  我們給obj物件實現了一個Symbol.iterator介面,在此,有一點需要說明的是,不用擔心[Symbol.iterator]屬性會被Object.keys()獲取到導致遍歷結果出錯,因為Symbol.iterator這樣的Symbol屬性,需要通過Object.getOwnPropertySymbols(obj)才能獲取,Object.getOwnPropertySymbols() 方法返回一個給定物件自身的所有 Symbol 屬性的陣列。

  有一些場合會預設呼叫 Iterator 介面(即Symbol.iterator方法:

  • 擴充套件運算子...:這提供了一種簡便機制,可以將任何部署了 Iterator 介面的資料結構,轉為陣列。也就是說,只要某個資料結構部署了 Iterator 介面,就可以對它使用擴充套件運算子,將其轉為陣列(毫不意外的,程式碼[...{}]會報錯,而[...'123']會輸出陣列['1','2','3'])。
  • 陣列和可迭代物件的解構賦值(解構是ES6提供的語法糖,其實內在是針對可迭代物件Iterator介面,通過遍歷器按順序獲取對應的值進行賦值。而普通物件解構賦值的內部機制,是先找到同名屬性,然後再賦給對應的變數。);
  • yield*_yield*後面跟的是一個可遍歷的結構,它會呼叫該結構的遍歷器介面;
  • 由於陣列的遍歷會呼叫遍歷器介面,所以任何接受陣列作為引數的場合,其實都呼叫;
  • 字串是一個類似陣列的物件,也原生具有Iterator介面,所以也可被for of迭代。

迭代器模式

  迭代器模式提供了一種方法順序訪問一個聚合物件中的各個元素,而又無需暴露該物件的內部實現,這樣既可以做到不暴露集合的內部結構,又可讓外部程式碼透明地訪問集合內部的資料。迭代器模式為遍歷不同的集合結構提供了一個統一的介面,從而支援同樣的演算法在不同的集合結構上進行操作。

  不難發現,Symbol.iterator實現的就是一種迭代器模式。集合物件內部實現了Symbol.iterator介面,供外部呼叫,而我們無需過多的關注集合物件內部的結構,需要處理集合物件內部的資料時,我們通過for of呼叫Symbol.iterator介面即可。

  比如針對前文普通物件的Symbol.iterator介面實現一節的程式碼,如果我們對obj裡面的資料結構進行了如下調整,那麼,我們只需對應的修改供外部迭代使用的Symbol.iterator介面,即可不影響外部迭代呼叫:

const obj = {
  // 資料結構調整
  data: ['value1', 'value2'],
  [Symbol.iterator]() {
    let index = 0;
    return {
      next: () => {
        if (index < this.data.length) {
          // 迭代結果 未結束
          return {
            value: this.data[index++],
            done: false
          };
        } else {
          // 迭代結果 結束
          return { value: undefined, done: true };
        }
      }
    };
  }
}
// 外部呼叫
for (const value of obj) {
  console.log(value); // value1 value2
}

  實際使用時,我們可以把上面的Symbol.iterator提出來進行單獨封裝,這樣就可以對一類資料結構進行迭代操作了。當然,下面的程式碼只是最簡單的示例,你可以在此基礎上探究更多實用的技巧。

const obj1 = {
  data: ['value1', 'value2']
}
const obj2 = {
  data: [1, 2]
}
// 遍歷方法
consoleEachData = (obj) => {
  obj[Symbol.iterator] = () => {
    let index = 0;
    return {
      next: () => {
        if (index < obj.data.length) {
          return {
            value: obj.data[index++],
            done: false
          };
        } else {
          return { value: undefined, done: true };
        }
      }
    };
  }
  for (const value of obj) {
    console.log(value);
  }
}
consoleEachData(obj1); // value1 value2
consoleEachData(obj2); // 1  2

一點補充

  在寫這篇文章時,有個問題給我帶來了困擾:原生object物件預設沒有部署Iterator介面,即object不是一個可迭代物件。物件的擴充套件運算子...等同於使用Object.assign()方法,這個比較好理解。那麼,原生object物件的解構賦值又是怎樣一種機制呢?

let aClone = { ...a };
// 等同於
let aClone = Object.assign({}, a);

  有一種說法是:ES6提供了Map資料結構,實際上原生object物件被解構時,會被當作Map進行解構。關於這點,大家有什麼不同的觀點嗎?歡迎評論區一起探討。

參考資料

本文首發於個人部落格,歡迎指正和star

相關文章