JavaScript骚操作之遍历、枚举与迭代(下篇)

6,507 阅读10分钟

前言

JavaScript 遍历、枚举与迭代的骚操作(上篇)总结了一些常用对象的遍历方法,大部分情况下是可以满足工作需求的。但下篇介绍的内容,在工作中95%的情况下是用不到的,仅限装逼。俗话说:装得逼多必翻车!若本文有翻车现场,请轻喷。

ES6 迭代器(iterator)、生成器(generator)

上一篇提到,for of循环是依靠对象的迭代器工作的,如果用for of循环遍历一个非可迭代对象(即无默认迭代器的对象),for of循环就会报错。那迭代器到底是何方神圣?

迭代器是一种特殊的对象,其有一个next方法,每一次枚举(for of每循环一次)都会调用此方法一次,且返回一个对象,此对象包含两个值:

  • value属性,表示此次调用的返回值(for of循环只返回此值);
  • done属性,Boolean值类型,标志此次调用是否已结束。

生成器,顾名思义,就是迭代器他妈;生成器是返回迭代器的特殊函数,迭代器由生成器生成。

生成器声明方式跟普通函数相似,仅在函数名前面加一个*号(*号左右有空格也是可以正确运行的,但为了代码可读性,建议左边留空格,右边不留);函数内部使用yield关键字指定每次迭代返回值。

    // 生成器
    function *iteratorMother() {
        yield 'we';
        yield 'are';
        yield 'the BlackGold team!';
    }

    // 迭代器
    let iterator = iteratorMother();

    console.log(iterator.next());  // { value: "we", done: false }
    console.log(iterator.next());  // { value: "are", done: false }
    console.log(iterator.next());  // { value: "the BlackGold team!", done: false }

    console.log(iterator.next());  // { value: undefined, done: true }
    console.log(iterator.next());  // { value: undefined, done: true }

上面的例子展示声明了一个生成器函数iteratorMother的方式,调用此函数返回一个迭代器iterator。

yield是ES6中的关键字,它指定了iterator对象每一次调用next方法时返回的值。如第一个yield关键字后面的字符串"we"即为iterator对象第一次调用next方法返回的值,以此类推,直到所有的yield语句执行完毕。

注意:当yield语句执行完毕后,调用iterator.next()会一直返回{ value: undefined, done: true },so,别用for of循环遍历同一个迭代器两次

    function *iteratorMother() {
        yield 'we';
        yield 'are';
        yield 'the BlackGold team!';
    }

    let iterator = iteratorMother();

    for (let element of iterator) {
        console.log(element);
    }

    // we
    // are
    // the BlackGold team!

    for (let element of iterator) {
        console.log(element);
    }

    // nothing to be printed
    // 这个时候迭代器iterator已经完成他的使命,如果想要再次迭代,应该生成另一个迭代器对象以进行遍历操作

注意:可以指定生成器的返回值,当运行到return语句时,无论后面的代码是否有yield关键字都不会再执行;且返回值只返回一次,再次调用next方法也只是返回{ value: undefined, done: true }

    function *iteratorMother() {
        yield 'we';
        yield 'are';
        yield 'the BlackGold team!';
        return 'done';

        // 不存在的,这是不可能的
        yield '0 error(s), 0 warning(s)'
    }

    // 迭代器
    let iterator = iteratorMother();

    console.log(iterator.next());  // { value: "we", done: false }
    console.log(iterator.next());  // { value: "are", done: false }
    console.log(iterator.next());  // { value: "the BlackGold team!", done: false }

    console.log(iterator.next());  // { value: "done", done: true }
    console.log(iterator.next());  // { value: undefined, done: true }

注意third time:yield关键字仅可在生成器函数内部使用,一旦在生成器外使用(包括在生成器内部的函数例使用)就会报错,so,使用时注意别跨越函数边界

    function *iteratorMother() {
        let arr = ['we', 'are', 'the BlackGold team!'];

        // 报错了
        // 以下代码实际上是在forEach方法的参数函数里面使用yield
        arr.forEach(item => yield item);
    }

上面的例子,在JavaScript引擎进行函数声明提升的时候就报错了,而非在实例化一个迭代器实例的时候才报错。

注意fourth time:别尝试在生成器内部获取yield指定的返回值,否则会得到一个undefined

    function *iteratorMother() {
        let a = yield 'we';
        let b = yield a + ' ' +  'are';
        yield b + ' ' + 'the BlackGold team!';
    }

    let iterator = iteratorMother();

    for (let element of iterator) {
        console.log(element);
    }

    // we
    // undefined are
    // undefined the BlackGold team!

