欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

ES2015 实现可迭代接口与迭代器模式

程序员文章站 2024-03-18 11:52:10
...

for…of 循环

在 ECMAScript 中遍历数据有很多中方法,首先是最基本的 for 循环,它比较适用于变量普通的数组,然后就是 for...in 循环,它比较适合于遍历键值对,再有就是一些对象的遍历方法,比如数组的 forEach() 方法。这些各种各样的遍历数据的方式都会有一定的局限性,所以 ECMAScript2015 借鉴了很多其他语言引入了一种全新的 for...of 循环,这种循环方式作为遍历所有数据结构的统一方式。换句话说,只要明白 for…of 内部工作的原理就可以使用这种循环去遍历任何一种自定义的数据结构。

在介绍原理之前,先来了解 for...of 的基本用法。如下代码所示:

const arr = [100, 200, 300, 400]

for (const item of arr) {
  console.log(item)
}

上述代码的运行结果如下:

100
200
300
400

for...in 循环不同的是 for...of 循环遍历数组时得到的是数组中每一个成员,而不是索引值。

for...of 这种循环还可以替代之前所使用的数组中的 forEach() 方法,因为相比于 forEach() 方法来说,for...of 循环允许使用 break 关键字随时终止循环。如下代码所示:

// forEach()方法
arr.forEach(item => {
  console.log(item)
})

// for...of循环
for (const item of arr) {
  console.log(item)
  if (item > 100) {
    break
  }
}

之前为了能够随时终止遍历,必须要使用数组中的 some() 方法或者 every() 方法。

除了数组可以被 for...of 循环直接遍历,一些伪数组对象也是可以使用 for...of 循环遍历的,比如函数中的 arguments 对象,或者 DOM 操作时一些节点列表,它们的遍历和普通的数组是没有任何区别的。

再有就是 ECMAScript2015 新增的 SetMap 对象,也是可以使用 for...of 循环进行遍历的。首先来看 Set 的遍历,如下代码所示:

const s = new Set(['foo', 'bar', 'baz'])

for (const item of s) {
  console.log(item)
}

上述代码的运行结果如下:

foo
bar
baz

从打印的结果可以看到,遍历 Set 和遍历数组没有什么区别。然后再看 Map 的遍历,如下代码所示:

const m = new Map()
m.set('foo', '123')
m.set('bar', '456')

for (const item of m) {
  console.log(item)
}

上述代码的运行结果如下:

[ 'foo', '123' ]
[ 'bar', '456' ]

从打印的结果可以看到,遍历 Map 每次得到的还是一个数组,而且这个数组当中都是两个成员,这两个成员分别是被遍历的键和值。因为遍历的是一个键值结构,一般键和值在循环体当中都需要用到,所以说这里是数组的形式提供键和值。

这里就可以配合数组的解构语法,直接得到数组中的键和值。如下代码所示:

for (const [key, value] of m) {
  console.log(key, value)
}

最后,再来尝试使用 for…of 循环遍历对象。如下代码所示:

const obj = {
  foo: 123,
  bar: 456
}

for (const item of obj) {
  console.log(item)
}

上述代码的运行结果如下:

