ES6 推出的 for of 语句非常强大,远超曾经的所有遍历方式。

for of 可以很轻松地遍历数组、集合、映射,写法也十分简洁。

在我的项目中,除了需要获取特定返回值的时候还采用数组的 map filter reduce 方法,其余遍历都由 for of 代劳。

本文我将逐层深入地介绍 for of 语句的用法与注意事项,并刨析其原理——迭代器和生成器,最后在对象与数字类型上扩展 for of 的功能。

语法与优势

for of 语句的语法如下:

for (let variable of iterable) {
    //statements
}
  • iterable 是要被遍历的目标,一般是数组、字符串、集合、映射,或者是其他实现迭代器接口的类数组对象,比如函数的参数列表 arguments、DOM 的节点列表 NodeList
  • variable 是自己定义的一个变量,用来存储每次迭代中迭代器的返回值。

可以看看下面的示例,能更好的理解怎么使用。

迭代数组,变量存储的是数组的值。

let iterable = [10, 20, 30];

for (let value of iterable) {
    console.log(value);
}
// 10
// 20
// 30

迭代字符串,变量存储的是单个字符。

let iterable = "boo";

for (let value of iterable) {
  console.log(value);
}
// "b"
// "o"
// "o"

迭代集合,变量存储的是集合的值。

let iterable = new Set([1, 1, 2, 2, 3, 3]);

for (let value of iterable) {
  console.log(value);
}
// 1
// 2
// 3

迭代映射,变量存储的是一个键值对的数组,一般会通过解构赋值来使用。

let iterable = new Map([["a", 1], ["b", 2], ["c", 3]]);

for (let entry of iterable) {
  console.log(entry);
}
// ["a", 1]
// ["b", 2]
// ["c", 3]

for (let [key, value] of iterable) {
  console.log(key, value);
}
// a 1
// b 2
// c 3

相较于传统的循环语句,for of 语句更简洁,传统的循环是无法遍历集合与映射的,因为它们不具有索引。

for of 中可以使用 break continue 操作符结束或终止迭代,这使其超越 forEach 方法。

注意事项

for of 虽然好用,但要注意下面的几个问题:

  • 不能直接遍历对象。对象没有实现迭代器接口,直接遍历会抛出异常。如果想遍历对象的属性,可以先通过 Object.keys() 方法获取对象的属性列表,然后再遍历。
  • 不能实现数组的赋值for of 遍历数组时并没有提供索引,无法直接修改数组。如果打算改变数组,建议使用其他遍历方法。
  • 不要提前修改未迭代的项目。如果你在遍历途中修改后面项的值,在之后的迭代中获取的是新的值。
  • 不要在遍历途中增删项目。如果你在遍历途中删除了未迭代的项目,会导致迭代次数的减少;如果你在遍历途中添加了新项,它们也将会被迭代。

前两个问题在 扩展 for of 一节中将得到完美解决,另外两个问题我们在遍历时理应遵守的规范,如果不遵守将导致代码逻辑的混乱。

预备知识

想要弄清楚 for of 语句的原理,要先认识3个概念:迭代协议、迭代器、生成器。

迭代协议

MDN 对迭代协议的介绍比较复杂,我简单概括一下,详情可点击连接

一个对象要想能够被迭代,需要实现一个迭代接口,其值应该是一个符合规定的迭代器

在 JS 中,对象的迭代接口通过属性 Symbol.iterator 暴露给了开发者。

迭代器

迭代器是一个对象,它具有一个 next 方法,该方法会返回一个对象,包含 valuedone 两个属性。value 表示这次迭代的值; done 表示是否已经迭代到序列中的最后一个。

迭代器对象可以重复调用 next() 方法,该过程称迭代一个迭代器,又称消耗了这个迭代器,因为它通常只能迭代一次。 在产生终止值之后,对 next() 的额外调用只会返回 {value: 最终值, done:true}

迭代器的写法较为复杂,在这里只展示一个实例,该函数用于生成迭代器对象。

不需要对其过多研究,因为我们不用手写迭代器,JS 向我们提供了便捷的生成器用来生成迭代器对象。

// 数字从start开始,每次迭代增加step,直到大于end
function makeRangeIterator(start = 0, end = Infinity, step = 1) {
  let nextIndex = start

  const rangeIterator = {
    next: function () {
      let result
      if (nextIndex <= end) {z
        result = { value: nextIndex, done: false }
        nextIndex += step
      } else {
        result = { value: undefined, done: true }
      }
      return result
    },
  }

  return rangeIterator
}

生成器

生成器是一个函数,我将从语法和调用两个方面详细说明生成器函数。

语法:
生成器函数的语法有一定规则,该函数要使用 function* 语法编写,在其内部可以使用 yield 关键字指定每一次迭代产出的值,也可以使用 return 关键字作为迭代器的终值。

调用:调用生成器函数只会返回一个迭代器对象,不会执行函数体中的代码。通过调用生成器的 next() 方法,才会执行函数体中的内容,直到遇到 yield 关键字或执行完毕。