note:可以使用匿名函数表达式声明一个生成器,只要在function关键字后面加个可爱的*号就好,例子就不写了;但是不可以使用箭头函数声明生成器

为对象添加生成器

使用for of循环去遍历一个对象的时候,会先去寻找此对象有没有生成器,若有则使用其默认的生成器生成一个迭代器,然后遍历此迭代器;若无,报错!

上篇也提到,像Set、Map、Array等特殊的对象类型,都有多个生成器,但是自定义的对象是没有内置生成器的,不知道为啥;就跟别人有女朋友而我没有女朋友一样,不知道为啥。没关系,自己动手,丰衣足食;我们为自定义对象添加一个生成器(至于怎么解决女朋友的问题,别问我)

    let obj = {
        arr: ['we', 'are', 'the BlackGold team!'],
        *[Symbol.iterator]() {
            for (let element of this.arr) {
                yield element;
            }
        }
    }

    for (let key of obj) {
        console.log(key);
    }

    // we
    // are
    // the BlackGold team!

好吧,我承认上面的例子有点脱了裤子放P的味道,当然不是说这个例子臭,而是有点多余;毕竟我们希望遍历的是对象的属性,那就换个方式搞一下吧

    let father = {
        *[Symbol.iterator]() {
            for (let key of Reflect.ownKeys(this)) {
                yield key;
            }
        }
    };

    let obj = Object.create(father);

    obj.a = 1;
    obj[0] = 1;
    obj[Symbol('PaperCrane')] = 1;
    Object.defineProperty(obj, 'b', {
        writable: true,
        value: 1,
        enumerable: false,
        configurable: true
    });

    for (let key of obj) {
        console.log(key);
    }

    /* 看起来什么鬼属性都能被Reflect.ownKeys方法获取到 */
    // 0
    // a
    // b
    // Symbol(PaperCrane)

通过上面例子的展示的方式包装对象,确实可以使用for of来遍历对象的属性,但是使用起来还是有点点的麻烦,目前没有较好的解决办法。我们在创建自定义的类(构造器)的时候,可以加上Symbol.iterator生成器,那么类的实例就可以使用for of循环遍历了。

note:Reflect对象是反射对象,其提供的方法默认特性与底层提供的方法表现一致,如Reflect.ownKeys的表现就相当于Object.keys、Object.getOwnPropertyNames、Object.getOwnPropertySymbols三个操作加起来的操作。上篇有一位ID为“webgzh907247189”的朋友提到还有这种获取对象属性名的方法,这一篇就演示一下,同时也非常感谢这位朋友的宝贵意见。

迭代器传值

上面提到过,如果在迭代器内部获取yield指定的返回值,将会得到一个undefined,但代码逻辑如果依赖前面的返回值的话,就需要通过给迭代器的next方法传参达到此目的

    function *iteratorMother() {
        let a = yield 'we';
        let b = yield a + ' ' +  'are';
        yield b + ' ' + 'the BlackGold team!';
    }

    let iterator = iteratorMother(),
        first, second, third;

    // 第一次调用next方法时,传入的参数将不起任何作用
    first = iterator.next('anything,even an Error instance');
    console.log(first.value);                // we
    second = iterator.next(first.value);
    console.log(second.value);               // we are
    third = iterator.next(second.value);
    console.log(third.value);                // we are the BlackGold team!

往next方法传的参数,将会成为上一次调用next对应的yield关键字的返回值,在生成器内部可以获得此值。所以调用next方法时,会执行对应yield关键字右侧至上一个yield关键字左侧的代码块;生成器内部变量a的声明和赋值是在第二次调用next方法的时候进行的。

note:往第一次调用的next方法传参时,将不会对迭代有任何的影响。此外,也可以往next方法传递一个Error实例,当迭代器报错时,后面的代码将不会执行。

解决回调地狱

