Generator函式語法解析

凱斯發表於2018-01-28

轉載請註明出處:

Generator函式語法解析-掘金專欄

Generator函式語法解析-知乎專欄

Generator函式語法解析-部落格園

Generator函式是ES6提供的一種非同步程式設計解決方案,語法與傳統函式完全不同。以下會介紹一下Generator函式。

寫下這篇文章的目的其實很簡單,是想梳理一下自己對於Generator的理解,同時呢,為學習async函式做一下知識儲備。


Generator函式

  1. 基本概念
  2. yield表示式
  3. next方法
  4. next方法的引數
  5. yield*表示式
  6. 與Iterator介面的關係
  7. for...of迴圈
  8. 作為物件屬性的Generator函式
  9. Generator函式中的this
  10. 應用

基本概念

對於Generator函式(也可以叫做生成器函式)的理解,可以從四個方面:

形式上: Generator函式是一個普通的函式,不過相對於普通函式多出了兩個特徵。一是在function關鍵字和函式明之間多了'*'號;二是函式內部使用了yield表示式,用於定義Generator函式中的每個狀態。

語法上: Generator函式封裝了多個內部狀態(通過yield表示式定義內部狀態)。執行Generator函式時會返回一個遍歷器物件(Iterator物件)。也就是說,Generator是遍歷器物件生成函式,函式內部封裝了多個狀態。通過返回的Iterator物件,可以依次遍歷(呼叫next方法)Generator函式的每個內部狀態。

呼叫上: 普通函式在呼叫之後會立即執行,而Generator函式呼叫之後不會立即執行,而是會返回遍歷器物件(Iterator物件)。通過Iterator物件的next方法來遍歷內部yield表示式定義的每一個狀態。

寫法上: *號放在哪裡好像都可以也。看個人習慣吧,我喜歡第一種寫法

function *gen () {}   √
function* gen () {}
function * gen () {}
function*gen () {}
複製程式碼

yield表示式

yield,英文意思即產生、退讓的意思,因此yield表示式也有兩種作用:定義內部狀態暫停執行

舉一個栗子吧: )

function *gen () {
  yield 1
  yield 2
  return 3
}

const g = gen()   // Iterator物件
g.next() // {value: 1, done: false}
g.next() // {value: 2, done: false}
g.next() // {value: 3, done: true}
複製程式碼

從上面程式碼中可以看出,gen函式使用yield表示式定義了兩個內部狀態。同時呢,也可以看出來,return語句只能有一個,而yield表示式卻可以有多個。

執行gen函式之後,會返回一個遍歷器物件,而不是立即執行gen函式。如果需要獲取yield表示式定義的每個狀態,需要呼叫next方法。

每呼叫一次next方法都會返回一個包含value和done屬性的物件,此時會停留在某個yield表示式結尾處。value屬性值即是yield表示式的值;done屬性是布林值,表示是否遍歷完畢。

另外呢,yield表示式沒有返回值,或者說返回值是undefined。待會會說明一下如何給yield表示式傳遞返回值。

需要注意的是,yield表示式的值,只有呼叫next方法時才能獲取到。因此等於為JavaScript提供了手動的'惰性求值'(Lazy Evaluation)的功能。

一般情況下,Generator函式會結合yield表示式使用,通過yield表示式定義多個內部狀態。但是,如果不使用yield表示式的Generator函式就成為了一個單純的暫緩執行函式,個人感覺沒什麼意義...

function *gen () {
  console.log('凱斯')
}

window.setTimeout(() => {
  gen().next()
}, 2000)

// 不使用yield表示式來暫停函式的執行,還不如使用普通函式呢..
// 所以Generator函式配合yield表示式使用效果更佳
複製程式碼

另外,yield表示式如果用在另一個表示式中,需要為其加上圓括號。作為函式引數和語句是可以不使用圓括號。

function *gen () {
  console.log('hello' + yield) ×
  console.log('hello' + (yield)) √
  console.log('hello' + yield '凱斯') ×
  console.log('hello' + (yield '凱斯')) √
  foo(yield 1)  √
  const param = yield 2  √
}
複製程式碼

next方法

yield表示式具有暫停執行的功能,而恢復執行的是next方法。每一次呼叫next方法,就會從函式頭部或者上一次停下來的地方開始執行,直到遇到下一個yield表示式(return 語句)為止。同時,呼叫next方法時,會返回包含value和done屬性的物件,value屬性值可以為yield表示式、return語句後面的值或者undefined值,done屬性表示遍歷是否結束。

遍歷器物件的next方法(從Generator函式繼承而來)的執行邏輯如下

  1. 遇到yield表示式,就暫停執行後面的操作,並將緊跟在yield表示式後面的那個表示式的值,作為返回的物件的value屬性值。
  2. 下一次呼叫next方法時,再繼續往下執行,直到遇到下一個yield表示式。
  3. 如果沒有再遇到新的yield表示式,就一直執行到函式結束,直到遇到return語句為止,並將return語句後面表示式的值,作為返回的物件的value屬性值。
  4. 如果該函式沒有return語句,則返回的物件的value屬性值為undefined。

