从一个for 循环到 iterator (迭代器遍历器)

avatar
前端工程师 @豌豆公主

iterator (迭代器/遍历器)

今天的主角是iterator。他的汉语意思是(迭代器,遍历器) 遍历器 ? 那岂不是和for循环, forEach, map 差不多,都是遍历,循环数据? 今天我们就来窥见历史,一起来聊聊js中的 遍历器,迭代。看看他们是怎么从for 循环到ForEach,到iterator,再到for of 的

从for 说起

for 循环是一个老前辈,在js出来的时候,就已经存在了。我们似乎也在乐此不疲的使用的。

比如我们有5个学生的信息,需要依次输出

let student = [
  {
    name: '张一',
    age: 10,
  },
  {
    name: '张二',
    age: 11,
  },
  {
    name: '张三',
    age: 12,
  },
];

数据结构如上,代码如下

for (let i = 0; i < student.length; i++) {
  console.log(student[i].name);
}

这样实现并没有什么问题。但是从写法上,我们看看有没有什么优化的点,那我们先来简单分析一下这个for循环

  1. 需要引入一个变量(i)做计数器,来做终止条件的判断 i < student.length

  2. 需要靠下标去取值 student[i].name

  3. 很多数据结构不是数组,无法使用 Map, Set ...

  4. 每次遍历,都要student.length, 时间复杂度是O(n) 。 当然,可以用变量把student.length,接收,这样时间复杂度就是O(1)了。

那聪明的javascript, 面对上面这种情况,有什么好的妙招呢?

forEach

为了解决上面所说的问题,数组提供了内置的forEach方法。(但是并没有解决for循环的所有缺点,而且还只是针对数组)

我们先来看一段js代码

student.forEach((item, index, arr) => {
  console.log(item.name);
});

从写法上来看,确实有不少优化,forEach内部做了封装。所以我们不用引入新的变量,也不用判断终止条件,就可以轻松遍历数据。但是当我们要中在forEach中,中途退出时

let arr = [1,2,3,4,5]
arr.forEach((item, index, arr) => {
  if (item == 3) return;
  console.log(item); // 1,2,4,5
});

forEach为什么不能中断 zhuanlan.zhihu.com/p/385521894

设置了终止条件,但是并不会终止,而是当item == 3 的时候,只是跳出了本次循环,后面的循环依旧会执行

除了抛出异常以外,没有办法中止或跳出 forEach() 循环。如果你需要中止或跳出循环,forEach() 方法不是应当使用的工具,可以使用try cache 来强抛出错误,接收错误(为了终止条件,强行报错,是不是合理)...

难道循环就止步不前了吗? 随着javascript的升级,来到了es6,es6 又引入了一些新的复杂数据类型(Map, Set),老的问题还没有优化完,新的数据复杂数据又出来了,这些新增进来的数据结构,又该用什么样的方式遍历,之前的那一摊子烂泥,,该怎么一起糊上墙呢 😂

很庆幸啊,真的很庆幸, 机智的javascript真的是太机智了,引出了Iterator(遍历器) 。 这个主角,真的是千呼万唤使出来啊

Iterator:

以前我们只说遍历,现在出来了个遍历器。自如其名,器: 就是工具,容器,可以处理很多数据结构,只要部署了 Iterator 接口,就可以完成遍历操作(即依次处理该数据结构的所有成员)。

 Q: 怎么是```部署了 Iterator 接口```?

 A: 就是数据结构的原型上有Symbol.iterator这个方法
 
 例如:
 Array.prototype

 通过数据类型的原型查找,看是否部署了 Symbol.iterator

image.png

当然,你不用一个一个的试,我已经给你准备好了

  • Array
  • Map
  • Set
  • String
  • 函数的 arguments 对象
  • NodeList 对象

ES6 的这些些数据结构原生具备 Iterator 接口,即不用任何处理,就可以被for...of循环遍历。原因在于,这些数据结构原生部署了Symbol.iterator属性(详见下文),另外一些数据结构没有(比如对象)。凡是部署了Symbol.iterator属性的数据结构,就称为部署了遍历器接口。调用这个接口,就会返回一个遍历器对象(iterable)。

[Symbol.iterator]的迭代过程

摘录至阮一峰大佬的文章

(1)创建一个指针对象,指向当前数据结构的起始位置。也就是说,遍历器对象本质上,就是一个指针对象。

(2)第一次调用指针对象的next方法,可以将指针指向数据结构的第一个成员。

