第四章: Generator 2

特別說明,為便于查閱,文章轉(zhuǎn)自https://github.com/getify/You-Dont-Know-JS

Generator中的Promise并發(fā)

至此,所有我們展示過的是一種使用Promise+generator的單步異步流程。但是現(xiàn)實世界的代碼將總是有許多異步步驟。

如果你不小心,generator看似同步的風(fēng)格也許會蒙蔽你,使你在如何構(gòu)造你的異步并發(fā)上感到自滿,導(dǎo)致性能次優(yōu)的模式。那么我們想花一點時間來探索一下其他選項。

想象一個場景,你需要從兩個不同的數(shù)據(jù)源取得數(shù)據(jù),然后將這些應(yīng)答組合來發(fā)起第三個請求,最后打印出最終的應(yīng)答。我們在第三章中用Promise探索過類似的場景,但這次讓我們在generator的環(huán)境下考慮它。

你的第一直覺可能是像這樣的東西:

function *foo() {
    var r1 = yield request( "http://some.url.1" );
    var r2 = yield request( "http://some.url.2" );

    var r3 = yield request(
        "http://some.url.3/?v=" + r1 + "," + r2
    );

    console.log( r3 );
}

// 使用剛才定義的`run(..)`工具
run( foo );

這段代碼可以工作,但在我們特定的這個場景中,它不是最優(yōu)的。你能發(fā)現(xiàn)為什么嗎?

因為r1r2請求可以——而且為了性能的原因,應(yīng)該——并發(fā)運行,但在這段代碼中它們將順序地運行;直到"http://some.url.1"請求完成之前,"http://some.url.2"URL不會被Ajax取得。這兩個請求是獨立的,所以性能更好的方式可能是讓它們同時運行。

但是使用generator和yield,到底應(yīng)該怎么做?我們知道yield在代碼中只是一個單獨的暫停點,所以你根本不能再同一時刻做兩次暫停。

最自然和有效的答案是基于Promise的異步流程,特別是因為它們的時間無關(guān)的狀態(tài)管理能力(參見第三章的“未來的值”)。

最簡單的方式:

function *foo() {
    // 使兩個請求“并行”
    var p1 = request( "http://some.url.1" );
    var p2 = request( "http://some.url.2" );

    // 等待兩個promise都被解析
    var r1 = yield p1;
    var r2 = yield p2;

    var r3 = yield request(
        "http://some.url.3/?v=" + r1 + "," + r2
    );

    console.log( r3 );
}

// 使用剛才定義的`run(..)`工具
run( foo );

為什么這與前一個代碼段不同?看看yield在哪里和不在哪里。p1p2是并發(fā)地(也就是“并行”)發(fā)起的Ajax請求promise。它們哪一個先完成都不要緊,因為promise會一直保持它們的解析狀態(tài)。

然后我們使用兩個連續(xù)的yield語句等待并從promise中取得解析值(分別取到r1r2中)。如果p1首先解析,yield p1會首先繼續(xù)執(zhí)行然后等待yield p2繼續(xù)執(zhí)行。如果p2首先解析,它將會耐心地保持解析值知道被請求,但是yield p1將會首先停住,直到p1解析。

不管是哪一種情況,p1p2都將并發(fā)地運行,并且在r3 = yield request..Ajax請求發(fā)起之前,都必須完成,無論以哪種順序。

如果這種流程控制處理模型聽起來很熟悉,那是因為它基本上和我們在第三章中介紹的,因Promise.all([ .. ])工具成為可能的“門”模式是相同的。所以,我們也可以像這樣表達這種流程控制:

function *foo() {
    // 使兩個請求“并行”并等待兩個promise都被解析
    var results = yield Promise.all( [
        request( "http://some.url.1" ),
        request( "http://some.url.2" )
    ] );

    var r1 = results[0];
    var r2 = results[1];

    var r3 = yield request(
        "http://some.url.3/?v=" + r1 + "," + r2
    );

    console.log( r3 );
}

// 使用前面定義的`run(..)`工具
run( foo );

注意: 就像我們在第三章中討論的,我們甚至可以用ES6解構(gòu)賦值來把var r1 = .. var r2 = ..賦值簡寫為var [r1,r2] = results

換句話說,在generator+Promise的方式中,Promise所有的并發(fā)能力都是可用的。所以在任何地方,如果你需要比“這個然后那個”要復(fù)雜的順序異步流程步驟時,Promise都可能是最佳選擇。

Promises,隱藏起來

作為代碼風(fēng)格的警告要說一句,要小心你在 你的generator內(nèi)部 包含了多少Promise邏輯。以我們描述過的方式在異步性上使用generator的全部意義,是要創(chuàng)建簡單,順序,看似同步的代碼,并盡可能多地將異步性細節(jié)隱藏在這些代碼之外。

比如,這可能是一種更干凈的方式:

// 注意:這是一個普通函數(shù),不是generator
function bar(url1,url2) {
    return Promise.all( [
        request( url1 ),
        request( url2 )
    ] );
}

function *foo() {
    // 將基于Promise的并發(fā)細節(jié)隱藏在`bar(..)`內(nèi)部
    var results = yield bar(
        "http://some.url.1",
        "http://some.url.2"
    );

    var r1 = results[0];
    var r2 = results[1];

    var r3 = yield request(
        "http://some.url.3/?v=" + r1 + "," + r2
    );

    console.log( r3 );
}

// 使用剛才定義的`run(..)`工具
run( foo );

*foo()內(nèi)部,它更干凈更清晰地表達了我們要做的事情:我們要求bar(..)給我們一些results,而我們將用yield等待它的發(fā)生。我們不必關(guān)心在底層一個Promise.all([ .. ])的Promise組合將被用來完成任務(wù)。

我們將異步性,特別是Promise,作為一種實現(xiàn)細節(jié)。

如果你要做一種精巧的序列流控制,那么將你的Promise邏輯隱藏在一個僅僅從你的generator中調(diào)用的函數(shù)里特別有用。舉個例子:

function bar() {
    return Promise.all( [
                baz( .. )
                .then( .. ),
                Promise.race( [ .. ] )
            ] )
            .then( .. )
}

有時候這種邏輯是必須的,而如果你直接把它扔在你的generator內(nèi)部,你就違背了大多數(shù)你使用generator的初衷。我們 應(yīng)當(dāng) 有意地將這樣的細節(jié)從generator代碼中抽象出去,以使它們不會搞亂更高層的任務(wù)表達。