從上面的執行邏輯可以看出,返回的物件的value屬性值有三種結果:

  1. yield表示式後面的值
  2. return語句後面的值
  3. undefined

也就是說,如果有yield表示式,則value屬性值就是yield表示式後面的指;如果沒有yield表示式,value屬性值就等於return語句後面的值;如果yield表示式和return語句都不存在的話,則value屬性值就等於undefined。舉個例子: )

function *gen () {
  yield 1
  yield 2
  return 3
}

const g = gen()
g.next() // {value: 1, done: false}
g.next() // {value: 2, done: false}
g.next() // {value: 3, done: true}
g.next() // {value: undefined, done: true}
複製程式碼

根據next執行邏輯再針對這個例子,就很容易理解了。呼叫gen函式,返回遍歷器物件。

第一次呼叫next方法時,在遇到第一個yield表示式時停止執行,value屬性值為1,即yield表示式後面的值,done為false表示遍歷沒有結束;

第二次呼叫next方法時,從暫停的yield表示式後開始執行,直到遇到下一個yield表示式後暫停執行,value屬性值為2,done為false;

第三次呼叫next方法時,從上一次暫停的yield表示式後開始執行,由於後面沒有yield表示式了,所以遇到return語句時函式執行結束,value屬性值為return語句後面的值,done屬性值為true表示已經遍歷完畢了。

第四次呼叫next方法時,value屬性值就是undefined了,此時done屬性為true表示遍歷完畢。以後再呼叫next方法都會是這兩個值。

next方法的引數

yield表示式本身沒有返回值,或者說總是返回undefined。

function *gen () {
  var x = yield 'hello world'
  var y = x / 2
  return [x, y]
}
const g = gen()
g.next()    // {value: 'hello world', done: false}
g.next()    // {value: [undefined, NaN], done: true}
複製程式碼

從上面程式碼可以看出,第一次呼叫next方法時,value屬性值是'hello world',第二次呼叫時,由於變數y的值依賴於變數x,而yield表示式沒有返回值,所以返回了undefined給變數x,此時undefined / 2為NaN。

要解決上面的問題,可以給next方法傳遞引數。next方法可以帶一個引數,該引數就會被當作上一個yield表示式的返回值。

function *gen () {
  var x = yield 'hello world'
  var y = x / 2
  return [x, y]
}
const g = gen()
g.next()    // {value: 'hello world', done: false}
g.next(10)    // {value: [10, 5], done: true}
複製程式碼

當給第二個next方法傳遞引數10時,yield表示式的返回值為10,即var x = 10,所以此時變數y為5。

注意,由於next方法的參數列示上一個yield表示式的返回值,所以在第一次使用next方法時,傳遞引數是無效的。V8引擎直接忽略第一次使用next方法的引數,只有從第二次使用next方法開始,引數才是有效的。從語義上說,第一個next方法用來啟動遍歷器物件,所以不用帶上引數。所以呢,每次使用next方法會比yield表示式要多一次。

如果想要第一次呼叫next方法時就可以傳遞引數,可以使用閉包的方式。

// 實際上就是在閉包內部執行了一次next方法
function wrapper (gen) {
  return function (...args) {
    const genObj = gen(...args)
    genObj.next()
    return genObj
  }
}
const generator = wrapper(function *generator () {
  console.log(`hello ${yield}`)
  return 'done'
})
const a = generator().next('keith')
console.log(a)   // hello keith, done
複製程式碼

實際上,**yield表示式和next方法構成了雙向資訊傳遞。**yield表示式可以向外傳遞value值,而next方法引數可以向內傳遞值。

yield*表示式

如果在Generator函式中呼叫另一個Generator函式,預設情況下是無效的。

function *foo () {
  yield 1
}
function *gen () {
  foo()
  yield 2
}
const g = gen()
g.next()  // {value: 2, done: false}
g.next()  // {value: undefined, done: true}
複製程式碼

從上面程式碼中可以看出,並沒有在yield 1處停止執行。此時就需要使用yield* 表示式。從語法角度上說,如果yield表示式後面跟著遍歷器物件,需要在yield表示式後面加上星號,表明它返回的是一個遍歷器物件。實際上,yield*表示式是for...of迴圈的簡寫,完全可以使用for...of迴圈來代替yield*表示式

function *foo () {
  yield 1
}
function *gen () {
  yield* foo()
  yield 2
}
const g = gen()
g.next()   // {value: 1, done: false}
g.next()   // {value: 2, done: false}
g.next()   // {value: undefined, done: true}

// 相當於
function *gen () {
  yield 1
  yield 2
}

// 相當於
function *gen () {
  for (let item of foo()) {
    yield item
  }
  yield 2
}
複製程式碼

如果直接使用了yield foo(),返回的物件的value屬性值為一個遍歷器物件。而不是Generator函式的內部狀態。

function *foo () {
  yield 1
}
function *gen () {
  yield foo()
  yield 2
}
const g = gen()
g.next()   // {value: Generator, done: false}
g.next()   // {value: 2, done: false}
g.next()   // {value: undefined, done: true}
複製程式碼

