Skip to content

可迭代对象

Symbol.iterator

例如,我们有一个对象,它并不是数组,但是看上去很适合使用 for..of 循环。

比如一个 range 对象,它代表了一个数字区间:

js
let range = {
  from: 1,
  to: 5
};

// 我们希望 for..of 这样运行:
// for(let num of range) ... num=1,2,3,4,5

为了让 range 对象可迭代(也就让 for..of 可以运行)我们需要为对象添加一个名为 Symbol.iterator 的方法(一个专门用于使对象可迭代的内建 symbol)。

  1. for..of 循环启动时,它会调用这个方法(如果没找到,就会报错)。这个方法必须返回一个 迭代器(iterator) —— 一个有 next 方法的对象。
  2. 从此开始,for..of 仅适用于这个被返回的对象
  3. for..of 循环希望取得下一个数值,它就调用这个对象的 next() 方法。
  4. next() 方法返回的结果的格式必须是 {done: Boolean, value: any},当 done=true 时,表示循环结束,否则 value 是下一个值。
js
let range = {
  from: 1,
  to: 5,
}

range[Symbol.iterator] = function () {
  return {
    current: this.from,
    last: this.to,

    next() {
      if (this.current <= this.last) {
        return { done: false, value: this.current++ }
      } else {
        return { done: true }
      }
    },
  }
}

for (let num of range) {
  console.log(num)
}
  • range自身没有next()方法
  • 相反,是通过调用 range[Symbol.iterator]() 创建了另一个对象,即所谓的“迭代器”对象,并且它的 next 会为迭代生成值。

因此,迭代器对象和与其进行迭代的对象是分开的。

从技术上说,我们可以将它们合并,并使用 range 自身作为迭代器来简化代码。

js
let range = {
  from: 1,
  to: 5,

  [Symbol.iterator]() {
    return {
      current: this.from,
      last: this.to,

      next() {
        if (this.current <= this.last) {
          return { done: false, value: this.current++ }
        } else {
          return { done: true }
        }
      },
    }
  },
}

for (let num of range) {
  console.log(num)
}

现在 range[Symbol.iterator]() 返回的是 range 对象自身:它包括了必需的 next() 方法,并通过 this.current 记忆了当前的迭代进程。这样更短,对吗?是的。有时这样也可以。

但缺点是,现在不可能同时在对象上运行两个 for..of 循环了:它们将共享迭代状态,因为只有一个迭代器,即对象本身。但是两个并行的 for..of 是很罕见的,即使在异步情况下。

字符串也是可迭代的

数组和字符串是使用最广泛的内建可迭代对象。

对于一个字符串,for..of 遍历它的每个字符:

对于代理对(surrogate pairs),它也能正常工作!(译注:这里的代理对也就指的是 UTF-16 的扩展字符)

js
let str = '𝒳😂'
for (let char of str) {
  console.log(char)
}

显示调用迭代器

为了更深层地了解底层知识,让我们来看看如何显式地使用迭代器。

我们将会采用与 for..of 完全相同的方式遍历字符串,但使用的是直接调用。这段代码创建了一个字符串迭代器,并“手动”从中获取值。

js
let str = 'hello'

let it = str[Symbol.iterator]()

while (true) {
  let res = it.next()
  if (res.done) break
  console.log(res.value)
}

可迭代(iterator)和类数组(array-like)

这两个官方术语看起来差不多,但其实大不相同。请确保你能够充分理解它们的含义,以免造成混淆。

  • Iterable 如上所述,是实现了 Symbol.iterator 方法的对象。
  • Array-like 是有索引和 length 属性的对象,所以它们看起来很像数组。

当我们将 JavaScript 用于编写在浏览器或任何其他环境中的实际任务时,我们可能会遇到可迭代对象或类数组对象,或两者兼有。

例如,字符串即是可迭代的(for..of 对它们有效),又是类数组的(它们有数值索引和 length 属性)。

但是一个可迭代对象也许不是类数组对象。反之亦然,类数组对象可能不可迭代。

例如,上面例子中的 range 是可迭代的,但并非类数组对象,因为它没有索引属性,也没有 length 属性。

下面这个对象则是类数组的,但是不可迭代:

js
let arrayLike = {
  0: 'hello',
  1: 'world',
  length: 2,
}

for (let i of arrayLike) {
  console.log(i) // TypeError: arrayLike is not iterable
}

可迭代对象和类数组对象通常都 不是数组,它们没有 pushpop 等方法。如果我们有一个这样的对象,并想像数组那样操作它,那就非常不方便。例如,我们想使用数组方法操作 range,应该如何实现呢?

Array.from

有一个全局方法 Array.from 可以接受一个可迭代或类数组的值,并从中获取一个“真正的”数组。然后我们就可以对其调用数组方法了。

js
let arrayLike = {
  0: 'hello',
  1: 'world',
  length: 2,
}

let arr = Array.from(arrayLike)
console.log(arr.pop()) // world

(*) 行的 Array.from 方法接受对象,检查它是一个可迭代对象或类数组对象,然后创建一个新数组,并将该对象的所有元素复制到这个新数组。

js
// 假设 range 来自上文的例子中
let arr = Array.from(range);
alert(arr); // 1,2,3,4,5 (数组的 toString 转化方法生效)

Array.from 的完整语法允许我们提供一个可选的“映射(mapping)”函数:

js
Array.from(obj[, mapFn, thisArg])

可选的第二个参数 mapFn 可以是一个函数,该函数会在对象中的元素被添加到数组前,被应用于每个元素,此外 thisArg 允许我们为该函数设置 this

例如:

js
// 假设 range 来自上文例子中

// 求每个数的平方
let arr = Array.from(range, num => num * num);

alert(arr); // 1,4,9,16,25

我们甚至可以基于 Array.from 创建代理感知(surrogate-aware)的slice 方法(译注:也就是能够处理 UTF-16 扩展字符的 slice 方法):

js
function slice(str, start, end) {
  return Array.from(str).slice(start, end).join('');
}

let str = '𝒳😂𩷶';

alert( slice(str, 1, 3) ); // 😂𩷶

// 原生方法不支持识别代理对(译注:UTF-16 扩展字符)
alert( str.slice(1, 3) ); // 乱码(两个不同 UTF-16 扩展字符碎片拼接的结果)