在創(chuàng)建功能強與性能好的代碼之上,你還應(yīng)當(dāng)努力使代碼盡可能地容易推理和維護。

注意: 對于編程來說,抽象不總是一種健康的東西——許多時候它可能在得到簡潔的同時增加復(fù)雜性。但是在這種情況下,我相信你的generator+Promise異步代碼要比其他的選擇健康得多。雖然有所有這些建議,你仍然要注意你的特殊情況,并為你和你的團隊做出合適的決策。

Generator 委托

在上一節(jié)中,我們展示了從generator內(nèi)部調(diào)用普通函數(shù),和它如何作為一種有用的技術(shù)來將實現(xiàn)細節(jié)(比如異步Promise流程)抽象出去。但是為這樣的任務(wù)使用普通函數(shù)的缺陷是,它必須按照普通函數(shù)的規(guī)則行動,也就是說它不能像generator那樣用yield來暫停自己。

在你身上可能發(fā)生這樣的事情:你可能會試著使用我們的run(..)幫助函數(shù),從一個generator中調(diào)用另個一generator。比如:

function *foo() {
    var r2 = yield request( "http://some.url.2" );
    var r3 = yield request( "http://some.url.3/?v=" + r2 );

    return r3;
}

function *bar() {
    var r1 = yield request( "http://some.url.1" );

    // 通過`run(..)`“委托”到`*foo()`
    var r3 = yield run( foo );

    console.log( r3 );
}

run( bar );

通過再一次使用我們的run(..)工具,我們在*bar()內(nèi)部運行*foo()。我們利用了這樣一個事實:我們早先定義的run(..)返回一個promise,這個promise在generator運行至完成時才解析(或發(fā)生錯誤),所以如果我們從一個run(..)調(diào)用中yield出一個promise給另一個run(..),它就會自動暫停*bar()直到*foo()完成。

但這里有一個更好的辦法將*foo()調(diào)用整合進*bar(),它稱為yield委托。yield委托的特殊語法是:yield * __(注意額外的*)。讓它在我們前面的例子中工作之前,讓我們看一個更簡單的場景:

function *foo() {
    console.log( "`*foo()` starting" );
    yield 3;
    yield 4;
    console.log( "`*foo()` finished" );
}

function *bar() {
    yield 1;
    yield 2;
    yield *foo();   // `yield`-delegation!
    yield 5;
}

var it = bar();

it.next().value;    // 1
it.next().value;    // 2
it.next().value;    // `*foo()` starting
                    // 3
it.next().value;    // 4
it.next().value;    // `*foo()` finished
                    // 5

注意: 在本章早前的一個注意點中,我解釋了為什么我偏好function *foo() ..而不是function* foo() ..,相似地,我也偏好——與關(guān)于這個話題的其他大多數(shù)文檔不同——說yield *foo()而不是yield* foo()*的擺放是純粹的風(fēng)格問題,而且要看你的最佳判斷。但我發(fā)現(xiàn)保持統(tǒng)一風(fēng)格很吸引人。

yield *foo()委托是如何工作的?

首先,正如我們看到過的那樣,調(diào)用foo()創(chuàng)建了一個 迭代器。然后,yield *將(當(dāng)前*bar()generator的) 迭代器 的控制委托/傳遞給這另一個*foo()迭代器

那么,前兩個it.next()調(diào)用控制著*bar(),但當(dāng)我們發(fā)起第三個it.next()調(diào)用時,*foo()就啟動了,而且這時我們控制的是*foo()而非*bar()。這就是為什么它稱為委托——*bar()將它的迭代控制委托給*foo()

只要it迭代器 的控制耗盡了整個*foo()迭代器,它就會自動地將控制返回到*bar()

那么現(xiàn)在回到前面的三個順序Ajax請求的例子:

function *foo() {
    var r2 = yield request( "http://some.url.2" );
    var r3 = yield request( "http://some.url.3/?v=" + r2 );

    return r3;
}

function *bar() {
    var r1 = yield request( "http://some.url.1" );

    // 通過`run(..)`“委托”到`*foo()`
    var r3 = yield *foo();

    console.log( r3 );
}

run( bar );

這個代碼段和前面使用的版本的唯一區(qū)別是,使用了yield *foo()而不是前面的yield run(foo)

注意: yield *讓出了迭代控制,不是generator控制;當(dāng)你調(diào)用*foo()generator時,你就yield委托給它的 迭代器。但你實際上可以yield委托給任何 迭代器yield *[1,2,3]將會消費默認(rèn)的[1,2,3]數(shù)組值 迭代器

為什么委托?

yield委托的目的很大程度上是為了代碼組織,而且這種方式是與普通函數(shù)調(diào)用對稱的。

想象兩個分別提供了foo()bar()方法的模塊,其中bar()調(diào)用foo()。它們倆分開的原因一般是由于為了程序?qū)⑺鼈冏鳛榉蛛x的程序來調(diào)用而進行的恰當(dāng)組織。例如,可能會有一些情況foo()需要被獨立調(diào)用,而其他地方bar()來調(diào)用foo()

由于這些完全相同的原因,將generator分開可以增強程序的可讀性,可維護性,與可調(diào)試性。從這個角度講,yield *是一種快捷的語法,用來在*bar()內(nèi)部手動地迭代*foo()的步驟。

如果*foo()中的步驟是異步的,這樣的手動方式可能會特別復(fù)雜,這就是為什么你可能會需要那個run(..)工具來做它。正如我們已經(jīng)展示的,yield *foo()消滅了使用run(..)工具的子實例(比如run(foo))的需要。

委托消息

你可能想知道,這種yield委托在除了與 迭代器 控制一起工作以外,是如何與雙向消息傳遞一起工作的。仔細查看下面這些通過yield委托進進出出的消息流:

function *foo() {
    console.log( "inside `*foo()`:", yield "B" );

    console.log( "inside `*foo()`:", yield "C" );

    return "D";
}

function *bar() {
    console.log( "inside `*bar()`:", yield "A" );

    // `yield`-委托!
    console.log( "inside `*bar()`:", yield *foo() );

    console.log( "inside `*bar()`:", yield "E" );

    return "F";
}

var it = bar();

console.log( "outside:", it.next().value );
// outside: A

console.log( "outside:", it.next( 1 ).value );
// inside `*bar()`: 1
// outside: B