另外,任何資料型別(Array, String)只要有Iterator介面,就能夠被yield*遍歷

const arr = ['a', 'b']
const str = 'keith'
function *gen () {
  yield arr
  yield* arr
  yield str
  yield* str
}
const g = gen()
g.next() // {value: ['a', 'b'], done: false}
g.next() // {value: 'a', done: false}
g.next() // {value: 'b', done: false}
g.next() // {value: 'keith', done: false}
g.next() // {value: 'k', done: false}
...
複製程式碼

如果在Generator函式中存在return語句,則需要使用let value = yield* iterator方式獲取返回值。

function *foo () {
  yield 1
  return 2
}
function *gen () {
  var x = yield* foo()
  return x
}
const g = gen()
g.next()  // {value: 1, done: false}
g.next()  // {value: 2, done: true}
複製程式碼

使用yield*表示式可以很方便的取出巢狀陣列的成員。

// 普通方法
const arr = [1, [[2, 3], 4]]
const str = arr.toString().replace(/,/g, '')
for (let item of str) {
  console.log(+item)      // 1, 2, 3, 4
}

// 使用yield*表示式
function *gen (arr) {
  if (Array.isArray(arr)) {
    for (let i = 0; i < arr.length; i++) {
      yield * gen(arr[i])
    }
  } else {
    yield arr
  }
}
const g = gen([1, [[2, 3], 4]])
for (let item of g) {
  console.log(item)       // 1, 2, 3, 4
}
複製程式碼

與Iterator介面的關係

任何一個物件的Symbol.iterator屬性,指向預設的遍歷器物件生成函式。而Generator函式也是遍歷器物件生成函式,所以可以將Generator函式賦值給Symbol.iterator屬性,這樣就使物件具有了Iterator介面。預設情況下,物件是沒有Iterator介面的。 具有Iterator介面的物件,就可以被擴充套件運算子(...)解構賦值Array.fromfor...of迴圈遍歷了。

const person = {
  name: 'keith',
  height: 180
}
function *gen () {
  const arr = Object.keys(this)
  for (let item of arr) {
    yield [item, this[item]]
  }
}
person[Symbol.iterator] = gen
for (let [key, value] of person) {
  console.log(key, value)   // name keith , height 180
}
複製程式碼

Generator函式函式執行之後,會返回遍歷器物件。該物件本身也就有Symbol.iterator屬性,執行後返回自身

function *gen () {}
const g = gen()
g[Symbol.iterator]() === g    // true
複製程式碼

for...of迴圈

for...of迴圈可以自動遍歷Generator函式生成的Iterator物件,不用呼叫next方法。

function *gen () {
  yield 1
  yield 2
  yield 3
  return 4
}
for (let item of gen()) {
  console.log(item)  // 1 2 3
}
複製程式碼

上面程式碼使用for...of迴圈,依次顯示 3 個yield表示式的值。這裡需要注意,一旦next方法的返回物件的done屬性為true,for...of迴圈就會中止,且不包含該返回物件,所以上面程式碼的return語句返回的6,不包括在for...of迴圈之中。

作為物件屬性的Generator函式

如果一個物件有Generator函式,那麼可以使用簡寫方式

let obj = {
  * gen () {}
}
// 也可以完整的寫法
let obj = {
  gen: function *gen () {}
}
複製程式碼

當然了,如果是在建構函式中,簡寫形式也是一樣的。

class F {
  * gen () {}
}
複製程式碼

Generator函式中的this

Generator函式中的this物件跟建構函式中的this物件有異曲同工之處。先來看看建構函式中的new關鍵字的工作原理。

function F () {
  this.a = 1
}
const f = new F()
複製程式碼
  1. 呼叫建構函式F,返回例項物件f
  2. 將建構函式內部中的this指向這個例項物件
  3. 將建構函式中的原型物件賦值給例項物件的原型
  4. 執行建構函式中的程式碼

呼叫Generator函式會返回遍歷器物件,而不是例項物件,因此無法獲取到this指向的例項物件上的私有屬性和方法。但是這個遍歷器物件可以繼承Generator函式的prototype原型物件上的屬性和方法(公有屬性和方法)。

function *Gen () {
  yield this.a = 1
}
Gen.prototype.say = function () {
  console.log('keith')
}
const g = new Gen()
g.a      // undefined
g.say()  // 'keith'
複製程式碼

如果希望修復this指向性問題,可以使用call方法將函式執行時所在的作用域繫結到Generator.prototype原型物件上。這樣做,會使私有屬性和方法變成公有的了,因為都在原型物件上了。

function *Gen () {
  this.a = 1
  yield this.b = 2
  yield this.c = 3
}
const g = Gen.call(Gen.prototype)
g.next()   // {value: 2, done: false}
g.next()   // {value: 3, done: false}
g.next()   // {value: undefined, done: true}
g.a        // 1,繼承自Gen.prototype
g.b        // 2,同上
g.c        // 3,同上
複製程式碼

應用

Generator函式的應用主要在非同步程式設計上,會在下一篇文章中分享。請期待噢: )

相關文章