美文网首页细品 JavaScript
细读 ES6 | Iterator 迭代器

细读 ES6 | Iterator 迭代器

作者: 越前君 | 来源:发表于2021-08-04 02:42 被阅读0次
    配图源自 Freepik

    迭代的英文是 “iteration”,源自拉丁文 “itero”,是“重复”或“再来”的意思。在软件开发领域,“迭代”的意思是按照顺序反复多次执行一段程序,通常会有明确的终止条件。ES6 规范新增了两个高级特性:迭代器生成器。使用这两个特性,能够更清晰、高效、方便地实现迭代。

    一、迭代器简述

    迭代器(Iterator)是一种机制,是一种接口。为各种不同的数据结构提供了统一的访问机制。

    有些书籍或文章译为“遍历器”,其他语言多称为“迭代器”。

    1. 为什么 ECMAScript 要引入“迭代器”?

    在 ES6 之前,最常见的数据结构有数组和对象。

    遍历这些数据结构通常使用 for 语句。例如数组,除了最原始的 for 循环,还有 Array.prototype.forEach() 等方法。而普通对象则使用 for...in 语句。那么,我们决定使用哪一种方法之前,是不是要提前知道它们内部结构是怎样的,才能正确地写出迭代的程序,对吧。

    而在 ES6 标准中,新增了 MapSet 两种不同的数据结构。它们的内部结构与原本就存在的 ArrayObject 又不一样。假设我们要去遍历它们,是不是要实现一个类似 Array.prototype.forEach() 的方法,例如,遍历以上两种结构的方法叫 Map.prototype.forEach()Set.prototype.forEach()(当然这两个是我瞎编了)。那么这样是不是又多了两种迭代方式,无论对负责制定标准的 TC39 委员会,还是 JavaScript 开发者来说,都增加了工作量。委员会要负责实现,开发者要了解学习,都需要成本。

    看到这种苗头,TC39 委员会那些大佬就内心开始骂街了。每增加一种数据结构,就要实现一种全新的方法去遍历它,那劳资不干了,都不能好好摸鱼了。

    于是它们就想出一个“偷懒”的方法:实现一种通用的迭代机制,并制定相关标准。它一定要支持原有的数据结构,而且未来新增的数据结构,也要满足这种模式(机制)才行。因此,在 ECMAScript 中就出现了“迭代器”(Iterator)的概念。

    这样的话,对于我们开发者来说,无需知道某种对象的内部数据结构,就可以利用 for...of 等语句,方便、快速、安全地写出迭代程序。假设在未来的 ES2050 标准中,新增了一种名为 Record 的数据结构,只要符合迭代器机制的,我们仍然可以使用 for...of 去遍历它们。

    2. JavaScript 内置的可迭代对象

    如果一个对象具有 Iterator 接口,我们将它称为“可迭代对象”(Iterable)。

    在 JavaScript 中,目前内置的可迭代对象有:StringArrayTypedArrayMapSetArgumentsNodeList。它们的原型对象(prototype)都实现了 @@iterator方法。(即对象含有 Symbol.iterator 属性)。

    除了内置的可迭代对象,我们可以实现自己的可迭代对象。例如:

    const myIterable = {
      [Symbol.iterator]: function* () {
        yield 1
        yield 2
        yield 3
        // 生成器函数,返回一个可迭代对象
      }
    }
    
    console.log([...myIterable]) // [1, 2, 3]
    
    3. Iterator 接口

    迭代器是一个对象,带有特殊的接口。一个具有 Iterator 接口的对象,分为两部分:可迭代协议迭代器协议

    简单来说:

    • 可迭代协议,是指对象实现了 @@iterator 方法(不管是本身属性,还是对象原型链上的属性都可以),并返回一个迭代器。可通过常量 Symbol.iterator 访问该属性。

    • 迭代器协议,是指 @@iterator 方法返回的迭代器实现了 next 方法。调用 next 方法返回一个 IteratorResult 对象,包括两个属性:{ done: true/false, value: '...' },其中 done 是一个布尔值,表示遍历是否结束;value 表示当前成员的值。

    某个对象只有实现了以上两个协议,才算是一个完整、合格的迭代器。

    二、生成器简述

    说到生成器,你们一定知道 Generator 函数(即“生成器函数”),它是 ES6 中提供的一种异步编程的解决方案。

    生成器的形式是一个函数,在函数名称前面加一个星号(*),表示它是一个生成器。尽管语法上与普通函数相似,但语法行为却完全不同。

    注意,箭头函数不能用来定义生成器函数。

    如示例:

    function* generatorFn() {} // generatorFn 表示一个生成器
    const gen = generatorFn() // 调用生成器函数,会返回一个“生成器对象”
    
    console.log(gen) // generatorFn {<suspended>}
    console.log(gen.next()) // {value: undefined, done: true}
    
    // 生成器对象,一开始会处于暂停执行(suspended)的状态
    // 调用 next() 方法会让生成器开始或恢复执行
    // 由于生成器函数体为空,因此调用一次 next() 方法就会让生成器到达 done: true 的状态
    

    本文不会详细介绍生成器函数相关语法,后面另起一文。本文提到它的缘由是:Generator 函数会产生一个“生成器对象”,该对象也实现了 Iterator 接口。

    因此,生成器格外合适作为默认迭代器。在实现自定义可迭代对象时,也是最简单的一种实现。例如:

    class Foo {
      constructor(value) {
        this.value = value
      }
    
      *[Symbol.iterator]() {
        yield* this.value
      }
    }
    
    // foo 实例对象是一个可迭代对象,因为它的原型上实现了 @@iterator 方法
    const foo = new Foo([1, 2, 3])
    
    // 访问迭代器
    for (const x of foo) {
      console.log(x)
    }
    // 依次打印:1、2、3
    

    三、Iterator 详解

    迭代器是按需创建的一次性对象。每个迭代器都会关联一个可迭代对象。

    这句话怎么理解,看示例:

    const num = 1
    const obj = {}
    const arr = [1, 2, 3]
    
    // 通过访问 Symbol.iterator 属性可判断对象是否实现了可迭代协议,
    // 返回值为函数,则表示实现了,否则未实现,
    // 因此 num、obj 不可迭代,arr 是可迭代的。
    console.log(num[Symbol.iterator]) // undefined
    console.log(obj[Symbol.iterator]) // undefined
    console.log(arr[Symbol.iterator]) // ƒ values() { [native code] }
    
    // 调用 @@iterator 方法,会生成一个迭代器,
    // iter1、iter2 表示两个不同的迭代器,
    // 但它们相关联的可迭代对象都是 arr
    const iter1 = arr[Symbol.iterator]()
    const iter2 = arr[Symbol.iterator]()
    
    console.log(iter1 === iter2) // false
    for (const x of iter1) {
      console.log(x)
    }
    // for...of 语句依次打印:1、2、3
    

    在上面的实例中,结论与分析请看注释部分。

    如何理解“一次性”对象,看示例:

    // 示例一:
    const gen = (function* () {
      yield 1
      yield 2
      yield 3
    })()
    
    for (let x of gen) {
      console.log(x)
      break // 关闭生成器
    }
    
    // 生成器不应该重用,以下没有意义!
    for (let x of gen) {
      console.log(x)
    }
    
    // 由始至终,仅打印出 1
    

    上面的示例一中,使用了一个生成器函数返回一个生成器(也是迭代器,它实现了 Iterator 接口)。当我们使用 for...of 循环遍历它,并使用了 break 关键字提前终止。在退出循环后,生成器关闭。若尝试再次迭代,不会产生任何进一步的结果。因此,我们不应该重用生成器。

    再看以下示例:

    // 示例二
    const arr = [1, 2, 3, 4, 5]
    const iter = arr[Symbol.iterator]()
    
    // 第一次迭代
    for (const x of iter) {
      console.log(x)
      if (x > 2) break
    }
    // 依次打印出:1、2、3
    
    // 再次迭代
    for (const x of iter) {
      console.log(x)
    }
    // 依次打印出:4、5
    

    观察示例二,数组的迭代器跟生成器函数返回的迭代器又稍有不同。尽管我们在第一次迭代的时候,提前跳出循环了,但是迭代器 iter 并没有关闭。因此,我们可以尝试继续从上一次离开的地方继续迭代,这个离开的地方由迭代器内部维护。因此,数组的迭代器是不能主动关闭的,当它迭代至最后一个成员才会关闭。

    结合两个示例,同一个迭代器最好不要重复使用,因此才说是一次性对象。

    迭代器的概念很容易混淆,所以要注意了。

    例如,数组不是一个迭代器(Iterator),但它是一个可迭代对象(Iterable)。在调用数组实例的 @@iteator 方法,才会生成一个与数组实例相关联的迭代器。

    还需要注意的是,由于迭代器维护着一个执行可迭代对象的引用,因此迭代器会阻止垃圾回收程序回收可迭代对象。

    Iterator 遍历过程

    过程如下:

    (1)创建一个指针对象,指向当前数据结构的起始位置。也就是说,迭代器对象本质上,就是一个指针对象。
    (2)第一次调用指针对象的 next() 方法,可以将指针对象指向数据结构的第一个成员。
    (3)第二次调用指针对象的 next() 方法,可以将指针对象指向数据结构的第二个成员。
    (4)不断调用指针对象的 next() 方法,直到它指向数据结构的结束位置。

    每一次调用迭代器的 next() 方法,都会返回数据结构的当前成员的信息,即前面提到的 IteratorResult 对象。它有包含 donevalue 两个属性,其中 done 是一个布尔值,表示遍历是否结束;value 表示当前成员的值。

    我们基于数组的结构特点,模拟一个简单的 next() 方法(迭代器协议):

    function createIterator(array) {
      let nextIndex = 0
      return {
        next: function () {
          return nextIndex < array.length
            ? { done: false, value: array[nextIndex++] }
            : { done: true, value: undefined }
    
          // 对迭代器对象来说,`done: false` 和 `value: undefined` 都是可以省略的,
          // 因此可以简写成:
          // return nextIndex < array.length
          //   ? { value: array[nextIndex++] }
          //   : { done: true }
        }
      }
    }
    
    const iter = createIterator([1, 2, 3])
    

    上面示例中,定义了一个 createIterator 函数,作用是返回一个迭代器。当我们对数组 [1, 2, 3] 执行 createIterator(),就会返回与该数组相关联的遍历器对象 iter(即上文提到的指针对象)。

    第一次调用指针对象 iternext() 方法,指针指向数组的开始位置,并返回一个 IteratorResult 对象 {done: false, value: 1},它包含了当前数据成员的信息。若 donefalse 表示遍历未结束,即可继续调用 next() 方法访问下一个成员信息......直到 donetrue 遍历结束。

    其实迭代器(Iterator)与可迭代对象(Iterable)是分开的,迭代器无需了解与其关联的可迭代对象,只需要知道如何取得连续的值。

    四、默认迭代器

    迭代器的目的就是提供一种统一的访问机制。可供给 JavaScript 所有接收可迭代对象的语句或方法使用。

    目前支持以下这些:
    for...of 语句
    数组解构
    扩展运算符
    Array.from()
    new Set()
    new Map()
    Promise.all()
    Promise.race()
    Promise.allSettled()
    Promise.any()
    yield* 操作符
    

    前面提到,只要某种数据结构(对象)部署了 Iterator 接口,那么我们就称这种数据结构是“可迭代的”。

    ES6 规定,默认的 Iterator 接口部署在对象的 Symbol.iterator 属性上。该属性本身就是一个函数,就是当前对象的迭代器生成函数。调用此方法就会返回一个迭代器。

    插个话:

    千万不要被 Symbol.iterator 属性吓到了,标准规定用它指向对象的默认遍历器方法而已,你完全可以在内心把它当成名称为 iterator 的属性。那么 arr[Symbol.iterator](),就相当于 arr.iterator()arr['iterator']()。它就是一个键名,仅此而已。

    我们知道使用 Symbol 类型去表示独一无二的值。同样的,当使用 Symbol 值作为对象的键名,就能保证不会出现同名属性。假设我们的默认迭代器部署在一个名称为 iterator(或其他)的属性上,那么是不是很容易被我们改写或覆盖掉。因此指定标准的那帮家伙才会使用 Symbol 值作为键名,并规定它的特殊含义,这样开发者就不会随意覆盖它了。

    由于 Symbol 值是唯一的,因此不能使用点运算符(.)去访问,需通过方括号([])的形式才能访问。

    除了 Symbol.iterator,还有很多内置的 Symbol 值。例如 Symbol. hasInstanceSymbol.toPrimitiveSymbol.toStringTag 等等,它们都有特定的含义。有时候也会对应称作 @@iterator@@toPrimitive 等。

    在 JavaScript 中,有以下这些数据结构原生具备 Iterator 接口:

    String、Array、TypedArray、Map、 Set、Arguments 和 NodeList。
    

    因此我们不用任何处理,无需自己编写迭代器生成函数,就可以使用如 for...of 语句去遍历它们。for...of 内部会自动访问默认 Iterator 接口 Symbol.iterator

    而普通对象并没有默认部署 Iterator 接口,因此我们不能使用 for...of 去迭代普通对象。但我们可以为它实现自定义迭代器接口。

    实现迭代器接口,最简单的方法应该是使用 Generator 函数。

    如何判断某种数据结构是否实现了 Iterator 接口,可以这样:

    // Symbol.iterator 属性值为 undefined,表示未实现 Iterator 接口
    console.log(1[Symbol.iterator]) // undefined
    console.log({}[Symbol.iterator]) // undefined
    console.log('abc'[Symbol.iterator]) // ƒ [Symbol.iterator]() { [native code] }
    console.log([][Symbol.iterator]) // ƒ values() { [native code] }
    

    五、自定义迭代器

    普通对象,为什么不实现默认 Iterator 接口。主要是因为对象的属性是无序的,先遍历哪个属性,后遍历哪个属性是不确定的。若需要有序的对象结构,那不就 Map 对象嘛。因此,普通对象好像没必要默认部署迭代器接口了。

    尽管原生未部署 Iterator 接口,我们可以自定义啊,标准又没有限制我们不能自定义对吧。

    我们来定义一个 Counter 类,并实现 Iterator 接口。

    1. 粗略版本

    如下:

    class Counter {
      constructor([min = 0, max = 10]) {
        this.min = min
        this.max = max
      }
    
      // 实现迭代器协议
      next() {
        const isDone = this.min > this.max
        return {
          done: isDone,
          value: isDone ? undefined : this.min++
        }
        // 迭代器的 next() 方法,需要返回一个对象,
        // 对象包括 { done, value } 属性,
        // 其中 done 为 false,或 value 为 undefined 时,返回值可省略该属性。
        // 这里返回 this(即实例对象),它的原型上实现了 next() 方法
      }
    
      // 实现可迭代协议
      [Symbol.iterator]() {
        return this
        // @@iterator 方法返回一个迭代器,它是一个对象。
        // 不能返回类似 return 1 等无意义的值,否则进行迭代操作时会报错。
      }
    }
    

    以上 Counter 类实现了可迭代协议和迭代器协议,因此其实例对象就具备了 Iterator 接口,可使用 for...of 进行迭代。

    const counter = new Counter([0, 3]) // { min: 0, max: 3 }
    for (const x of counter) {
      console.log(x)
    }
    // 依次打印出:0、1、2、3
    

    尽管实现了 Iterator 接口,但不理想。原因是每个实例只能被迭代一次,而且实例的 min 属性值会发生变化。

    // 上一个代码块里面,counter 实例对象已迭代过一次
    // 现在尝试再迭代一次,以下代码并不会打印出什么
    for (const x of counter) {
      console.log(x)
    }
    
    // 原因很简单:
    // 当我们再次使用 for...of 之前,counter 实例已经变成:{ mix: 4, max: 3 }
    // 然后执行到 for...of 的时候,内部做了以下这些步骤:
    // 1) 首先创建一个迭代器,即访问实例的 @@iterator 方法,
    //    返回的迭代器就是 this,值为 { mix: 4, max: 3 }。
    // 2) 接着循环,其实就是不停调用迭代器的 next() 方法,即 this.next()
    //    那么循环什么时候终止呢,那就是 done 为 true 时,表示遍历结束。
    //    此时,第一次调用 next() 方法,返回值 done 就是为 true,即结束了。
    //    因此,压根不会执行 for...of 的循环体,也就不会打印相关值了。
    //
    // 可通过以下示例去验证:
    // for (const x of counter) {
    //   console.log(x)
    //   if (x === 1) break
    // }
    // 依次打印:0、1
    // for (const x of counter) {
    //   console.log(x)
    // }
    // 依次打印:2、3
    
    2. 改进版本

    鉴于以上原因,我们还需要改进一下。

    为了让一个实例对象能够创建多个迭代器,必须每创建一个迭代器就对应一个新计算器。因此,我们可以把计算器变量放到闭包里,然后通过闭包返回迭代器。

    class Counter {
      constructor([min = 0, max = 10]) {
        this.min = min
        this.max = max
      }
    
      [Symbol.iterator]() {
        let point = this.min
        const end = this.max
        return {
          next: () => {
            const isDone = point > end
            return {
              done: isDone,
              value: isDone ? undefined : point++
            }
          }
        }
      }
    }
    
    const counter = new Counter([0, 3])
    for (const x of counter) {
      console.log(x)
    }
    // 依次打印出:0、1、2、3
    for (const x of counter) {
      console.log(x)
    }
    // 依次打印出:0、1、2、3
    

    以上这种改进写法,与数组迭代的表现是相似的。

    3. 完善版本

    但目前跟数组,还存在一点区别。例如:

    // ✅ 数组可以这样使用
    const arr = [0, 1, 2, 3]
    const iter = arr[Symbol.iterator]()
    for (const x of iter) {
      console.log(x)
    }
    // 依次打印:0、1、2、3
    
    
    // ❌ 但是如果 Counter 这样去使用
    const counter = new Counter([0, 3])
    const iter = counter[Symbol.iterator]()
    // 当代码执行到 for...of 这行的时候,发现就会报错:TypeError: iter is not iterable
    for (const x of iter) {
      console.log(x)
    }
    // 原因分析:
    // 其实很简单,使用 for...of 的时候,内部会自动访问 iter[Symbol.iterator],
    // 但 iter 是仅含有 next 属性(不计原型)的普通对象,并没有 Symbol.iterator 属性,
    // 因此判断 iter 不是一个可迭代对象,所以报错:TypeError: iter is not iterable
    

    解决方法也很简单,只要我们给 iter 添加一个 Symbol.iterator 属性,并返回其本身即可。

    class Counter {
      constructor([min = 0, max = 10]) {
        this.min = min
        this.max = max
      }
    
      [Symbol.iterator]() {
        let point = this.min
        const end = this.max
        return {
          [Symbol.iterator]() {
            // 不能使用箭头函数,否则 this 指向就不对了
            // 因此,我也把下面 next 方法,将原来的箭头函数改回普通函数
            return this
          },
          next() {
            const isDone = point > end
            return {
              done: isDone,
              value: isDone ? undefined : point++
            }
          }
        }
      }
    }
    

    再次执行,就可以了。

    const counter = new Counter([0, 3])
    const iter = counter[Symbol.iterator]()
    for (const x of iter) {
      console.log(x)
    }
    // 依次打印出:0、1、2、3
    

    4. 更完善版本

    假设我们在 for...of 循环使用 breakcontinuereturnthrow 提前退出,或者使用解构操作未消费所有值时,我们希望就此“关闭”迭代器(即 done 变为 true)。例如,生成器对象若提前退出,它的状态就会关闭。

    怎么实现呢?

    ECMAScript 标准为我们提供了一个“可选”的 return() 方法,它属于迭代器协议的一部分。

    由于 return() 方法是可选的,因此并非所有数据结构的默认迭代器都是可关闭的。比如,数组的迭代器就是不可关闭的。而生成器对象就是可关闭的。

    数组迭代器示例:

    const arr = [1, 2, 3, 4, 5]
    const iter = arr[Symbol.iterator]()
    
    for (const x of iter) {
      console.log(x)
      if (x > 3) break
    }
    // 依次打印:1、2、3
    
    for (const x of iter) {
      console.log(x)
    }
    // 依次打印:4、5
    
    // 需要注意的是:
    // `for (const x of arr) {}` 与 `for (const x of iter) {}` 是有区别的,
    // 前者每次调用,内部都会产生一个全新的迭代器,因此每次迭代,都会输出 0 ~ 5;
    // 而后者则同一个迭代器,因此再次迭代时,会接着遍历下一个成员。
    // 所以,不用误以为数组的迭代器是“可关闭的”。
    

    生成器对象示例:

    function* generatorFn() {
      yield 0
      yield 1
      yield 2
      yield 3
    }
    
    // 生成器对象,也是一个迭代器。
    const gen = generatorFn() // generatorFn {<suspended>}
    for (const x of gen) {
      console.log(x)
      if (x === 1) break
    }
    console.log(gen) // generatorFn {<closed>}
    // 迭代器“关闭”之后,再次去迭代的话就没意义了,它不会打印任何东西。
    for (const x of gen) {
      // do something...
    }
    

    其实 return() 的实现要求也很简单,它必须返回一个有效的 IteratorResult 对象。一般情况,可以只返回 { done: true }。因为这个返回值只会用在生成器的上下文中。

    我们基于前面的 Counter 类,修改一下:

    class Counter {
      constructor([min = 0, max = 10]) {
        this.min = min
        this.max = max
      }
    
      [Symbol.iterator]() {
        let point = this.min
        const end = this.max
        return {
          [Symbol.iterator]() {
            return this
          },
          next() {
            const isDone = point > end
            return {
              done: isDone,
              value: isDone ? undefined : point++
            }
          },
          return() {
            console.log('Exiting early')
            return { done: true }
            // 只会在“提前退出”才会触发此方法,
            // 正常遍历结束,是不会执行此方法的。
          }
        }
      }
    }
    

    上面实现了 return() 方法,以下“提前退出”的情况,可以看到都触发了此方法。

    const counter = new Counter([0, 3])
    for (const x of counter) {
      console.log(x)
      if ( x === 1 ) break // 或 throw 'xxx' 或 return 都行
    }
    // 依次打印出:0、1、"Exiting early"
    
    const [a, b] = counter
    // "Exiting early"
    

    利用迭代器实例的 return 属性,就可以判断一个可迭代对象是否具备可关闭的 Iterator 接口。但注意,我们不能给一个不可关闭的迭代器仅增加 return() 方法,使其变成可关闭的。除非自定义 Iterator 接口去覆盖原生的 Symbol.iterator 方法。

    5. 最终版本

    既然又要提前关闭,为什么不直接利用 Generator 函数呢,简单又快捷。

    class Counter {
      constructor([min = 0, max = 10]) {
        this.min = min
        this.max = max
      }
    
      *[Symbol.iterator]() {
        let point = this.min
        const end = this.max
        while (point <= end) {
          yield point++
        }
      }
    }
    

    一般情况下,Generator 函数是实现自定义迭代器的一种选择。它非常强大,语法上更为简练。但如果要实现类似数组默认迭代器那样的话,就不能用它了。

    若对 Generator 函数不太熟悉,可看这篇文章

    六、Iterator 应用场景

    无论是默认的迭代器接口,还是自定义迭代器接口,它们主要供以语法或方法消费。目前有以下这些方法:

    • for...of 循环
    • 数组解构
    • 扩展运算符
    • Array.from()
    • new Set()
    • new Map()
    • Promise.all()
    • Promise.race()
    • Promise.allSettled()
    • Promise.any()
    • yield* 操作符

    这些语法(方法)内部都会去访问(可迭代)对象的 @@iterator 方法,或叫作 Symbol.iterator 方法。

    The end.

    相关文章

      网友评论

        本文标题:细读 ES6 | Iterator 迭代器

        本文链接:https://www.haomeiwen.com/subject/lwzevltx.html