console.log( "outside:", it.next( 2 ).value );
// inside `*foo()`: 2
// outside: C

console.log( "outside:", it.next( 3 ).value );
// inside `*foo()`: 3
// inside `*bar()`: D
// outside: E

console.log( "outside:", it.next( 4 ).value );
// inside `*bar()`: 4
// outside: F

特別注意一下it.next(3)調(diào)用之后的處理步驟:

  1. 3被傳入(通過*bar里的yield委托)在*foo()內(nèi)部等待中的yield "C"表達式。
  2. 然后*foo()調(diào)用return "D",但是這個值不會一路返回到外面的it.next(3)調(diào)用。
  3. 相反地,值"D"作為結(jié)果被發(fā)送到在*bar()內(nèi)部等待中的yield *foo()表示式——這個yield委托表達式實質(zhì)上在*foo()被耗盡之前一直被暫停著。所以"D"被送到*bar()內(nèi)部來讓它打印。
  4. yield "E"*bar()內(nèi)部被調(diào)用,而且值"E"被讓出到外部作為it.next(3)調(diào)用的結(jié)果。

從外部 迭代器it)的角度來看,在初始的generator和被委托的generator之間的控制沒有任何區(qū)別。

事實上,yield委托甚至不必指向另一個generator;它可以僅被指向一個非generator的,一般的 iterable。比如:

function *bar() {
    console.log( "inside `*bar()`:", yield "A" );

    // `yield`-委托至一個非generator
    console.log( "inside `*bar()`:", yield *[ "B", "C", "D" ] );

    console.log( "inside `*bar()`:", yield "E" );

    return "F";
}

var it = bar();

console.log( "outside:", it.next().value );
// outside: A

console.log( "outside:", it.next( 1 ).value );
// inside `*bar()`: 1
// outside: B

console.log( "outside:", it.next( 2 ).value );
// outside: C

console.log( "outside:", it.next( 3 ).value );
// outside: D

console.log( "outside:", it.next( 4 ).value );
// inside `*bar()`: undefined
// outside: E

console.log( "outside:", it.next( 5 ).value );
// inside `*bar()`: 5
// outside: F

注意這個例子與前一個之間,被接收/報告的消息的不同之處。

最驚人的是,默認(rèn)的array迭代器 不關(guān)心任何通過next(..)調(diào)用被發(fā)送的消息,所以值23,與4實質(zhì)上被忽略了。另外,因為這個 迭代器 沒有明確的return值(不像前面使用的*foo()),所以yield *表達式在它完成時得到一個undefined

異常也委托!

yield委托在兩個方向上透明地傳遞消息的方式相同,錯誤/異常也在雙向傳遞:

function *foo() {
    try {
        yield "B";
    }
    catch (err) {
        console.log( "error caught inside `*foo()`:", err );
    }

    yield "C";

    throw "D";
}

function *bar() {
    yield "A";

    try {
        yield *foo();
    }
    catch (err) {
        console.log( "error caught inside `*bar()`:", err );
    }

    yield "E";

    yield *baz();

    // note: can't get here!
    yield "G";
}

function *baz() {
    throw "F";
}

var it = bar();

console.log( "outside:", it.next().value );
// outside: A

console.log( "outside:", it.next( 1 ).value );
// outside: B

console.log( "outside:", it.throw( 2 ).value );
// error caught inside `*foo()`: 2
// outside: C

console.log( "outside:", it.next( 3 ).value );
// error caught inside `*bar()`: D
// outside: E

try {
    console.log( "outside:", it.next( 4 ).value );
}
catch (err) {
    console.log( "error caught outside:", err );
}
// error caught outside: F

在這段代碼中有一些事情要注意:

  1. 但我們調(diào)用it.throw(2)時,它發(fā)送一個錯誤消息2*bar(),而*bar()將它委托至*foo(),然后*foo()catch它并平靜地處理。之后,yield "C""C"作為返回的value發(fā)送回it.throw(2)調(diào)用。
  2. 接下來值"D"被從*foo()內(nèi)部throw出來并傳播到*bar()*bar()catch它并平靜地處理。然后yield "E""E"作為返回的value發(fā)送回it.next(3)調(diào)用。
  3. 接下來,一個異常從*baz()throw出來,而沒有被*bar()捕獲——我們沒在外面catch它——所以*baz()*bar()都被設(shè)置為完成狀態(tài)。這段代碼結(jié)束后,即便有后續(xù)的next(..)調(diào)用,你也不會得到值"G"——它們的value將返回undefined

異步委托

最后讓我們回到早先的多個順序Ajax請求的例子,使用yield委托:

function *foo() {
    var r2 = yield request( "http://some.url.2" );
    var r3 = yield request( "http://some.url.3/?v=" + r2 );

    return r3;
}

function *bar() {
    var r1 = yield request( "http://some.url.1" );

    var r3 = yield *foo();

    console.log( r3 );
}

run( bar );

*bar()內(nèi)部,與調(diào)用yield run(foo)不同的是,我們調(diào)用yield *foo()就可以了。

在前一個版本的這個例子中,Promise機制(通過run(..)控制的)被用于將值從*foo()中的return r3傳送到*bar()內(nèi)部的本地變量r3。現(xiàn)在,這個值通過yield *機制直接返回。

除此以外,它們的行為是一樣的。

“遞歸”委托

當(dāng)然,yield委托可以一直持續(xù)委托下去,你想連接多少步驟就連接多少。你甚至可以在具有異步能力的generator上“遞歸”使用yield委托——一個yield委托至自己的generator:

function *foo(val) {
    if (val > 1) {
        // 遞歸委托
        val = yield *foo( val - 1 );
    }

    return yield request( "http://some.url/?v=" + val );
}

function *bar() {
    var r1 = yield *foo( 3 );
    console.log( r1 );
}

run( bar );

注意: 我們的run(..)工具本可以用run( foo, 3 )來調(diào)用,因為它支持用額外傳遞的參數(shù)來進行g(shù)enerator的初始化。然而,為了在這里高調(diào)展示yield *的靈活性,我們使用了無參數(shù)的*bar()

