Iterator的奥秘:深入理解JavaScript的迭代机制

379 阅读7分钟

前言

最近看到一道面试题觉得很有意思,如何才能使下面的代码打印出结果

const obj = {
    a: 1,
    b: 2,
    c: 3
}
for (let v of obj) {
    console.log(v)
}

猛地看上去挺简单,但是里面其实蕴含了很多东西,想让其打印出结果就必须要了解一个概念就是迭代器Iterator,这也正是本文所要讲的,先说下答案吧。

const obj = {
    a: 1,
    b: 2,
    c: 3,
    [Symbol.iterator]: function() {
        const values = Object.values(this); 
        let index = 0;
        
        return {
            next: () => {
                return{
                    value: values[index++],
                    done: index > values.length? true : false
                }
            }
        };
    }
};

只需要在for of之前给对象添加Symbol.iterator属性就可以完美打印出1,2,3,这个属性是一个函数,这里巧妙借用了数组可迭代这一点完成对对象的遍历,首先对象本身会默认调用Symbol.iterator方法。看不懂?没关系,接下来才是正文部分,那就先从概念部分说起吧。

Iterator是什么

image.png

相信经验比较丰富的一些同学可能会看到过这个,在一些数组或者Map、Set中经常会看到这个属性,但在对象(Object)中就没有,下面就来一起彻底弄明白这个东西。

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

原生具备Iterator的数据结构有

  • Array
  • Map
  • Set
  • String
  • arguments对象
  • NodeList对象

为什么需要Iterator

在Javascript中表示集合的数据结构有数组、对象、Map和Set数据结构,它们都有各自的特点,但同时也有一个共同的需求:遍历它们的内容。然而不同的数据结构都有对应的遍历方法,比如,数组有forEach、map、filter等方法,对象有for in/Object.keys()方法,Map和Set也都有各自的遍历方法,这就导致一个问题,如果要处理一个不同类型的集合,那么就要写很多的判断条件来根据集合的类型选择对应的遍历方法,这显然是不方便的。

还有一点就是迭代注重的是过程,而遍历指的是在一个固定的长度中把每个元素依次取出来,比如说数组结构必须有长度才可以进行遍历,而迭代不需要关注有多少元素只负责把元素依次取出来。

于是ES6中为了解决这个问题引入了Iterator这种统一的访问机制,即for of循环,使用for of循环时会自动去寻找该数据结构是否部署了Iterator接口。这就是为什么会出现Iterator的原因,总结一下:

  1. 为各种数据结构,提供一个统一的、简便的访问接口;
  2. 使得数据结构的成员能够按某种次序排列(自定义遍历行为);
  3. for of使用;

Iterator的实现

Iterator其实就是一个对象,对象中有一个next方法(固定格式),第一次调用对象的next方法可以返回当前数据结构中的第一个成员,第二次调用对象的next方法可以返回当前数据结构中的第二个成员,直到数据结构中的结束位置结束。下面来模拟实现一个遍历器生成函数:

const arr = [1,2,3];
function makeIterator(arr){
    let nextIndex = 0;
    return {
        next(){
               return nextIndex < arr.length?
               {value:arr[nextIndex++],done:false}:
               {value:undefined,done:true}
        }
    }
}

const iterator = makeIterator(arr);
console.log(iterator.next()) // { value: 1, done: false }
console.log(iterator.next()) // { value: 2, done: false }
console.log(iterator.next()) // { value: 3, done: false }
console.log(iterator.next()) // { value: undefined, done: true }

可以看出调用遍历器对象的next方法,就可以遍历事先给定的数据结构。在makeIterator函数中首先声明了一个表示从头开始的下标,每调用一次next方法就会以对象的形式返回数据结构中对应的元素,其中value表示数据结构中当前位置的值,done表示遍历是否结束。

上面提到的Symbol.iterator属性,它本身是一个函数,是当前数据结构默认的遍历器生成函数,这个函数的实现类似于makeItrerator函数,它默认部署在可遍历的数据结构中。

只要数据结构具有Symbol.iterator属性就可以使用for of遍历,for...of循环内部调用的其实就是数据结构的Symbol.iterator方法。

const arr = [1,2,3,4];
for (let v of arr){
    console.log(v) // 1 2 3 4 
}