(3)第二次调用指针对象的next方法,指针就指向数据结构的第二个成员。

(4)不断调用指针对象的next方法,直到它指向数据结构的结束位置。

精简描述[Symbol.iterator]的迭代过程

  • 迭代器的返回对象,需要有next方法,用于下一下遍历数据结构的成员
  • 每一次遍历,都返回一个对象{done: false, value: xxx}
    • done 记录是否遍历完成
    • value 当前遍历的结果

我们怎么样去实现一个迭代器?

  1. 首先要知道当前的迭代数据的长度
  2. 迭代器返回一个对象 return {}
  3. 需要有next方法 next(){}
  4. 每一次迭代,都返回一个对象 {done: false, value: xxx}
  5. 根据终止条件,改写返回对象的值 {done: true, value: undefinde}

先来看一个残缺版,容易理解

let student = ['a', 'b'];
function makeIterator(arr) {
  let index = 0;
  return {
    // 迭代器返回一个对象
    next() {
      // 有next方法,next 会返回一个对象,{done:false, value: xxx}
      return {
        done: false,
        value: arr[index++],
      };
    },
  };
}

残缺版是不是很easy😂,为什么说残缺呢,因为还没有加上终止条件。

let student = ['a', 'b'];
function makeIterator(arr) {
  let index = 0;
  return {
    next() {
      if (index < arr.length) {
        return {
          done: false,
          value: arr[index++],
        };
      } else {
        return {
          done: true,
          value: undefined,
        };
      }
    },
  };
}
let it = makeIterator(student); //调用迭代器(iterator),返回的是迭代器对象(iterable)。此时并不能执行,需要调用next方法
console.log('it', it); // 迭代器对象
console.log(it.next()); //  调用next,会返回一个对象。 {done: false, value: xxx} done: 迭代结束标识,value, 每次迭代的值
console.log(it.next());
console.log(it.next()); // 如果迭代完毕, {done: true, value: undefined}

上面其实还可以优化

--- else {}
 index < arr.length
        ? {
            done: false,
            value: arr[index++],
          }
        : {
            done: true,
            value: undefined,
          };
其实每次我们取值,迭代结束之前,只关心value,迭代结束,只关心done

index < arr.length
        ? {
            value: arr[index++],
          }
        : {
            done: true,
          };

使用for of 迭代 迭代对象

对象迭代

let student = {
  a: 1,
  b: 2,
  [Symbol.iterator]() {
    let index = 0,
      keys = Reflect.ownKeys(this),
      len = keys.length;
    return {
      next() {
        return index < len
          ? { done: false, value: keys[index++] }
          : { done: true, value: undefined };
      },
    };
  },
};
for (let key of student) {
  console.log('key', key);
}

[Symbol.iterator]的迭代的核心

  • 循环逻辑: 不停的调用next,进行下一次循环
  • 终止条件: 每一次遍历,都返回一个对象{done: false, value: xxx},done: true, 标识终止。value: xxx每一次迭代的值
  • 固有属性: [Symbol.iterator]

扩展

概念梳理

  1. 什么是循环:循环算是最基础的概念, 凡是重复执行一段代码, 都可以称之为循环.  大部分的递归, 遍历, 迭代, 都是循环。所以说,我认为迭代可以算作是循环的子集。
  2. 什么是迭代:百度百科给出的迭代的定义 迭代是指让计算机对一组指令(或一定步骤)进行重复执行,在每次执行这组指令(或这些步骤)时,都从变量的原值推出它的一个新值

所以说: 这个过程当中, 旧值和新的值可以不连续, 可以不是顺序的,只要满足以下下三个条件的都算是迭代

  • 有个初始的值

  • 有一套算法,对这个初始值操作, 并计算出新的值

  • 新的值还可以再次调用刚才的算法,再产生新的值,这个过程是可控的

  1. 可迭代对象(iterable):能够被迭代的对象,成为可迭代对象(iterable),例如 Map, Set...。一种数据结构只要部署了 Iterator 接口,我们就称这种数据结构是“可遍历的”(iterable)

  2. 迭代器(Iterator):它是一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署 Iterator 接口,就可以完成遍历操作(即依次处理该数据结构的所有成员)。

  3. 迭代器协议( Iterator protocol ): 所谓协议,就是一系列规定的实现的一个东西。就是我们说的迭代器的实现原理

为什么对象没有默认部署

