Generator 基礎指南

斯年發表於2019-03-24

一個栗子

Generator函式是ES6提供的一種非同步程式設計解決方案,它語法行為與傳統函式不同,我們先來看一個使用Generator書寫的Fibonacci函式的示例:

function* fibonacci(n) {
  let current = 0;
  let next = 1;
  while (n-- > 0) {
    yield current;
    [current, next] = [next, next + current];
  }
}
複製程式碼

費波那契數列由0和1開始,之後的費波那契係數就是由之前的兩數相加而得出。而我們上面的生成器引數n即代表需要計算到第幾個序列:

const test = fibonacci(3);

console.log(test.next()); // {value: 0, done: false}
console.log(test.next()); // {value: 1, done: false}
console.log(test.next()); // {value: 1, done: false}
console.log(test.next()); // {value: undefined, done: true}

for (item of fibonacci(3)) {
  console.log(item) // 0, 1, 1
}
複製程式碼

通過以上實踐,可以簡單總結出生成器的幾個特點:

  • 生成器函式在呼叫時不會立即執行,而是返回一個遍歷器物件。
  • 生成器中每個yield代表一個新的狀態,通過遍歷器物件的next()方法執行並返回結果,返回物件結構為:{value: any, done: boolean}
  • 遍歷器Iterator可由for ... of執行。

Generator生成器

Generator生成器與普通函式相比具有2個特徵:

  1. function與函式名之間有一個*
  2. 函式內部使用yield表示式。

當在呼叫生成器時,它不允許使用new關鍵字,也不會立即執行,而是返回一個Iterator物件,允許通過遍歷器、for...of、解構語法等表示式執行。

每一個 yield 表示式,會使 Generator 生成一種新的狀態,並轉交控制權給外部函式,此時我們需要呼叫遍歷器物件的next方法,才能使 Generator 繼續執行。需要注意的是,Generator函式內部如果不存在yield表示式,它也不會立即執行,而是需要手動使用next方法觸發:

function* test() { console.log('hello') }
const fn = test(); // 沒有任何輸出
fn.next(); // hello
複製程式碼

Generator函式結束的標誌為 return,return 返回的值也會作為next方法返回物件的value,而此時 done 屬性為:true(如果函式體內無 return 關鍵字,則會執行到函式結束,預設返回值為undefined)。

yield表示式

yield表示式用於定義Generator不同的內部狀態,它同時作為函式暫停的標誌,將執行權交給外部的其他函式,並將 yield 關鍵字緊鄰的表示式作為接下來遍歷器的next()方法返回的物件的value鍵值。外部函式在呼叫了next()方法以後,Generator才得以恢復執行:

function* test() {
  yield 'hello'
  yield 'world'
  return '!'
}

const executer = test();
executer.next(); // {value: "hello", done: false}
executer.next(); // {value: "world", done: false}
executer.next(); // {value: "!", done: true}


for (item of test()) { console.log(item) } // hello world

複製程式碼

return與yield都能使函式停止執行,並將後面的表示式的值作為返回物件value傳遞出去,區別在於return是函式結束的標識,不具備多次執行的能力,返回的值也不能作為迭代物件使用(迭代器如果判斷 done 標識為true,則會忽略該值)。

那我們可以在生成器中呼叫生成器嗎?最開始嘗試時可能會這樣寫:

function* hello() {
    yield 'hello'
    yield world()
}

function* world() {
    yield 'world'
}

for (item of hello()) { console.log(item) }
// hello
// world {<suspended>}
複製程式碼

第二個迭代器的值返回的是一個新的Generator,它按照原樣返回了,並沒有按照我們預想中執行。為了在一個Generator函式裡執行另一個Generator,此時就需要使用yield*表示式:

function* hello() {
    yield 'hello'
    yield* world()
}

function* world() {
    yield 'world'
}

for (item of hello()) { console.log(item) }
// hello
// world
複製程式碼

yield*語句後面能接生成器物件或是實現了Iterator介面的值(字串物件、陣列物件等),它的作用就像是將生成器物件進行了for...of遍歷,將每一個遍歷到的物件傳遞到當前的生成器中執行yield,舉一個示例:

function* example() {
  yield 'hello';
  yield* ['world'];
  yield* test();
  yield* '??';
}