只看文字不好理解,看个例子吧

function* generator() {
  console.log('第一次调用')
  yield 'a'
  console.log('第二次调用')
  yield 'b'
  console.log('第三次调用')
  return 'c'
}

let iterator = generator()
console.log('创建迭代器')

console.log('next1:', iterator.next())
console.log('next2:', iterator.next())
console.log('next3:', iterator.next())
console.log('next4:', iterator.next())

控制台的打印如下:

image.png

生成器函数的内容是分步调用的,每次迭代只运行到下一个 yield 的位置,将 yield 关键字后的表达式作为本次迭代的值产出。当遇到 return 或执行完函数后,返回对象的 done 属性会被设置为 true,表示这个迭代器被完全消耗了。

将之前的例子改用生成器的写法,代码十分简洁:

// 数字从start开始,每次迭代增加step,直到大于end
function* makeRangeIterator(start = 0, end = Infinity, step = 1) {
  for (let i = start; i <= end; i += step) {
    yield i
  }
}

next 方法是可以传参的,参数将以 yield 关键字的返回值的形式被使用,第一次 next 调用传递的参数将被忽略。

看看下面这个无限累加器,传递0会将其重置:

function* generator(start) {
  let cur = start
  while (true) {
    let num = yield cur
    if (num == 0) {
      console.log('迭代器被重置')
      cur = start
    } else {
      cur += num
    }
  }
}

let iterator = generator(10)
console.log('创建迭代器')
console.log('next1:', iterator.next().value)
console.log('next2:', iterator.next(2).value)
console.log('next3:', iterator.next(4).value)
console.log('next4:', iterator.next(5).value)
console.log('next5:', iterator.next(0).value)
console.log('next6:', iterator.next(5).value)
console.log('next7:', iterator.next(10).value)

控制台输出如下

image.png

原理与实现

for of 的原理,就是调用目标的迭代接口(生成器函数)获取一个迭代器,然后不断迭代这个迭代器,将返回对象的 value 属性赋值给变量,直到返回对象的 done 属性为 true

通过函数的方式简单实现一下:

/**
 * @description: for of 方法实现
 * @param {object} iteratorObj 可迭代对象
 * @param {Function} fn 回调函数
 * @return {void}
 */
function myForOf(iteratorObj, fn) {
  // 如果传入的对象不具备迭代接口,抛出异常
  if (typeof iteratorObj[Symbol.iterator] != 'function') {
    throw new TypeError(`${iteratorObj} is not iterable`)
  }
  // 获取迭代器
  let iterator = iteratorObj[Symbol.iterator]()
  // 遍历迭代器
  let i
  while (!(i = iterator.next()).done) {
    fn(i.value)
  }
}

const arr = [10, 20, 30]

myForOf(arr, (item) => {
  console.log(item)
})

let map = new Map([
  ['a', 1],
  ['b', 2],
  ['c', 3],
])

myForOf(map, ([key, value]) => {
  console.log(key, value)
})

控制台输入如下:

image.png

for offorEach 方法原理一致,上述代码稍作修改就能实现 forEach 方法

扩展 for of

我们知道,for of 美中不足的一点是没法直接遍历对象的属性

我们只要实现 Object 原型对象上的迭代接口,将其定义为一个返回包含对象所有属性的生成器

实现如下:

Object.prototype[Symbol.iterator] = function* () {
  const keys = Object.keys(this)
  for (let i = 0; i < keys.length; i++) {
    yield keys[i]
  }
}

const obj = { a: 1, b: 2, c: 3 }

for (const key of obj) {
  console.log(key, obj[key])
}
// a 1
// b 2
// c 3

我们在生成器中提前获取了对象的属性数组,在迭代器中不断产生就好了

令人惊喜的是,这个行为不会影响到 ... 操作符的对象浅拷贝功能,百利无一害。

console.log({ ...obj }) // {a: 1, b: 2, c: 3}
console.log([...obj]) // ['a', 'b', 'c']
使用 ... 浅拷贝对象,实际上调用的 Object.assign 方法

for of 另一点不足就是取不到索引,没法修改数组,可以通过实现 Number 原型对象上的迭代接口解决

代码如下:

Number.prototype[Symbol.iterator] = function* () {
  const num = this.valueOf()
  for (let i = 0; i < num; i++) {
    yield i
  }
}

const arr = [...5]

console.log(arr) // [0, 1, 2, 3, 4]

for (const index of arr.length) {
  arr[index] *= 2
}

console.log(arr) // [0, 2, 4, 6, 8]

如果是在 ts 中扩展 for of 后,使用时会提示错误,加入下列接口声明就好了

declare interface Object {
  [Symbol.iterator]: any
}
declare interface Number {
  [Symbol.iterator]: any
}

结语

如果文中有错误或不严谨的地方,请务必给予指正,十分感谢。

内容整理不易,如果喜欢或者有所启发,希望能点赞关注,鼓励一下作者。


清隆
29 声望2 粉丝

学完某项技能一定要写写文章,用的时候都是照搬代码,写出来才能深入理解!