【ES6基礎】生成器(Generator)

前端達人發表於2019-06-30

在上一篇文章裡《【ES6基礎】迭代器(iterator)》,筆者介紹了迭代器及相關例項,我們要實現一個迭代器要寫不少的程式碼。幸運的是,ES6引入了一個新的函式型別——生成器函式(Generator function),讓我們能夠更輕鬆更便捷的實現迭代器的相關功能。

今天筆者將從以下幾個方面進行介紹生成器(Generator):

  • 什麼是生成器
  • 生成器的基本語法
  • yield關鍵字
  • 生成器函式的型別檢測
  • yield*委託
  • return(value)方法
  • throw(exception)方法
  • 向生成器傳遞資料
  • 生成器示例應用

本篇文章閱讀時間預計15分鐘

什麼是生成器?

生成器第一次出現在CLU語言中。CLU語言是美國麻省理工大學的Barbara Liskov教授和她的學生們在1974年至1975年間所設計和開發出來的。Python、C#和Ruby等語言都受到其影響,實現了生成器的特性,生成器在CLU和C#語言中被稱為迭代器(iterator),Ruby語言中稱為列舉器(Enumerator)。

生成器的主要功能是:通過一段程式,持續迭代或列舉出符合某個公式或演算法的有序數列中的元素。這個程式便是用於實現這個公式或演算法的,而不需要將目標數列完整寫出。

在ES6定義的生成器函式有別於普通的函式,生成器可以在執行當中暫停自身,可以立即恢復執行也可以過一段時間之後恢復執行。最大的區別就是它並不像普通函式那樣保證執行到完畢。還有一點就是,在執行當中每次暫停或恢復迴圈都提供了一個雙向資訊傳遞的機會,生成器可以返回一個值,恢復它的控制程式碼也可發回一個值。

生成器的基本語法

與普通函式語法的差別,在function關鍵字和函式名直接有個*號,這個*作為生成器函式的主要識別符號,如下所示:

function *it(){}複製程式碼

*號的位置沒有嚴格規定,只要在中間就行,你可以這麼寫:

function *it(){ }
function* it(){ }
function * it(){ }
function*it(){ }複製程式碼

筆者覺得*靠近函式名——function *it(){ },看著更為清晰,選擇哪種書寫方式完全憑個人喜好。

呼叫生成器也十分簡單,就和呼叫普通函式一樣,比如:

it();複製程式碼

同時也可以向生成器函式傳遞引數:

function *it(x,y){ 

}
it(5,10);複製程式碼

yield關鍵字

生成器函式中,有一個特殊的新關鍵字:yield——用來標註暫停點,如下段程式碼所示:

function *generator_function(){ 
  yield 1; 
  yield 2; 
  yield 3;
}複製程式碼

如何執行生成器呢?如下段程式碼所示:

let generator = generator_function();
console.log(generator.next().value);//1
console.log(generator.next().value);//2
console.log(generator.next().value);//3
console.log(generator.next().done);//true

generator = generator_function();
let iterable = generator[Symbol.iterator]();
console.log(iterable.next().value);//1
console.log(iterable.next().value);//2
console.log(iterable.next().value);//3
console.log(iterable.next().done);//true複製程式碼

從上述程式碼我們可以看出:我們可以在例項化的生成器generator的物件裡直接呼叫next()方法,同時我們也可以呼叫生成器原型鏈的Symbol.iterator屬性方法呼叫next(),效果是一致的。我們每呼叫一次next()方法,就是順序在對應的yield關鍵字的位置暫停,遵守迭代器協議,返回例如這樣形式的物件: {value:”1″,done:false},直到所有的yield的值消費完為止,再一次呼叫next()方法生成器函式會返回 {value:undefined,done:true},說明生成器的所有值已消費完。由此可見done屬性用來標識生成器序列是否消費完了。當done屬性為true時,我們就應該停止呼叫生成器例項的next方法。還有一點需要說明帶有yield的生成器都是以惰性求值的順序執行,當我們需要時,對應的值才會被計算出來。

生成器函式的型別檢測

如何檢測一個函式是生成器函式和生成器例項的原型呢,我們可以使用constructor.prototype屬性檢測,例項程式碼如下:

function *genFn() {}
const gen=genFn();
console.log(genFn.constructor.prototype);//GeneratorFunction {}
console.log(gen.constructor.prototype);//Object [Generator] {}
console.log(gen instanceof genFn)//true
//判斷某個物件是否為指定生成函式所對應的例項複製程式碼

除了以上方法進行判斷,我們還可以使用@@tostringTag屬性,如下段程式碼所示:

function *genFn() {}
const gen=genFn();
console.log(genFn[Symbol.toStringTag]);//GeneratorFunction
console.log(gen[Symbol.toStringTag]);//Generator複製程式碼

yield*委託

yield* 可以將可迭代的物件iterable放在一個生成器裡,生成器函式執行到yield * 位置時,將控制權委託給這個迭代器,直到耗盡為止,如下段程式碼所示:

function *generator_function_1(){ 
 yield 2; 
 yield 3;
}
function *generator_function_2(){
 yield 1; 
 yield* generator_function_1(); 
 yield* [4, 5];
}
const generator = generator_function_2();
console.log(generator.next().value); //1
console.log(generator.next().value); //2
console.log(generator.next().value); //3
console.log(generator.next().value); //4
console.log(generator.next().value); //5
console.log(generator.next().done);  //true複製程式碼

從上述程式碼中,我們在一個生成器中巢狀了一個生成器和一個陣列,當程式執行至生成器generator_function_1()時,將其中的值消費完跳出後,再去迭代消費陣列,消費完後,done的屬性值返回true。

return(value)方法

你可以在生成器裡使用return(value)方法,隨時終止生成器,如下段程式碼所示:

function *generator_function(){ 
 yield 1; 
 yield 2; 
 yield 3;
}
const generator = generator_function();
console.log(generator.next().value); //1
console.log(generator.return(22).value); //22
console.log(generator.next().done);//true複製程式碼

從上述程式碼我們看出,使用return()方法我們提前終止了生成器,返回return裡的值,再次呼叫next()方法時,done屬性的值為true,由此可見return提前終止了生成器,其他的值也不再返回。

throw(exception)方法

除了用return(value)方法可以終止生成生成器,我們還可以呼叫 throw(exception) 進行提前終止生成器,示例程式碼如下:

function *generator_function(){ 
    yield 1;
    yield 2;
    yield 3;    
}
const generator = generator_function();
console.log(generator.next());
try{
    generator.throw("wow");
}
catch(err){
    console.log(err);
}
finally{
    console.log("clean")
}
console.log(generator.next());複製程式碼

上段程式碼輸出:

{ value: 1, done: false }
wow
clean
{ value: undefined, done: true }複製程式碼

由此可以看出,在生成器外部呼叫try…catch…finally,throw()異常被try…catch捕捉並返回,並執行了finally程式碼塊中的程式碼,再次呼叫next方法,done屬性返回true,說明生成器已被終止,提前消費完畢。

我們不僅可以在next執行過程中插入throw()語句,我們還可以在生成器內部插入try…catch進行錯誤處理,程式碼如下所示:

function *generator_function(){ 
try { 
 yield 1; 
} catch(e) { 
 console.log("1st Exception"); 
} 
try { 
 yield 2; 
} catch(e) { 
 console.log("2nd Exception"); 
}
}
const generator = generator_function();
console.log(generator.next().value);
console.log(generator.throw("exception string").value);
console.log(generator.throw("exception string").done);複製程式碼

執行上段程式碼將會輸出:

1
1st Exception
2
2nd Exception
true複製程式碼

從程式碼輸出可以輸出,當我們在generator.throw()方法時,被生成器內部上個暫停點的異常處理程式碼所捕獲,同時可以繼續返回下個暫停點的值。由此可見在生成器內部使用try…catch可以捕獲異常,並不影響值的下次消費,遇到異常不會終止。

向生成器傳遞資料

生成器不但能對外輸出資料,同時我們也可以向生成器內部傳遞資料,是不是很神奇呢,還是從一段程式碼開始說起:

function *generator_function(){ 
    const a = yield 12;
    const b = yield a + 1;
    const c = yield b + 2; 
    yield c + 3; // Final Line
}
const generator = generator_function();
console.log(generator.next().value);
console.log(generator.next(5).value);
console.log(generator.next(11).value);
console.log(generator.next(78).value);
console.log(generator.next().done);複製程式碼

執行上述程式碼將會輸出:

12
6
13
81
true複製程式碼

從上述程式碼我們可以看出:

  • 第一次呼叫generator.next(),呼叫yield 12,並返回值12,相當啟動生成器。並在 yield 12 處暫停。
  • 第二次呼叫我們向其進行傳值generator.next(5),前一個yield 12這行暫停點獲取傳值,並將5賦值給a, 忽略12這個值,然後執行至 yield (a + 1) 這個暫停點,因此是6,並返回給value屬性。並在 yield a + 1 這行暫停。
  • 第三次呼叫next,同理在第二處暫停進行恢復復,把11的值賦值給b,忽略a+1運算,因此在yield b + 2中,返回13,並在此行暫停。
  • 第四次呼叫next,函式執行到最後一行,C變數被賦值78,最後一行為加法運算,因此value屬性返回81。
  • 再次執行next()方法,done屬性返回true,生成器數值消費完畢。

