解析JavaScript中的Generators

2021-09-21 19:00:14

最近,為了更好地理解Redux Sagas的工作原理,我重學了JavaScript generators的知識,我把從網上收集到的各種知識點濃縮到一篇文章裡,我希望這篇文章既通俗易懂,又足夠嚴謹,可以作為初學者的generators使用指南。

簡介

JavaScript在ES6時引入了生成器。生成器函數與常規函數類似,除了可以暫停和恢復它們這一點以外。生成器也與迭代器密切相關,因為生成器物件就是迭代器。
在JavaScript中,函數呼叫後通常不能暫停或停止。(是的,非同步函數在等待await語句時暫停,但是非同步函數在ES7時才引入。此外,非同步函數是建立在生成器之上的。)一個普通函數只有在返回或丟擲錯誤時才會結束。

function foo() {
  console.log('Starting');
  const x = 42;
  console.log(x);  
  console.log('Stop me if you can');  
  console.log('But you cannot');
 }

相反,生成器允許我們在任意斷點處暫停執行,並從同一斷點恢復執行。

生成器和迭代器

來自MDN:

在JavaScript中,迭代器是一個物件,它定義一個序列,並在終止時可能返回一個返回值。 >更具體地說,迭代器是通過使用 next() 方法實現 Iterator protocol >的任何一個物件,該方法返回具有兩個屬性的物件: value,這是序列中的 next 值;和 done, 如果已經迭代到序列中的最後一個值,則它為 true 。如果 value 和 done 一起存在,則它是迭代器的返回值。

因此,迭代器的本質就是:

  • 定義序列的物件
  • 有一個next()方法…
  • 返回一個具有兩個屬性的物件:value和done

是否需要生成器來建立迭代器?不。事實上,我們已經可以使用閉包pre-ES6建立一個無限的斐波那契數列,如下例所示:

var fibonacci = {
  next: (function () {
    var pre = 0, cur = 1;
    return function () {
      tmp = pre;
      pre = cur;
      cur += tmp;
      return cur;
    };
  })()
};

fibonacci.next(); // 1
fibonacci.next(); // 2
fibonacci.next(); // 3
fibonacci.next(); // 5
fibonacci.next(); // 8

關於生成器的好處,我將再次參照MDN:

雖然自定義迭代器是一個有用的工具,但是由於需要顯式地維護它們的內部狀態,建立它們需要我們仔細地程式設計。生成器函數提供了一個強大的替代方法:它們允許我們通過編寫一個執行不是連續的函數來定義迭代演演算法。
換句話說,使用生成器建立迭代器更簡單(不需要閉包!),這意味著出錯的可能性更小。
生成器和迭代器之間的關係就是生成器函數返回的生成器物件是迭代器。

語法

生成器函數使用function *語法建立,並使用yield關鍵字暫停。
最初呼叫生成器函數並不執行它的任何程式碼;相反,它返回一個生成器物件。該值通過呼叫生成器的next()方法來使用,該方法執行程式碼,直到遇到yield關鍵字,然後暫停,直到再次呼叫next()。

function * makeGen() {
  yield 'Hello';
  yield 'World';
}

const g = makeGen(); // g is a generator
g.next(); // { value: 'Hello', done: false }
g.next(); // { value: 'World', done: false }
g.next(); // { value: undefined, done: true }

在上面的最後一個語句之後重複呼叫g.next()只會返回(或者更準確地說,產生)相同的返回物件:{ value: undefined, done: true }。

yield暫停執行

大家可能會注意到上面的程式碼片段有一些特殊之處。第二個next()呼叫生成一個物件,該物件的屬性為done: false,而不是done: true。
既然我們正在生成器函數中執行最後一條語句,那麼done屬性不應該為true嗎?並不是的。當遇到yield語句時,它後面的值(在本例中是「World」)被生成,執行暫停。因此,第二個next()呼叫暫停在第二個yield語句上,因此執行還沒有完成—只有在第二個yield語句之後執行重新開始時,執行才算完成(即done: true),並且不再執行程式碼。
我們可以將next()呼叫看作是告訴程式執行到下一個yield語句(假設它存在)、生成一個值並暫停。程式在恢復執行之前不會知道yield語句之後沒有任何內容,並且只能通過另一個next()呼叫恢復執行。

yield和return

在上面的範例中,我們使用yield將值傳遞給生成器外部。我們也可以使用return(就像在普通函數中一樣);但是,使用return可以終止執行並設定done: true。

function * makeGen() {
  yield 'Hello';
  return 'Bye';
  yield 'World';
}

const g = makeGen(); // g is a generator
g.next(); // { value: 'Hello', done: false }
g.next(); // { value: 'Bye', done: true }
g.next(); // { value: undefined, done: true }

因為執行不會在return語句上暫停,而且根據定義,在return語句之後不能執行任何程式碼,所以done被設定為true。

yield:next方法的引數

到目前為止,我們一直在使用yield傳遞生成器外部的值(並暫停其執行)。
然而,yield實際上是雙向的,並且允許我們將值傳遞到生成器函數中。