对象(Object)之所以没有默认部署 Iterator 接口,是因为对象的哪个属性先遍历,哪个属性后遍历是不确定的,需要开发者手动指定。本质上,遍历器是一种线性处理,对于任何非线性的数据结构,部署遍历器接口,就等于部署一种线性转换。不过,严格地说,对象部署遍历器接口并不是很必要,因为这时对象实际上被当作 Map 结构使用,ES5 没有 Map 结构,而 ES6 原生提供了 Map

判断是否可迭代

只需要判断对象的Symbol.iterator 是不是一个函数

const isIterable = obj => obj != null && typeof obj[Symbol.iterator] === 'function';

类似数组的对象

ECMA-262 的定义是

  1. 它必须是一个对象
  2. 它有 length 属性
let arrayLike = {
    '0': 'a',
    '1': 'b',
    '2': 'c',
    length: 3
};

let bb = {
  v: 1,
  b: 2,
  c: 34,
  length:3
}
// 所以这两个都是类数组对象

定义里还有两条注释

1, 通常,类似数组的对象也有一些具有整数索引名的属性。然而,这不是这个定义的要求。
2, 数组对象和字符串对象是类数组对象的实例。

所以 NodeList 对象和 arguments 对象和 类似数组的对象 都可以叫类数组,只一种表现为数组形式,一种表现为对象形式。但是只有整数索引的属性才会部署Iterator 接口

字符串的 Iterator 接口

字符串为什么还会部署Iterator 接口?

image.png

image.png 字符串是一个类似数组的对象,有length,有整数索引名的属性。 也原生具有 Iterator 接口。

当我们有一个类似数组的对象,并且整数索引名的属性。但是没有Iterator 接口,该怎么取部署一个Iterator 接口?

  • 从0 开始,手写一个Iterator 接口
  • 奇淫技巧
let 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'
}

既然部署了Iterator 接口,那么 数组的结构,扩展运算符,for of, 都适用于字符串,换句话说,只要部署了Iterator 接口,上面说的这些都能用!!!

结构赋值

image.png

扩展运算符

image.png

Set

image.png

遍历器对象的 return(),throw()

遍历器对象除了具有next()方法,还可以具有return()方法和throw()方法。如果你自己写遍历器对象生成函数,那么next()方法是必须部署的,return()方法和throw()方法是否部署是可选的。

return()方法的使用场合是,如果for...of循环提前退出(通常是因为出错,或者有break语句),就会调用return()方法。如果一个对象在完成遍历前,需要清理或释放资源,就可以部署return()方法。 这里不讲他的具体实现,使用起来和for循环一致

yield* 关键字

yield*后面跟的是一个可遍历的结构,执行时也会调用迭代器函数。

let generator = function* () {
  yield 1;
  yield* [2,3,4];
  yield 5;
};

var iterator = generator();

iterator.next() // { value: 1, done: false }
iterator.next() // { value: 2, done: false }
iterator.next() // { value: 3, done: false }
iterator.next() // { value: 4, done: false }
iterator.next() // { value: 5, done: false }
iterator.next() // { value: undefined, done: true }

涉及到了生成器:Generator 函数 ,下一篇讲解

回顾

从for到forEach到迭代器,for of。js在不断的进步,for循环虽然简单,可以return,break,continue,但是需要自己实现终止条件需要根据索引获取数据。颇为麻烦。

ES5 出来了forEach,内部做了封装,不需要我们去关心终止条件,也不用索引去获取当前项,缺点是不能终止代码执行,如果利用 Error,进行try catch 强行中断,又不太优雅

面对不断增加的数据类型,我们该怎么统一去遍历这些数据类型呢?比如ES6新增了Map,Set。出现了一种新的遍历方式,for of。 for of机制兼顾了 forforEach

for of的底层实现,是用一个叫做iterator的接口实现的,我们讲了iterator的接口的核心逻辑

  • 循环逻辑: 不停的调用next,进行下一次循环
  • 终止条件: 每一次遍历,都返回一个对象{done: false, value: xxx},done: true, 标识终止。value: xxx每一次迭代的值
  • 固有属性: [Symbol.iterator]

讲到此处,蓦然回首,从for 循环的简单纯朴,到现在新的名词,新的封装出现,我们似乎少写很多代码,殊不知,早已在底层封装完备

真是感慨啊,哪有什么虽有静好,只不过有人在替我们负罪前行。(哭唧唧o(╥﹏╥)o)