for-of.js:48
for (const item of obj) {
                   ^

TypeError: obj is not iterable
    at Object.<anonymous> (/Users/king/前端课湛/04 整理课程/[拉勾教育]大前端高薪训练营/03 源码/1 JavaScript深度剖析/1.1 ECMAScript新特性/26-for-of.js:48:20)
    at Module._compile (internal/modules/cjs/loader.js:1201:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1221:10)
    at Module.load (internal/modules/cjs/loader.js:1050:32)
    at Function.Module._load (internal/modules/cjs/loader.js:938:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:71:12)
    at internal/main/run_main_module.js:17:47

从打印的结果可以看到,说明对象是不可被迭代的。

可迭代接口

ECMAScript 中能够表示有结构的数据类型越来越多,从最早的数组和对象到现在新增了 SetMap,而且开发者还可以组合使用这些类型定义符合自己业务需求的数据结构。

为了给各种各样的数据结构提供一种统一的遍历方式,ECMAScript2015 提供了一个叫做 Iterable 接口,意思就是可迭代的。

实现 Iterable 接口就是 for...of 循环统一遍历访问的前提,换句话说,只要这个数据结构实现了 Iterable 接口,它就能被 for...of 循环进行遍历。

这也就是说,之前尝试的能够直接被 for...of 循环进行遍历的数据类型在内部都已经实现了 Iterable 接口。脱离 for...of 循环的表象,来看看这个 Iterable 接口到底约定了哪些内容。

在浏览器的开发者工具的控制台分别打印数组、SetMap,可以在它们的原型上看到一个 Symbol 开头的属性,而且这个 Symbol 的属性名为 iterator。如下图所示:

ES2015 实现可迭代接口与迭代器模式

根据这三个对象当中都存在一个名为 iterator 的方法就可以确定,Iterable 接口的约定就是对象当中必须要挂载一个叫做 iterator() 方法。

iterator() 方法又是做什么的呢?可以通过手动调用这个方法来看看这个方法返回的到底是什么。如下代码所示:

const arr = ['foo', 'bar', 'baz']

arr[Symbol.iterator]()

上述代码的运行结果如下:

ES2015 实现可迭代接口与迭代器模式

从打印的结果可以看到,iterator() 方法返回的是数组的迭代器对象,这个对象当中存在一个 next() 方法。通过数组的迭代器对象调用 next() 方法,看看这个方法返回的结果又是什么?如下代码所示:

const arr = ['foo', 'bar', 'baz']

const iterator = arr[Symbol.iterator]()

iterator.next()

上述代码的运行结果如下:

{value: "foo", done: false}

从打印的结果可以看到,next() 方法返回的又是一个对象,这个对象当中存在两个成员 valuedone,而且 value 对应的值就是数组当中第一个成员。

进而多调用几次 next() 方法,得到如下所示的结果:

{value: "bar", done: false}
{value: "baz", done: false}
{value: undefined, done: true}

从打印的结果可以看到,第二次的 value 对应的是数组中的第二个成员,第三次的 value 对应的是数组中的第三个成员。前三次打印的 done 对应的值都是 false,而第四次打印的是 true

这就说明在这个迭代器当中内部应该是维护了一个数据指针,每调用一次 next() 方法这个指针都会向后移动一位。而 done 的作用就是表示内部所有的数据是否被遍历完成。

这里就可以总结一下,所有可以被 for...of 循环遍历的数据类型都必须实现 Iterable 接口,也就是在内部要挂载 iterator() 方法,这个方法需要返回一个带有 next() 方法的对象,不断调用 next() 方法就可以实现对内部所有数据的遍历。

实现可迭代接口

了解了 for...of 循环的内部原理之后就应该理解为什么 for...of 循环可以作为遍历所有数据结构的统一方式了,因为它内部就是调用被遍历对象的 iterator() 方法得到一个迭代器而去遍历内部所有的数据,这也就是 Iterable 接口所约定的内容。换句话说,只要自定义对象也实现了 Iterable 接口就可以实现使用 for...of 循环进行遍历。如下代码所示:

const obj = {
  [Symbol.iterator]: function () {
    return {
      next: function () {
        return {
          value: '前端课湛',
          done: true
        }
      }
    }
  }
}

这里需要梳理一下上述代码的结构:

  1. 定义一个实现 Iterable 接口的自定义对象,这里命名为 obj
  2. 实现 Iterable 接口实际上就是在 obj 对象上挂载 iterator() 方法,该方法可以通过 Symbol.iterator 常量来实现
  3. iterator() 方法需要返回一个实现迭代器的对象,该对象需要提供 next() 方法
  4. next() 方法用来实现向后迭代的逻辑,并且返回一个迭代结果对象
  5. 这个迭代结果对象包含两个成员,一个是 value 表示当前迭代的结果,一个是 done 表示当前是否迭代完成

整体结构如下图所示:

ES2015 实现可迭代接口与迭代器模式

明白了整体结构之后,就可以在 obj 对象内部定义一个数组用来存储值得被遍历的数据,在 next() 方法当中迭代这个数组。如下代码所示:

const obj = {
  store: ['foo', 'bar', 'baz'],

  [Symbol.iterator]: function () {
    // 定义一个变量维护数组的索引值
    let index = 0
    // 由于next()方法中的this不是obj对象, 先将this(指向obj对象的)进行缓存
    const self = this

    return {
      next: function () {
        const result = {
          value: self.store[index],
          done: index >= self.store.length
        }
        // 移动指针位置
        index++
        // 返回迭代结果
        return result
      }
    }
  }
}

这样改造之后的 obj 对象就可以被 for...of 循环正常遍历了,如下代码所示:

for (const item of obj) {
  console.log(item)
}

上述代码的运行结果如下:

foo
bar
baz

迭代器模式

自定义对象实现可迭代接口从而实现使用 for...of 循环迭代这个对象,其实这就是设计模式中的迭代器模式。这种模式的优势是什么呢?通过一个应用场景来看一看。

假设现在要协同开发一个任务清单应用,在一个 JavaScript 文件(a.js 文件)中设计一个用于存放所有任务的对象,如下代码所示:

const todos = {
  life: ['吃饭', '睡觉', '打豆豆'],
  learn: ['语文', '数学', '外语'],
}

在另一个 JavaScript 文件(b.js 文件)中将存放所有任务的对象当中所有的任务项全部罗列呈现在页面上,如下代码所示:

for (const item of todos.life) {
  console.log(item)
}

for (const item of todos.learn) {
  console.log(item)
}

但是这时如果在 a.js 文件中对象的结构发生了变化,比如新增了一个全新的类目,如下代码所示:

const todos = {
  life: ['吃饭', '睡觉', '打豆豆'],
  learn: ['语文', '数学', '外语'],
  work: ['喝茶']
}

而在 b.js 文件中的代码结构是与 a.js 文件中对象之前的结构严重耦合的,所以 b.js 文件的代码逻辑也需要跟着一起变化。如下代码所示:

for (const item of todos.life) {
  console.log(item)
}
for (const item of todos.learn) {
  console.log(item)
}
for (const item of todos.work) {
  console.log(item)
}

如果在 a.js 文件中能够提供一个统一对外的遍历接口,对于 b.js 文件来说就不需要关心 a.js 文件中对象的结构是怎么样的了,更不用担心这个对象结构发生变化之后所产生的影响。比如在 a.js 文件中的对象内部定义一个 each() 方法用来对外提供一个遍历接口,如下代码所示:

const todos = {
  each: function (callback) {
    const all = [].concat(this.life, this.learn, this.work)
    for (const item of all) {
      callback(item)
    }
  }
}

这样的话在 b.js 文件里面就可以直接使用 each() 方法来遍历到 a.js 文件中对象的所有数据。如下代码所示:

todos.each((item) => {
  console.log(item)
})

其实实现可迭代接口也是相同的道理,再来使用迭代器的方式来解决一下这个问题。如下代码所示:

const todos = {
  [Symbol.iterator]: function () {
    const all = [].concat(this.life, this.learn, this.work)
    let index = 0
    return {
      next: function () {
        return {
          value: all[index],
          done: index++ >= all.length
        }
      }
    }
  }
}

这就是实现迭代器的意义。迭代器模式的核心就是对外提供统一遍历接口,让外部不再关心这个数据内部的结构是怎样的。