function * makeGen() {
  const foo = yield 'Hello world';
  console.log(foo);
}

const g = makeGen();
g.next(1); // { value: 'Hello world', done: false }
g.next(2); // logs 2, yields { value: undefined, done: true }

等一下。不應該是"1"列印到控制檯,但是控制檯列印的是"2"?起初,我發現這部分在概念上與直覺相反,因為我預期的賦值foo = 1。畢竟,我們將「1」傳遞到next()方法呼叫中,從而生成Hello world,對嗎?
但事實並非如此。傳遞給第一個next(...)呼叫的值將被丟棄。除了這似乎是ES6規範之外,實際上沒有其他原因.從語意上講,第一個next方法用來啟動遍歷器物件,所以不用帶有引數。
我喜歡這樣對程式的執行進行合理化:

  • 在第一個next()呼叫時,它將一直執行,直到遇到yield 'Hello world',在此基礎上生成{ value: 'Hello world', done: false }和暫停。就是這麼回事。正如大家所看到的,傳遞給第一個next()呼叫的任何值都是不會被使用的(因此被丟棄)。
  • 當再次呼叫next(...)時,執行將恢復。在這種情況下,執行需要為常數foo分配一些值(由yield語句決定)。因此,我們對next(2)的第二次呼叫賦值foo=2。程式不會在這裡停止—它會一直執行,直到遇到下一個yield或return語句。在本例中,沒有更多的yield,因此它記錄2並返回undefined的done: true。在生成器使用非同步因為yield是一個雙向通道,允許資訊在兩個方向上流動,所以它允許我們以非常酷的方式使用生成器。到目前為止,我們主要使用yield在生成器之外傳遞值。但是我們也可以利用yield的雙向特性以同步方式編寫非同步函數。

使用上面的概念,我們可以建立一個類似於同步程式碼但實際上執行非同步函數的基本函數:

function request(url) {
  fetch(url).then(res => {
    it.next(res); // Resume iterator execution
  });
}

function * main() {
  const rawResponse = yield request('https://some-url.com');
  const returnValue = synchronouslyProcess(rawResponse);
  console.log(returnValue);
}

const it = main();
it.next(); // Remember, the first next() call doesn't accept input

這是它的工作原理。首先,我們宣告一個request函數和main生成器函數。接下來,通過呼叫main()建立一個迭代器it。然後,我們從呼叫it.next()開始。
在第一行的function * main(),在yield request('https://some-url.com')之後執行暫停。request()隱式地返回undefined,因此我們實際上生成了undefined值,但這並不重要—我們沒有使用該值。
當request()函數中的fetch()呼叫完成時,it.next(res)將會被呼叫並完成下列兩件事:
it繼續執行;和
it將res傳遞給生成器函數,該函數被分配給rawResponse
最後,main()的其餘部分將同步完成。
這是一個非常基礎的設定,應該與promise有一些相似之處。有關yield和非同步性的更詳細介紹,請參閱此文。

生成器是一次性

我們不能重複使用生成器,但可以從生成器函數建立新的生成器。

function * makeGen() {
  yield 42;
}

const g1 = makeGen();
const g2 = makeGen();
g1.next(); // { value: 42, done: false }
g1.next(); // { value: undefined, done: true }
g1.next(); // No way to reset this!
g2.next(); // { value: 42, done: false }
...
const g3 = makeGen(); // Create a new generator
g3.next(); // { value: 42, done: false }

無限序列

迭代器表示序列,有點像陣列。所以,我們應該能夠將所有迭代器表示為陣列,對吧?
然而,並不是的。陣列在建立時需要立即分配,而迭代器是延遲使用的。陣列是迫切需要的,因為建立一個包含n個元素的陣列需要首先建立/計算所有n個元素,以便將它們儲存在陣列中。相反,迭代器是惰性的,因為序列中的下一個值只有在使用時才會建立/計算。
因此,表示無限序列的陣列在物理上是不可能的(我們需要無限記憶體來儲存無限項!),而迭代器可以輕鬆地表示(而不是儲存)該序列。
讓我們建立一個從1到正無窮數的無窮序列。與陣列不同,這並不需要無限記憶體,因為序列中的每個值只有在使用時才會懶散地計算出來。

function * makeInfiniteSequence() {
  var curr = 1;
  while (true) {
    yield curr;
    curr += 1;
  }
}

const is = makeInfiniteSequence();
is.next(); { value: 1, done: false }
is.next(); { value: 2, done: false }
is.next(); { value: 3, done: false }
... // It will never end

有趣的事實:這類似於Python生成器表示式vs列表理解。雖然這兩個表示式在功能上是相同的,但是生成器表示式提供了記憶體優勢,因為值的計算是延遲的,而列表理解則是立即計算值並建立整個列表。

推薦學習:《》

以上就是解析JavaScript中的Generators的詳細內容,更多請關注TW511.COM其它相關文章!