這段代碼之后的處理步驟是什么?堅持住,它的細節(jié)要描述起來可是十分錯綜復(fù)雜:

  1. run(bar)啟動了*bar()generator。
  2. foo(3)*foo(..)創(chuàng)建了 迭代器 并傳遞3作為它的val參數(shù)。
  3. 因為3 > 1foo(2)創(chuàng)建了另一個 迭代器 并傳遞2作為它的val參數(shù)。
  4. 因為2 > 1foo(1)又創(chuàng)建了另一個 迭代器 并傳遞1作為它的val參數(shù)。
  5. 1 > 1false,所以我們接下來用值1調(diào)用request(..),并得到一個代表第一個Ajax調(diào)用的promise。
  6. 這個promise被yield出來,回到*foo(2)generator實例。
  7. yield *將這個promise傳出并回到*foo(3)生成generator。另一個yield *把這個promise傳出到*bar()generator實例。而又有另一個yield *把這個promise傳出到run(..)工具,而它將會等待這個promise(第一個Ajax請求)再處理。
  8. 當(dāng)這個promise解析時,它的完成消息會被發(fā)送以繼續(xù)*bar()*bar()通過yield *把消息傳遞進*foo(3)實例,*foo(3)實例通過yield *把消息傳遞進*foo(2)generator實例,*foo(2)實例通過yield *把消息傳給那個在*foo(3)generator實例中等待的一般的yield
  9. 這第一個Ajax調(diào)用的應(yīng)答現(xiàn)在立即從*foo(3)generator實例中被return,作為*foo(2)實例中yield *表達式的結(jié)果發(fā)送回來,并賦值給本地val變量。
  10. *foo(2)內(nèi)部,第二個Ajax請求用request(..)發(fā)起,它的promise被yield回到*foo(1)實例,然后一路yield *傳播到run(..)(回到第7步)。當(dāng)promise解析時,第二個Ajax應(yīng)答一路傳播回到*foo(2)generator實例,并賦值到他本地的val變量。
  11. 最終,第三個Ajax請求用request(..)發(fā)起,它的promise走出到run(..),然后它的解析值一路返回,最后被return到在*bar()中等待的yield *表達式。

天!許多瘋狂的頭腦雜技,對吧?你可能想要把它通讀幾遍,然后抓點兒零食放松一下大腦!

Generator并發(fā)

正如我們在第一章和本章早先討論過的,另個同時運行的“進程”可以協(xié)作地穿插它們的操作,而且許多時候這可以產(chǎn)生非常強大的異步表達式。

坦白地說,我們前面關(guān)于多個generator并發(fā)穿插的例子,展示了這真的容易讓人糊涂。但我們也受到了啟發(fā),有些地方這種能力十分有用。

回想我們在第一章中看過的場景,兩個不同但同時的Ajax應(yīng)答處理需要互相協(xié)調(diào),來確保數(shù)據(jù)通信不是竟合狀態(tài)。我們這樣把應(yīng)答分別放在res數(shù)組的不同位置中:

function response(data) {
    if (data.url == "http://some.url.1") {
        res[0] = data;
    }
    else if (data.url == "http://some.url.2") {
        res[1] = data;
    }
}

但是我們?nèi)绾卧谶@種場景下使用多generator呢?

// `request(..)` 是一個基于Promise的Ajax工具

var res = [];

function *reqData(url) {
    res.push(
        yield request( url )
    );
}

注意: 我們將在這里使用兩個*reqData(..)generator的實例,但是這和分別使用兩個不同generator的一個實例沒有區(qū)別;這兩種方式在道理上完全一樣的。我們過一會兒就會看到兩個generator的協(xié)調(diào)操作。

與不得不將res[0]res[1]賦值手動排序不同,我們將使用協(xié)調(diào)過的順序,讓res.push(..)以可預(yù)見的順序恰當(dāng)?shù)貙⒅捣旁陬A(yù)期的位置。如此被表達的邏輯會讓人感覺更干凈。

但是我們將如何實際安排這種互動呢?首先,讓我們手動實現(xiàn)它:

var it1 = reqData( "http://some.url.1" );
var it2 = reqData( "http://some.url.2" );

var p1 = it1.next().value;
var p2 = it2.next().value;

p1
.then( function(data){
    it1.next( data );
    return p2;
} )
.then( function(data){
    it2.next( data );
} );

*reqData(..)的兩個實例都開始發(fā)起它們的Ajax請求,然后用yield暫停。之后我們再p1解析時繼續(xù)運行第一個實例,而后來的p2的解析將會重啟第二個實例。以這種方式,我們使用Promise的安排來確保res[0]將持有第一個應(yīng)答,而res[1]持有第二個應(yīng)答。

但坦白地說,這是可怕的手動,而且它沒有真正讓generator組織它們自己,而那才是真正的力量。讓我們用不同的方法試一下:

// `request(..)` 是一個基于Promise的Ajax工具

var res = [];

function *reqData(url) {
    var data = yield request( url );

    // 傳遞控制權(quán)
    yield;

    res.push( data );
}

var it1 = reqData( "http://some.url.1" );
var it2 = reqData( "http://some.url.2" );

var p1 = it1.next().value;
var p2 = it2.next().value;

p1.then( function(data){
    it1.next( data );
} );

p2.then( function(data){
    it2.next( data );
} );

Promise.all( [p1,p2] )
.then( function(){
    it1.next();
    it2.next();
} );

好的,這看起來好些了(雖然仍然是手動),因為現(xiàn)在兩個*reqData(..)的實例真正地并發(fā)運行了,而且(至少是在第一部分)是獨立的。

在前一個代碼段中,第二個實例在第一個實例完全完成之前沒有給出它的數(shù)據(jù)。但是這里,只要它們的應(yīng)答一返回這兩個實例就立即分別收到他們的數(shù)據(jù),然后每個實例調(diào)用另一個yield來傳送控制。最后我們在Promise.all([ .. ])的處理器中選擇用什么樣的順序繼續(xù)它們。

可能不太明顯的是,這種方式因其對稱性啟發(fā)了一種可復(fù)用工具的簡單形式。讓我們想象使用一個稱為runAll(..)的工具:

// `request(..)` 是一個基于Promise的Ajax工具

var res = [];

runAll(
    function*(){
        var p1 = request( "http://some.url.1" );

        // 傳遞控制權(quán)
        yield;

        res.push( yield p1 );
    },
    function*(){
        var p2 = request( "http://some.url.2" );

        // 傳遞控制權(quán)
        yield;

        res.push( yield p2 );
    }
);