每当面试时问到如何解决回调地狱问题时,我们的第一反应应该是使用Promise对象;如果你是大牛,可以随手甩面试官Promise的实现原理;但是万一不了解Promise原理,又想装个逼,可以试试使用迭代器解决回调地狱问题

    // 执行迭代器的函数,参数iteratorMother是一个生成器
    let iteratorRunner = iteratorMother => {
        let iterator = iteratorMother(),
            result = iterator.next(); // 开始执行迭代器
        
        let run = () => {
            if (!result.done) {
                // 假如上一次迭代的返回值是一个函数
                // 执行result.value,传入一个回调函数,当result.value执行完毕时执行下一次迭代
                if ((typeof result.value).toUpperCase() === 'FUNCTION') {
                    result.value(params => {
                        result = iterator.next(params);

                        // 继续迭代
                        run();
                    });
                } else {
                    // 上一次迭代的返回值不是一个函数,直接进入下一次迭代
                    result = iterator.next(result.value);
                    run();
                }
            }
        }

        // 循环执行迭代器,直到迭代器迭代完毕
        run();
    }

        // 异步函数包装器,为了解决向异步函数传递参数问题
    let asyncFuncWrapper = (asyncFunc, param) => resolve => asyncFunc(param, resolve),
        // 模拟的异步函数
        asyncFunc = (param, callback) => setTimeout(() => callback(param), 1000);

    iteratorRunner(function *() {
        // 按照同步的方式快乐的写代码
        let a = yield asyncFuncWrapper(asyncFunc, 1);
        a += 1;
        let b = yield asyncFuncWrapper(asyncFunc, a);
        b += 1;
        let c = yield asyncFuncWrapper(asyncFunc, b);

        let d = yield c + 1;
        console.log(d);          // 4
    });

上面的例子中,使用setTimeout来模拟一个异步函数asyncFunc,此异步函数接受两个参数:param和回调函数callback;在生成器内部,每一个yield关键字返回的值都为一个包装了异步函数的函数,用于往异步函数传入参数;执行迭代器的函数iteratorRunner,用于循环执行迭代器,并运行迭代器返回的函数。最后,我们可以在匿名生成器里面以同步的方式处理我们的代码逻辑。

以上的方式虽然解决了回调地狱的问题,但本质上依然是使用回调的方式调用代码,只是换了代码的组织方式。生成器内部的代码组织方式,有点类似ES7的async、await语法;所不同的是,async函数可以返回一个promise对象,搬砖工作者可以继续使用此promise对象以同步方式调用异步函数。

    let asyncFuncWrapper = (asyncFunction, param) => {
            return new Promise((resolve, reject) => {
                asyncFunction(param, data => {
                    resolve(data);
                });
            });
        },
        asyncFunc = (param, callback) => setTimeout(() => callback(param), 1000);

    async function asyncFuncRunner() {
        let a = await asyncFuncWrapper(asyncFunc, 1);
        a += 1;
        let b = await asyncFuncWrapper(asyncFunc, a);
        b += 1;
        let c = await asyncFuncWrapper(asyncFunc, b);

        let d = await c + 1;
        return d;
    }

    asyncFuncRunner().then(data => console.log(data));    // 三秒后输出 4

委托生成器

在这个讲求DRY(Don't Repeat Yourself)的时代,生成器也可以进行复用。

    function *iteratorMother() {
        yield 'we';
        yield 'are';
    }

    function *anotherIteratorMother() {
        yield 'the BlackGold team!';
        yield 'get off work now!!!!!!';
    }

    function *theLastIteratorMother() {
        yield *iteratorMother();
        yield *anotherIteratorMother();
    }

    let iterator = theLastIteratorMother();

    for (let key of iterator) {
        console.log(key);
    }

    // we
    // are
    // the BlackGold team!
    // get off work now!!!!!!

上面的例子中,生成器theLastIteratorMother定义里面,复用了生成器iteratorMother、anotherIteratorMother两个生成器,相当于在生成器theLastIteratorMother内部声明了两个相关的迭代器,然后进行迭代。需要注意的是,复用生成器是,yield关键字后面有星号。

几个循环语句性能

上一篇有小伙伴提到对比一下遍历方法的性能,我这边简单对比一下各个循环遍历数组的性能,测试数组长度为1000万,测试代码如下:

    let arr = new Array(10 * 1000 * 1000).fill({ test: 1 });

    console.time();
    for (let i = 0, len = arr.length; i < len; i++) {}
    console.timeEnd();

    console.time();
    for (let i in arr) {}
    console.timeEnd();

    console.time();
    for (let i of arr) {}
    console.timeEnd();

    console.time();
    arr.forEach(() => {});
    console.timeEnd();

结果如下图(单位为ms,不考虑IE):

以上的结果可能在不同的环境下略有差异,但是基本可以说明,原生的循环速度最快,forEach次之,for of循环再次之,forin循环又次之。其实,如果数据量不大,遍历的方法基本不会成为性能的瓶颈,考虑如何减少循环遍历或许更实际一点。

总结

含泪写完这一篇,我要下班了,再见各位。

@Author: PaperCrane