function* test() {
  yield '!';
}

// example函式等同於
function* example() {
  yield 'hello';
  for (item of ['world']) {
    yield item;
  }
  yield '!'
  for (item of '??') {
    yield item;
  }
}
複製程式碼

Iterator遍歷器

通過以上對Generator函式的介紹,我們對Iterator有了一個初步的瞭解,Generator函式在執行後,會生成一個遍歷器物件,再由for...of語法或是解構函式對Iterator進行消費。其實Iterator不僅僅應用於Generator,它其實還是一種通用的介面規範,為不同的資料結構提供統一的訪問機制。

ES6規定,Iterator介面部署在物件的Symbol.iterator上,凡是實現了這一屬性的物件都認為是可遍歷的(iterable),原生具備Iterator介面的資料結構有:

  • Array、String
  • Set、Map
  • TypedArray、NodeList

因此對一個字串來講,我們可以手動獲取到它的遍歷器物件,並進行迴圈列印每一個字元:

const strs = 'hello world'
for (str of strs[Symbol.iterator]()) { console.log(str) }
複製程式碼

除以上原生實現了Iterable資料結構以外,我們還可以自己定義任意物件的Symbol.iterator屬性方法,從而實現Iterable特性,該屬性方法具備以下特徵:

  • 函式不需要任何引數,要求返回一個Iterator Object
  • Iterator Object中,存在一個next()方法,該函式總是返回一個{value: any, done: boolean}物件,done預設值為falsevalue預設值為undefined
  • value可以是任意值
  • donetrue,表示迭代器已經執行到序列的末尾;donefalse表示迭代器還可以繼續執行next()方法並返回下一個序列物件

實現Iterator介面有很多種方法,不論你的資料結構為類還是物件,我們只要保證[Symbol.iterator]屬性方法及其返回的資料規範即可,以下為自定義迭代器的示例:

// 使用生成器的方式,推薦使用
const obj  = {
  [Symbol.iterator] = function* () {
    yield 'hello';
    yield 'world';
  }
}

// 使用純函式的方式定義返回物件及next方法
const obj = {
  [Symbol.iterator]: () => {
    const items = ['hello', 'world'];
    let nextIndex = 0;
    return {
      next() {
        return nextIndex < items.length
          ? { value: items[nextIndex++] }
          : { done: true };
      }
    };
  }
};
for (item of obj) { console.log(item) }
複製程式碼

next()、throw()與return()方法

這三種方法都屬於Generator的原型方法,通過其物件進行呼叫,目的是讓Generator恢復執行,並使用不同語句替換當前yield標誌所在的表示式:

  • next(value),將表示式替換為value,next函式主要用於向生成器內部傳遞值,從而改變生成器的狀態。
  • throw(error),將表示式替換為throw(error),throw方法會將Error物件交由生成器內部處理,若生成器無法處理則會又將錯誤丟擲來,此時會中斷生成器的執行。
  • return(value),將表示式替換為return value,return方法用於中斷生成器的執行。

若要一一舉例,篇幅可能會非常長,因此在這裡舉一個包含這三種語句的示例:

function* test() {
  const flag = yield 'does anybody here';
  if (flag) {
    try {
      yield 'could you sing for me?';
    } catch (e) {
      if (e.message === 'sorry!') {
        yield 'I can teach you';
      } else {
        throw e
      }
    }
    yield 'maybe next time';
  }
}

const executer = test();
executer.next(); // {value: 'does anybody here?'}
executer.next(true); // { value: 'could you sing for me ?' }
executer.throw(new Error('sorry!')); // {value: 'i will teach you'}
executer.return('thank you'); // {value: "thank you", done: true}
複製程式碼

函式有幾點需要解釋:

  • 當生成器小張詢問是否有人在這裡時,我們需要通過next(true)進行迴應,以此來改變它的狀態。
  • 小張喜歡聽音樂,因此他會詢問你能否為他唱首歌,假設你不會唱,你可以通過throw丟擲一個錯誤,說實在是抱歉,這時小張會熱情的教你唱歌;如果你的回答是“No Way!”,那麼小張就會當場崩潰。
  • 你同意向小張學習並唱歌給他聽,可以通過return方法向他表示感謝,此時對話就可以終止了。

參考資料

相關文章