從上述步驟說明中,向生成器傳遞資料,首行的next方法是啟動生成器,即使向其傳值,也不能進行變數賦值,你可以拿上述例子進行實驗,無論你傳遞什麼都是徒勞的,因為傳遞資料只能向上個暫停點進行傳遞,首個暫停點不存在上個暫停點。

生成器示例應用

瞭解生成器的基礎知識後,我們來一起做些有趣的練習:

斐波那契數列

首先我們實現一個生成斐波那契數列的生成器函式,然後編寫一個輔助函式用於進行控制輸出指定位置的數,如下段程式碼所示:

function *fibonacciSequence() {  
    let x = 0, y = 1;  
    for(;;) {   
         yield y;    
        [x, y] = [y, x+y]; 
}}

function fibonacci(n) {  
    for(let f of fibonacciSequence()){    
        if (n-- <= 0) return f;  
        }}
console.log(fibonacci(20))   // => 10946複製程式碼

此函式只能返回指定位置的數值,如果返回指定位置的數列看起來會更加實用,如下段程式碼所示:

function *fibonacciSequence() {  
    let x = 0, y = 1;  
    for(;;) {   
         yield y;    
        [x, y] = [y, x+y]; 
}}

function* take(n, iterable) {  
    let it = iterable[Symbol.iterator](); 
      while(n-- > 0) {        
            let next = it.next();  
    if (next.done){
        return;
    }    
    else { 
        yield next.value
    }; 
}}

console.log([...take(5, fibonacciSequence())])
//[ 1, 1, 2, 3, 5 ]複製程式碼

多個生成器進行交錯迭代

比如我們要實現一個zip函式功能,類似Python的zip函式功能,將多個可迭代的物件合成一個新物件,合成物件的方法,就是迴圈依次從各個物件的位置進行取值合併,比如有兩個陣列a=[1,2,3],b=[4,5,6],合併後就是c=[1,4,2,5,3,6],如何用生成器進行實現呢?如下段程式碼所示:

function *oneDigitPrimes() { 
    yield 2;                   
    yield 3;               
    yield 5;                 
    yield 7;           
}
function *zip(...iterables) {  
    let iterators = iterables.map(i => i[Symbol.iterator]()); 
    let index = 0;  
    while(iterators.length > 0) { 
        if (index >= iterators.length)     
        index = 0;                       
        let item = iterators[index].next();   
        if (item.done) {                       
            iterators.splice(index, 1);      
            }
            else {
                yield item.value;                
                index++;
                }  
        }
}
console.log([...zip(oneDigitPrimes(),"ab",[0])]);
//[ 2, 'a', 0, 3, 'b', 5, 7 ]複製程式碼

從zip函式中我們可以看出:

  • 首先通過Map函式將傳入的可迭代物件進行例項化。
  • 然後迴圈可迭代物件,通過yield關鍵字呼叫next()方法進行返回輸出。
  • 直到對應生成器數值消費完畢,移除對應的生成器(迭代器)物件。
  • 直到所有的生成器函式數值消費完,迴圈迭代的物件為空,函式停止執行。

通過向後追加的形式合併可迭代物件成一個新物件

function* oneDigitPrimes() { 
    yield 2;                   
    yield 3;               
    yield 5;                 
    yield 7;           
}
function* sequence(...iterables) {  
    for(let iterable of iterables) {   
         yield* iterable;  
        }}
console.log([...sequence("abc",oneDigitPrimes())])
//[ 'a', 'b', 'c', 2, 3, 5, 7 ]複製程式碼

使用生成器處理非同步呼叫

假設有兩個簡單的非同步函式

let getDataOne=(cb)=>{
    setTimeout(function () {
        cb('response data one');
    }, 1000);
};
let getDateTwo=(cb)=>{
    setTimeout(function () {
        cb('response data two')
    }, 1000)
}複製程式碼

將上述程式碼改成使用Generator,我們使用next(value)的方法向生成器內部傳值,程式碼如下:

let generator;
let getDataOne=()=>{
    setTimeout(function () {
        generator.next('response data one');
    }, 1000);
};
let getDateTwo=()=>{
    setTimeout(function () {
        generator.next('response data two')
    }, 1000)
}複製程式碼

接下來我們來實現一個生成器函式main,呼叫上述方法,程式碼如下:

function *main() {
    let dataOne=yield getDataOne();
    let dataTwo=yield getDateTwo();
    console.log("data one",dataOne);
    console.log("data two",dataTwo);
}複製程式碼