注意: 我們沒有包含runAll(..)的實現(xiàn)代碼,不僅因為它長得無法行文,也因為它是一個我們已經(jīng)在先前的 run(..)中實現(xiàn)的邏輯的擴展。所以,作為留給讀者的一個很好的補充性練習(xí),請你自己動手改進run(..)的代碼,來使它像想象中的runAll(..)那樣工作。另外,我的 asynquence 庫提供了一個前面提到過的runner(..)工具,它內(nèi)建了這種能力,我們將在本書的附錄A中討論它。

這是runAll(..)內(nèi)部的處理將如何操作:

  1. 第一個generator得到一個代表從"http://some.url.1"來的Ajax應(yīng)答,然后將控制權(quán)yield回到runAll(..)工具。
  2. 第二個generator運行,并對"http://some.url.2"做相同的事,將控制權(quán)yield回到runAll(..)工具。
  3. 第一個generator繼續(xù),然后yield出他的promisep1。在這種情況下runAll(..)工具和我們前面的run(..)做同樣的事,它等待promise解析,然后繼續(xù)這同一個generator(沒有控制傳遞!)。當(dāng)p1解析時,runAll(..)使用解析值再一次繼續(xù)第一個generator,而后res[0]得到它的值。在第一個generator完成之后,有一個隱式的控制權(quán)傳遞。
  4. 第二個generator繼續(xù),yield出它的promisep2,并等待它的解析。一旦p2解析,runAll(..)使用這個解析值繼續(xù)第二個generator,于是res[1]被設(shè)置。

在這個例子中,我們使用了一個稱為res的外部變量來保存兩個不同的Ajax應(yīng)答的結(jié)果——這是我們的并發(fā)協(xié)調(diào)。

但是這樣做可能十分有幫助:進一步擴展runAll(..)使它為多個generator實例提供 分享的 內(nèi)部的變量作用域,比如一個我們將在下面稱為data的空對象。另外,它可以接收被yield的非Promise值,并把它們交給下一個generator。

考慮這段代碼:

// `request(..)` 是一個基于Promise的Ajax工具

runAll(
    function*(data){
        data.res = [];

        // 傳遞控制權(quán)(并傳遞消息)
        var url1 = yield "http://some.url.2";

        var p1 = request( url1 ); // "http://some.url.1"

        // 傳遞控制權(quán)
        yield;

        data.res.push( yield p1 );
    },
    function*(data){
        // 傳遞控制權(quán)(并傳遞消息)
        var url2 = yield "http://some.url.1";

        var p2 = request( url2 ); // "http://some.url.2"

        // 傳遞控制權(quán)
        yield;

        data.res.push( yield p2 );
    }
);

在這個公式中,兩個generator不僅協(xié)調(diào)控制傳遞,實際上還互相通信:通過data.res,和交換url1url2的值的yield消息。這強大到不可思議!

這樣的認(rèn)識也是一種更為精巧的稱為CSP(Communicating Sequential Processes——通信順序處理)的異步技術(shù)的概念基礎(chǔ),我們將在本書的附錄B中討論它。

Thunks

至此,我們都假定從一個generator中yield一個Promise——讓這個Promise使用像run(..)這樣的幫助工具來推進generator——是管理使用generator的異步處理的最佳方法。明白地說,它是的。

但是我們跳過了一個被輕度廣泛使用的模式,為了完整性我們將簡單地看一看它。

在一般的計算機科學(xué)中,有一種老舊的前JS時代的概念,稱為“thunk”。我們不在這里贅述它的歷史,一個狹隘的表達是,thunk是一個JS函數(shù)——沒有任何參數(shù)——它連接并調(diào)用另一個函數(shù)。

換句話講,你用一個函數(shù)定義包裝函數(shù)調(diào)用——帶著它需要的所有參數(shù)——來 推遲 這個調(diào)用的執(zhí)行,而這個包裝用的函數(shù)就是thunk。當(dāng)你稍后執(zhí)行thunk時,你最終會調(diào)用那個原始的函數(shù)。

舉個例子:

function foo(x,y) {
    return x + y;
}

function fooThunk() {
    return foo( 3, 4 );
}

// 稍后

console.log( fooThunk() );  // 7

所以,一個同步的thunk是十分直白的。但是一個異步的thunk呢?我們實質(zhì)上可以擴展這個狹隘的thunk定義,讓它接收一個回調(diào)。

考慮這段代碼:

function foo(x,y,cb) {
    setTimeout( function(){
        cb( x + y );
    }, 1000 );
}

function fooThunk(cb) {
    foo( 3, 4, cb );
}

// 稍后

fooThunk( function(sum){
    console.log( sum );     // 7
} );

如你所見,fooThunk(..)僅需要一個cb(..)參數(shù),因為它已經(jīng)預(yù)先制定了值34(分別為xy)并準(zhǔn)備傳遞給foo(..)。一個thunk只是在外面耐心地等待著它開始工作所需的最后一部分信息:回調(diào)。

但是你不會想要手動制造thunk。那么,讓我們發(fā)明一個工具來為我們進行這種包裝。

考慮這段代碼:

function thunkify(fn) {
    var args = [].slice.call( arguments, 1 );
    return function(cb) {
        args.push( cb );
        return fn.apply( null, args );
    };
}

var fooThunk = thunkify( foo, 3, 4 );

// 稍后

fooThunk( function(sum) {
    console.log( sum );     // 7
} );

提示: 這里我們假定原始的(foo(..))函數(shù)簽名希望它的回調(diào)的位置在最后,而其它的參數(shù)在這之前。這是一個異步JS函數(shù)的相當(dāng)普遍的“標(biāo)準(zhǔn)”。你可以稱它為“回調(diào)后置風(fēng)格”。如果因為某些原因你需要處理“回調(diào)優(yōu)先風(fēng)格”的簽名,你只需要制造一個使用args.unshift(..)而非args.push(..)的工具。

前面的thunkify(..)公式接收foo(..)函數(shù)的引用,和任何它所需的參數(shù),并返回thunk本身(fooThunk(..))。然而,這并不是你將在JS中發(fā)現(xiàn)的thunk的典型表達方式。

thunkify(..)制造thunk本身相反,典型的——可能有點兒讓人困惑的——thunkify(..)工具將產(chǎn)生一個制造thunk的函數(shù)。

額...是的。

考慮這段代碼:

function thunkify(fn) {
    return function() {
        var args = [].slice.call( arguments );
        return function(cb) {
            args.push( cb );
            return fn.apply( null, args );
        };
    };
}

這里主要的不同之處是有一個額外的return function() { .. }。這是它在用法上的不同:

var whatIsThis = thunkify( foo );

var fooThunk = whatIsThis( 3, 4 );

// 稍后

fooThunk( function(sum) {
    console.log( sum );     // 7
} );

明顯地,這段代碼隱含的最大的問題是,whatIsThis叫什么合適?它不是thunk,它是一個從foo(..)調(diào)用生產(chǎn)thunk的東西。它是一種“thunk”的“工廠”。而且看起來沒有任何標(biāo)準(zhǔn)的意見來命名這種東西。

所以,我的提議是“thunkory”("thunk" + "factory")。于是,thunkify(..)制造了一個thunkory,而一個thunkory制造thunks。這個道理與第三章中我的“promisory”提議是對稱的:

var fooThunkory = thunkify( foo );

var fooThunk1 = fooThunkory( 3, 4 );
var fooThunk2 = fooThunkory( 5, 6 );

// 稍后

fooThunk1( function(sum) {
    console.log( sum );     // 7
} );

fooThunk2( function(sum) {
    console.log( sum );     // 11
} );

注意: 這個例子中的foo(..)期望的回調(diào)不是“錯誤優(yōu)先風(fēng)格”。當(dāng)然,“錯誤優(yōu)先風(fēng)格”更常見。如果foo(..)有某種合理的錯誤發(fā)生機制,我們可以改變而使它期望并使用一個錯誤優(yōu)先的回調(diào)。后續(xù)的thunkify(..)不會關(guān)心回調(diào)被預(yù)想成什么樣。用法的唯一區(qū)別是fooThunk1(function(err,sum){..

暴露出thunkory方法——而不是像早先的thunkify(..)那樣將中間步驟隱藏起來——可能看起來像是沒必要的混亂。但是一般來講,在你的程序一開始就制造一些thunkory來包裝既存API的方法是十分有用的,然后你就可以在你需要thunk的時候傳遞并調(diào)用這些thunkory。這兩個區(qū)別開的步驟保證了功能上更干凈的分離。

來展示一下的話:

// 更干凈:
var fooThunkory = thunkify( foo );

var fooThunk1 = fooThunkory( 3, 4 );
var fooThunk2 = fooThunkory( 5, 6 );

// 而這個不干凈:
var fooThunk1 = thunkify( foo, 3, 4 );
var fooThunk2 = thunkify( foo, 5, 6 );

不管你是否愿意明確對付thunkory,thunk(fooThunk1(..)fooThunk2(..))的用法還是一樣的。

s/promise/thunk/

那么所有這些thunk的東西與generator有什么關(guān)系?

一般性地比較一下thunk和promise:它們是不能直接互換的,因為它們在行為上不是等價的。比起單純的thunk,Promise可用性更廣泛,而且更可靠。

但從另一種意義上講,它們都可以被看作是對一個值的請求,這個請求可能被異步地應(yīng)答。

回憶第三章,我們定義了一個工具來promise化一個函數(shù),我們稱之為Promise.wrap(..)——我們本來也可以叫它promisify(..)的!這個Promise化包裝工具不會生產(chǎn)Promise;它生產(chǎn)那些繼而生產(chǎn)Promise的promisories。這和我們當(dāng)前討論的thunkory和thunk是完全對稱的。

為了描繪這種對稱性,讓我們首先將foo(..)的例子改為假定一個“錯誤優(yōu)先風(fēng)格”回調(diào)的形式:

function foo(x,y,cb) {
    setTimeout( function(){
        // 假定 `cb(..)` 是“錯誤優(yōu)先風(fēng)格”
        cb( null, x + y );
    }, 1000 );
}

現(xiàn)在,我們將比較thunkify(..)promisify(..)(也就是第三章的Promise.wrap(..)):

// 對稱的:構(gòu)建問題的回答者
var fooThunkory = thunkify( foo );
var fooPromisory = promisify( foo );

// 對稱的:提出問題
var fooThunk = fooThunkory( 3, 4 );
var fooPromise = fooPromisory( 3, 4 );

// 取得 thunk 的回答
fooThunk( function(err,sum){
    if (err) {
        console.error( err );
    }
    else {
        console.log( sum );     // 7
    }
} );

// 取得 promise 的回答
fooPromise
.then(
    function(sum){
        console.log( sum );     // 7
    },
    function(err){
        console.error( err );
    }
);

thunkory和promisory實質(zhì)上都是在問一個問題(一個值),thunk的fooThunk和promise的fooPromise分別代表這個問題的未來的答案。這樣看來,對稱性就清楚了。

帶著這個視角,我們可以看到為了異步而yieldPromise的generator,也可以為異步而yieldthunk。我們需要的只是一個更聰明的run(..)工具(就像以前一樣),它不僅可以尋找并連接一個被yield的Promise,而且可以給一個被yield的thunk提供回調(diào)。

考慮這段代碼:

function *foo() {
    var val = yield request( "http://some.url.1" );
    console.log( val );
}

run( foo );

在這個例子中,request(..)既可以是一個返回一個promise的promisory,也可以是一個返回一個thunk的thunkory。從generator的內(nèi)部代碼邏輯的角度看,我們不關(guān)心這個實現(xiàn)細節(jié),這就它強大的地方!

所以,request(..)可以使以下任何一種形式:

// promisory `request(..)` (見第三章)
var request = Promise.wrap( ajax );

// vs.

// thunkory `request(..)`
var request = thunkify( ajax );

最后,作為一個讓我們早先的run(..)工具支持thunk的補丁,我們可能會需要這樣的邏輯:

// ..
// 我們收到了一個回調(diào)嗎?
else if (typeof next.value == "function") {
    return new Promise( function(resolve,reject){
        // 使用一個錯誤優(yōu)先回調(diào)調(diào)用thunk
        next.value( function(err,msg) {
            if (err) {
                reject( err );
            }
            else {
                resolve( msg );
            }
        } );
    } )
    .then(
        handleNext,
        function handleErr(err) {
            return Promise.resolve(
                it.throw( err )
            )
            .then( handleResult );
        }
    );
}

現(xiàn)在,我們generator既可以調(diào)用promisory來yieldPromise,也可以調(diào)用thunkory來yieldthunk,而不論那種情況,run(..)都將處理這個值并等待它的完成,以繼續(xù)generator。

在對稱性上,這兩個方式是看起來相同的。然而,我們應(yīng)當(dāng)指出這僅僅從Promise或thunk表示延續(xù)generator的未來值的角度講是成立的。

從更高的角度講,與Promise被設(shè)計成的那樣不同,thunk沒有提供,它們本身也幾乎沒有任何可靠性和可組合性的保證。在這種特定的generator異步模式下使用一個thunk作為Promise的替代品是可以工作的,但與Promise提供的所有好處相比,這應(yīng)當(dāng)被看做是一種次理想的方法。

如果你有選擇,那就偏向yield pr而非yield th。但是使run(..)工具可以處理兩種類型的值本身沒有什么問題。

注意: 在我們將要在附錄A中討論的,我的 asynquence 庫中的runner(..)工具,可以處理yield的Promise,thunk和 asynquence 序列。

前ES6時代的Generator

我希望你已經(jīng)被說服了,generator是一個異步編程工具箱里的非常重要的增強工具。但它是ES6中的新語法,這意味著你不能像填補Promise(它只是新的API)那樣填補generator。那么如果我們不能奢望忽略前ES6時代的瀏覽器,我們該如何將generator帶到瀏覽器中呢?

對所有ES6中的新語法的擴展,有一些工具——稱呼他們最常見的名詞是轉(zhuǎn)譯器(transpilers),也就是轉(zhuǎn)換編譯器(trans-compilers)——它們會拿起你的ES6語法,并轉(zhuǎn)換為前ES6時代的等價代碼(但是明顯地變難看了!)。所以,generator可以被轉(zhuǎn)譯為具有相同行為但可以在ES5或以下版本進行工作的代碼。

但是怎么做到的?yield的“魔法”聽起來不像是那么容易轉(zhuǎn)譯的。在我們早先的基于閉包的 迭代器 例子中,實際上提示了一種解決方法。

手動變形

在我們討論轉(zhuǎn)譯器之前,讓我們延伸一下,在generator的情況下如何手動轉(zhuǎn)譯。這不僅是一個學(xué)院派的練習(xí),因為這樣做實際上可以幫助我們進一步理解它們?nèi)绾喂ぷ鳌?/p>

考慮這段代碼:

// `request(..)` 是一個支持Promise的Ajax工具

function *foo(url) {
    try {
        console.log( "requesting:", url );
        var val = yield request( url );
        console.log( val );
    }
    catch (err) {
        console.log( "Oops:", err );
        return false;
    }
}

var it = foo( "http://some.url.1" );

第一個要注意的事情是,我們?nèi)匀恍枰粋€可以被調(diào)用的普通的foo()函數(shù),而且它仍然需要返回一個 迭代器。那么讓我們來畫出非generator的變形草圖:

function foo(url) {

    // ..

    // 制造并返回 iterator
    return {
        next: function(v) {
            // ..
        },
        throw: function(e) {
            // ..
        }
    };
}

var it = foo( "http://some.url.1" );

下一個需要注意的地方是,generator通過掛起它的作用域/狀態(tài)來施展它的“魔法”,但我們可以用函數(shù)閉包來模擬。為了理解如何寫出這樣的代碼,我們將先用狀態(tài)值注釋generator不同的部分:

// `request(..)` 是一個支持Promise的Ajax工具

function *foo(url) {
    // 狀態(tài) *1*

    try {
        console.log( "requesting:", url );
        var TMP1 = request( url );

        // 狀態(tài) *2*
        var val = yield TMP1;
        console.log( val );
    }
    catch (err) {
        // 狀態(tài) *3*
        console.log( "Oops:", err );
        return false;
    }
}

注意: 為了更準(zhǔn)去地講解,我們使用TMP1變量將val = yield request..語句分割為兩部分。request(..)發(fā)生在狀態(tài)*1*,而將完成值賦給val發(fā)生在狀態(tài)*2*。在我們將代碼轉(zhuǎn)換為非generator的等價物后,我們就可以擺脫中間的TMP1

換句話所,*1*是初始狀態(tài),*2*request(..)成功的狀態(tài),*3*request(..)失敗的狀態(tài)。你可能會想象額外的yield步驟將如何編碼為額外的狀態(tài)。

回到我們被轉(zhuǎn)譯的generator,讓我們在這個閉包中定義一個變量state,用它來追蹤狀態(tài):

function foo(url) {
    // 管理 generator 狀態(tài)
    var state;

    // ..
}

現(xiàn)在,讓我們在閉包內(nèi)部定義一個稱為process(..)的內(nèi)部函數(shù),它用switch語句來處理各種狀態(tài)。

// `request(..)` 是一個支持Promise的Ajax工具

function foo(url) {
    // 管理 generator 狀態(tài)
    var state;

    // generator-范圍的變量聲明
    var val;

    function process(v) {
        switch (state) {
            case 1:
                console.log( "requesting:", url );
                return request( url );
            case 2:
                val = v;
                console.log( val );
                return;
            case 3:
                var err = v;
                console.log( "Oops:", err );
                return false;
        }
    }

    // ..
}

在我們的generator中每種狀態(tài)都在switch語句中有它自己的case。每當(dāng)我們需要處理一個新狀態(tài)時,process(..)就會被調(diào)用。我們一會就回來討論它如何工作。

對任何generator范圍的變量聲明(val),我們將它們移動到process(..)外面的var聲明中,這樣它們就可以在process(..)的多次調(diào)用中存活下來。但是“塊兒作用域”的err變量僅在*3*狀態(tài)下需要,所以我們將它留在原處。

在狀態(tài)*1*,與yield request(..)相反,我們return request(..)。在終結(jié)狀態(tài)*2*,沒有明確的return,所以我們僅僅return;也就是return undefined。在終結(jié)狀態(tài)*3*,有一個return false,我們保留它。

