JavaScript騷操作之遍歷、列舉與迭代(下篇)

黑金團隊發表於2018-12-05

前言

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):

JavaScript騷操作之遍歷、列舉與迭代(下篇)

以上的結果可能在不同的環境下略有差異,但是基本可以說明,原生的迴圈速度最快,forEach次之,for of迴圈再次之,forin迴圈又次之。其實,如果資料量不大,遍歷的方法基本不會成為效能的瓶頸,考慮如何減少迴圈遍歷或許更實際一點。

總結

含淚寫完這一篇,我要下班了,再見各位。

@Author: PaperCrane

相關文章