怎麼執行程式碼呢,其實很簡單,如下所示:

generator=main();
generator.next();
//output
//data one response data one
//data two response data two複製程式碼

結果按照我們的預期進行輸出,而且main()函式的程式碼更加友好,和同步程式碼的感覺是一致的,接下來是這樣的:

  • 首先例項化生成器物件
  • 接下來我們呼叫next()方法,啟動生成器,生成器在第一行暫停,觸發呼叫getDataOne()函式。
  • getDataOne()函式在1秒鐘後,觸發呼叫generator.next(‘response data one’),向生成器main內部變數dataOne傳值,然後在yield getDateTwo()此處暫停,觸發呼叫getDateTwo()。
  • getDateTwo()函式在1秒鐘後,觸發呼叫generator.next(‘response data two’),向生成器main內部變數dataTwo傳值,然後執行下面console.log的內容,輸出dataOne,dataTwo變數的值。

你是不是發現一個非同步呼叫就和同步呼叫一樣,但它是以非同步的方式執行的。

一個真實的非同步例子

例如我們有一個需求,用NodeJs實現從論壇帖子列表資料中顯示其中的一個帖子的資訊及留言列表資訊,程式碼如下:

DB/posts.json(帖子列表資料)

[
    {
        "id": "001",
        "title": "Greeting",
        "text": "Hello World",
        "author": "Jane Doe"
    },
    {
        "id": "002",
        "title": "JavaScript 101",
        "text": "The fundamentals of programming.",
        "author": "Alberta Williams"
    },
    {
        "id": "003",
        "title": "Async Programming",
        "text": "Callbacks, Promises and Async/Await.",
        "author": "Alberta Williams"
    }
]複製程式碼

DB/comments.json(評論列表)

[
    {
        "id": "phx732",
        "postId": "003",
        "text": "I don't get this callback stuff."
    },
    {
        "id": "avj9438",
        "postId": "003",
        "text": "This is really useful info."
    },
    {
        "id": "gnk368",
        "postId": "001",
        "text": "This is a test comment."
    }
]複製程式碼

用回撥的方法實現程式碼如下 index.js

const fs = require('fs');
const path = require('path');
const postsUrl = path.join(__dirname, 'db/posts.json');
const commentsUrl = path.join(__dirname, 'db/comments.json');
//return the data from our file
function loadCollection(url, callback) {
    fs.readFile(url, 'utf8', function(error, data) {
        if (error) {
            console.log(error);
        } else {
            return callback(JSON.parse(data));
        }
    });
}
//return an object by id
function getRecord(collection, id, callback) {
    var collectobj=collection.find(function(element){
        return element.id == id;
    });
    callback(collectobj);
    return collectobj;
}
//return an array of comments for a post
function getCommentsByPost(comments, postId) {
    return comments.filter(function(comment){
        return comment.postId == postId;
    });
}
loadCollection(postsUrl, function(posts){
    loadCollection(commentsUrl, function(comments){
        getRecord(posts, "001", function(post){
            const postComments = getCommentsByPost(comments, post.id);
            console.log(post);
            console.log(postComments);
        });
    });
});複製程式碼

如果用生成器的方法如何實現呢?首先我們改寫loadCollection方法,程式碼如下:

let generator;
function loadCollection(url) {
    fs.readFile(url, 'utf8', function(error, data) {
        if (error) {
            generator.throw(error);
        } else {
            generator.next(JSON.parse(data));
        }
    });
}複製程式碼

接著我們完成main generator 函式的實現,程式碼如下:

function *main() {
    let posts=yield loadCollection(postsUrl);
    let comments=yield loadCollection(commentsUrl);
    getRecord(posts, "001", function(post){
                const postComments = getCommentsByPost(comments, post.id);
                console.log(post);
                console.log(postComments);
            });
}複製程式碼

最後我們進行呼叫

generator=main();
main().next();複製程式碼

將一個回撥機制轉換成一個生成器函式,看起來是不是很簡潔易懂呢,我們很輕鬆的建立了看似同步的非同步程式碼。

小節

關於生成器(Generator)的介紹就到這裡,它可以通過next方法暫停和恢復執行的函式。next方法還具備向生成器傳遞資料的功能,正是得益這個特點,才能幫助我們解決非同步程式碼的問題,讓我們建立了看似同步的非同步程式碼,對於我們來說這個神器是不是特別的強大。

注:本文參考《javascript ES6 函數語言程式設計入門經典》、《你不知道的javascript》、《JavaScript: The Definitive Guide, 7th Edition》

更多精彩內容,請微信關注”前端達人”公眾號!

【ES6基礎】生成器(Generator)


相關文章