現(xiàn)在我們需要定義 迭代器 函數(shù)的代碼,以便人們恰當(dāng)?shù)卣{(diào)用process(..)

function foo(url) {
    // 管理 generator 狀態(tài)
    var state;

    // generator-范圍的變量聲明
    var val;

    function process(v) {
        switch (state) {
            case 1:
                console.log( "requesting:", url );
                return request( url );
            case 2:
                val = v;
                console.log( val );
                return;
            case 3:
                var err = v;
                console.log( "Oops:", err );
                return false;
        }
    }

    // 制造并返回 iterator
    return {
        next: function(v) {
            // 初始狀態(tài)
            if (!state) {
                state = 1;
                return {
                    done: false,
                    value: process()
                };
            }
            // 成功地讓出繼續(xù)值
            else if (state == 1) {
                state = 2;
                return {
                    done: true,
                    value: process( v )
                };
            }
            // generator 已經(jīng)完成了
            else {
                return {
                    done: true,
                    value: undefined
                };
            }
        },
        "throw": function(e) {
            // 在狀態(tài) *1* 中,有唯一明確的錯誤處理
            if (state == 1) {
                state = 3;
                return {
                    done: true,
                    value: process( e )
                };
            }
            // 否則,是一個不會被處理的錯誤,所以我們僅僅把它扔回去
            else {
                throw e;
            }
        }
    };
}

這段代碼如何工作?

  1. 第一個對 迭代器next()調(diào)用將把gtenerator從未初始化的狀態(tài)移動到狀態(tài)1,然后調(diào)用process()來處理這個狀態(tài)。request(..)的返回值是一個代表Ajax應(yīng)答的promise,它作為value屬性從next()調(diào)用被返回。
  2. 如果Ajax請求成功,第二個next(..)調(diào)用應(yīng)當(dāng)送進Ajax的應(yīng)答值,它將我們的狀態(tài)移動到2process(..)再次被調(diào)用(這次它被傳入Ajax應(yīng)答的值),而從next(..)返回的value屬性將是undefined
  3. 然而,如果Ajax請求失敗,應(yīng)當(dāng)用錯誤調(diào)用throw(..),它將狀態(tài)從1移動到3(而不是2)。process(..)再一次被調(diào)用,這詞被傳入了錯誤的值。這個case返回false,所以false作為throw(..)調(diào)用返回的value屬性。

從外面看——也就是僅僅與 迭代器 互動——這個普通的foo(..)函數(shù)與*foo(..)generator的工作方式是一樣的。所以我們有效地將ES6 generator“轉(zhuǎn)譯”為前ES6可兼容的!

然后我們就可以手動初始化我們的generator并控制它的迭代器——調(diào)用var it = foo("..")it.next(..)等等——或更好地,我們可以將它傳遞給我們先前定義的run(..)工具,比如run(foo,"..")

自動轉(zhuǎn)譯

前面的練習(xí)——手動編寫從ES6 generator到前ES6的等價物的變形過程——教會了我們generator在概念上是如何工作的。但是這種變形真的是錯綜復(fù)雜,而且不能很好地移植到我們代碼中的其他generator上。手動做這些工作是不切實際的,而且將會把generator的好處完全抵消掉。

但走運的是,已經(jīng)存在幾種工具可以自動地將ES6 generator轉(zhuǎn)換為我們在前一節(jié)延伸出的東西。它們不僅幫我們做力氣活兒,還可以處理幾種我們敷衍而過的情況。

一個這樣的工具是regenerator(https://facebook.github.io/regenerator/),由Facebook的聰明伙計們開發(fā)的。

如果我們用regenerator來轉(zhuǎn)譯我們前面的generator,這就是產(chǎn)生的代碼(在編寫本文時):

// `request(..)` 是一個支持Promise的Ajax工具

var foo = regeneratorRuntime.mark(function foo(url) {
    var val;

    return regeneratorRuntime.wrap(function foo$(context$1$0) {
        while (1) switch (context$1$0.prev = context$1$0.next) {
        case 0:
            context$1$0.prev = 0;
            console.log( "requesting:", url );
            context$1$0.next = 4;
            return request( url );
        case 4:
            val = context$1$0.sent;
            console.log( val );
            context$1$0.next = 12;
            break;
        case 8:
            context$1$0.prev = 8;
            context$1$0.t0 = context$1$0.catch(0);
            console.log("Oops:", context$1$0.t0);
            return context$1$0.abrupt("return", false);
        case 12:
        case "end":
            return context$1$0.stop();
        }
    }, foo, this, [[0, 8]]);
});

這和我們的手動推導(dǎo)有明顯的相似性,比如switch/case語句,而且我們甚至可以看到,val被拉到了閉包外面,正如我們做的那樣。

當(dāng)然,一個代價是這個generator的轉(zhuǎn)譯需要一個幫助工具庫regeneratorRuntime,它持有全部管理一個普通generator/迭代器 所需的可復(fù)用邏輯。它的許多模板代碼看起來和我們的版本不同,但即便如此,概念還是可以看到的,比如使用context$1$0.next = 4追蹤generator的下一個狀態(tài)。

主要的結(jié)論是,generator不僅限于ES6+的環(huán)境中才有用。一旦你理解了它的概念,你可以在你的所有代碼中利用他們,并使用工具將代碼變形為舊環(huán)境兼容的。

這比使用PromiseAPI的填補來實現(xiàn)前ES6的Promise要做更多的工作,但是努力完全是值得的,因為對于以一種可推理的,合理的,看似同步的順序風(fēng)格來表達異步流程控制來說,generator實在是好太多了。

一旦你適應(yīng)了generator,你將永遠不會回到面條般的回調(diào)地獄了!

復(fù)習(xí)

generator是一種ES6的新函數(shù)類型,它不像普通函數(shù)那樣運行至完成。相反,generator可以暫停在一種中間完成狀態(tài)(完整地保留它的狀態(tài)),而且它可以從暫停的地方重新開始。

這種暫停/繼續(xù)的互換是一種協(xié)作而非搶占,這意味著generator擁有的唯一能力是使用yield關(guān)鍵字暫停它自己,而且控制這個generator的 迭代器 擁有的唯一能力是繼續(xù)這個generator(通過next(..))。

yield/next(..)的對偶不僅是一種控制機制,它實際上是一種雙向消息傳遞機制。一個yield ..表達式實質(zhì)上為了等待一個值而暫停,而下一個next(..)調(diào)用將把值(或隱含的undefined)傳遞回這個暫停的yield表達式。

與異步流程控制關(guān)聯(lián)的generator的主要好處是,在一個generator內(nèi)部的代碼以一種自然的同步/順序風(fēng)格表達一個任務(wù)的各個步驟的序列。這其中的技巧是我們實質(zhì)上將潛在的異步處理隱藏在yield關(guān)鍵字的后面——將異步處理移動到控制generator的 迭代器 代碼中。

換句話說,generator為異步代碼保留了順序的,同步的,阻塞的代碼模式,這允許我們的大腦更自然地推理代碼,解決了基于回調(diào)的異步產(chǎn)生的兩個關(guān)鍵問題中的一個。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容