Object对象上的没有默认的Iterator接口,是因为对象是无序的,里面的哪个属性先遍历哪个属性后遍历是不确定的,需要开发者手动确定。要使其可以进行遍历,添加一个遍历器生成函数即可。

const obj = {
    name:"xxx",
    age:18,
}
// 如果不加此函数,将会报错 obj is not iterable
Object.prototype[Symbol.iterator] = function(){
    let nextIndex = 0
    return {
        next:()=>{ // 确定this指向
            return nextIndex < Object.values(this).length?{value:Object.values(this)[nextIndex++],done:false}:{value:undefined,done:true}
        }
    }
}
// 相当于下面代码的语法糖
for (let v of obj){
    console.log("v",v) // xxx 18
}

// for of 实现自动迭代的原理
const iterator = obj[Symbol.iterator]();
let result = iterator.next();
while(!result.done){
    const item = result.value;
    console.log(item);
    result = iterator.next();
}

此外其他默认调用Iterator接口的场景还有解构赋值、扩展运算符。再补充一下for in for of是两种不同的数据结构,分别用于遍历两种不同的数据结构,for in 枚举的对象的键,而不是值,for of直接遍历的是对象的值,而不是键。

for of不是只能获取到对象的值,因为它是基于迭代器的所以遍历的形式可以是各种各样的,我们可以根据需求自行定义迭代器:

const obj = {
    name: "zhangsan",
    age: 18,
    [Symbol.iterator]() {
        let i = 0;
        const keys = Object.keys(this);
        return {
            next: () => {
                const propName = keys[i]
                const propValue = this[propName];
                const result = {
                    value: { propName, propValue },
                    done: i >= keys.length ? true : false
                }
                i++;
                return result
            }
        }
    }
}
for (let v of obj) {
    console.log(v) // {propName:'name',propValue:'zhangsan'}    {propName:'age',propValue:18}
}

生成器

语法

生成器(generator 函数)是为了更方便的使用迭代器,生成器内函数内部是为了给生成器的每次迭代提供数据。

function* test(){
    console.log('test...')
}
const iterator = test()

代码中的 test 的函数虽然被调用但并不会立马执行里面的语句,而是会返回一个迭代器对象,调用迭代器对象中的next方法才会开始执行。

每次调用生成器的 next 方法时,会导致生成器函数会执行到下一个 yield 语句,yield 是一个关键字,该关键字只能在函数内部使用,表达“产生”了一个迭代数据,并返回一个对象,对象中包含 value 和 done 属性,下面用代码来解释这句话。

function* test(){
    console.log('第一次执行')
    yield 1; 
    console.log('第二次执行')
    yield 2;
    console.log('第三次执行')
}
const generator=test()
generator.next() // 第一次执行 {value:1,done:false}
generator.next() // 第二次执行 {value:2,done:false}
generator.next() // 第三次执行 {value:undefined,done:true}

当执行第一次 next 方法时会输出'第一次执行'和 1,然后会暂停下面代码的执行,直到遇到第二个 next 方法的调用,当输出{value:undefined,done:;true}时则表示迭代完成。

返回值

生成器函数可以有返回值,返回值会出现在第一次done 为 true 的value 属性中。

function* test(){
    console.log('第一次执行')
    yield 1; 
    console.log('第二次执行')
    yield 2;
    console.log('第三次执行')
    return 3;
}
const generator=test()
generator.next() 
generator.next() 
generator.next() // 第三次执行 {value:3,done:true}

还是以上面的代码为例,当完成最后一次迭代时,存在返回值的话 value 属性的值将不再是undefined 而是我们返回的值。

参数

调用生成器 next 方法时,可以传递参数,传递的参数会交给 yield表达式的返回值。

function* test(){
    let info=yield 1; 
    yield 2+info;
}
const generator=test()
generator.next(); //{value:1,done:false}
generator.next(1); // {value:3,done:false}

总结

这篇文章主要讲解了JavaScript中一个可迭代对象是如何进行迭代的,详细说明了Iterator遍历器的概念、出现的原因以及如何实现,属于原理级别的知识,虽然在工作中不会经常用到,但是本着知其然知其所以然的道理了解它的原理,内部是如何运行的才能知道一些报错信息出现的原因以及解决方案,大家加油吧!