iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
JavaScript

30 天新世代 JavaScript 自我學習指南系列 第 4

Day 04|Generator 的雙重身分:它同時是 Iterator 也是 Iterable

  • 分享至 

  • xImage
  •  

摘要

  • 一句摘要:Generator 把手寫 iterator 的狀態管理改成順序程式碼;呼叫後得到的 generator 同時可 .next(),也可交給 for...of
  • 前置知識:知道 iterator 的 .next() 會逐筆回傳 { value, done }
  • 學習路線:今天學會generator function;
  • 標籤ES2015 Generator yield Iterable Iterator

今日學習目標

  1. 分清 generator function 與呼叫後得到的 generator object。
  2. 說明 yield 暫停、.next() 恢復流程的關係。
  3. 理解 Generator 如何簡化手寫 iterator 的狀態管理,且不把它直接等同於 lazy。
  4. 理解 Generator Object 如何透過 .next()、.return() 與 .throw() 接收外部的流程控制。

https://ithelp.ithome.com.tw/upload/images/20260908/20145251DukqR2kKNH.png


先從TC39規格定位 Generator

function* 是 ES2015 加入的 generator function 語法。它本身是可呼叫的建立者,不是 iterator;呼叫它後得到的 generator object 才有 .next()

ECMAScript 規格對 Generator Objects 的定義

A Generator is created by calling a generator function and conforms to both the iterator interface and the iterable interface.

  • 呼叫 generator function 會建立 generator,而這個 generator 同時符合 iterator 與 iterable 介面。
  • 它停在 yield 時會保留這次函式呼叫的執行狀態,會有一個暫停狀態(suspend state);
  • 資料的消費者(consumer) 以 .next() 推進時,才從暫停處恢復。

Generator 如何簡化手寫 iterator?

前幾次文章有看到 iteratorfor of 透過它不斷呼叫 .next() 取得下一筆資料。
若要自己製作一個可以產生 iterator ,並且符合迭代協議的函式,必須要定義入口 [Symbol.iterator] 和 返回物件的形狀至少有 next()

function createCounter() {
  let current = 1;

  return {
    next() {
      if (current > 3) return { value: undefined, done: true };
      return { value: current++, done: false };
    },

    [Symbol.iterator]() {
      return this;
    },
  };
}

ES6 Generator function 改寫

function* countToThree() {
  yield 1;
  yield 2;
  yield 3;
}

const counter = countToThree();

console.log(counter.next()); // { value: 1, done: false }
console.log(counter.next()); // { value: 2, done: false }
console.log(counter.next()); // { value: 3, done: false }
console.log(counter.next()); // { value: undefined, done: true }

function* 是 generator function。呼叫它時不會立刻跑完整個函式,而是拿到一個 generator object。每次 .next(),它才從上一次暫停的位置繼續,直到下一個 yield

因此 yield 2 不是「把整個函式回傳出去」,而是交出這一次 iterator result 的 value,並暫停目前的執行位置。下一次 .next() 會從這裡往下走。current、區域變數和迴圈進度由 generator 自己保存;比手寫狀態機容易閱讀~。


yield 像可暫停的導覽員,也能接收下一次 .next()

可以把 generator 想成一位帶團導覽員。它走到 yield 就停下來,把眼前這一站交給外部,並記住自己走到哪裡、手上拿了什麼、接下來該往哪裡走。外部下一次呼叫 .next(),才說「繼續」,導覽員便從原地接著走,而不是從第一站重來。

JavaScript 把這個被保留下來的「工作現場」稱為 execution context

其中包含目前位置、區域變數、參數與迴圈進度。讀文章時先記住「暫停後還能原地續走」即可;不必把它想成多個函式或多份狀態。

控制權可以想成這樣交接:

caller 呼叫 .next()
        ↓
導覽員從原地恢復
        ↓
流程跑到 yield
        ↓
暫停並交回 { value, done }

yield 不只把值交給外部,也能接收下一次 .next(value) 傳回的值:

第二次呼叫的 "讀者",會成為前一個 yield "你的名字是?" expression 的值,因此被指定給 name

function* conversation() {
  const name = yield "你的名字是?";
  yield `你好,${name}`;

  return "對話結束";
}

const chat = conversation();

console.log(chat.next());          // { value: "你的名字是?", done: false }
console.log(chat.next("讀者")); // { value: "你好,讀者", done: false }
console.log(chat.next());          // { value: "對話結束", done: true }

yield 不會建立新的工作現場;它暫停的是這一個 generator object 正在進行的函式呼叫。下一次 .next(value) 會恢復同一個工作現場,並把 value 交給剛才那個 yield expression。這對應TC39 規格中的 GeneratorResumeGeneratorYield

發現這個暫停現象會保存上一次執行的狀態有點微妙,讓我想起 閉包(Closure)

ClosureGenerator 都可能讓區域變數在稍後仍可使用,但它們保存狀態的原因不同:

  • Closure 保留對 lexical environment 的參照,外層函式可以早已執行完畢。
  • Generator Object 保留尚未完成呼叫的暫停點、區域 bindings 與控制流程。

兩者不是互斥關係,generator function 內也可以建立 Closure ,像下面範例:

function* demo() {
  let count = 1;

  const readCount = () => count; // Closure:捕捉 count
  yield readCount;
}
  • demo() 停在 yield 時,count 因 Generator Object 保存執行狀態而繼續存在。
  • readCount 是 Closure因為它保留了對 count 所在 lexical environment 的參照。
  • 外部取得 readCount 後,即使 demo() 後來結束,仍可透過 readCount() 讀到該 binding。

Generator 的完整控制介面

一般 iterator 的必要介面只有 .next()[Symbol.iterator]() 當作迭代調用入口:它只保證能回答「下一筆是什麼」和可以迭代調用即可。

.return().throw()Iterator Protocol 的可選方法,不是每個 iterator 都有;它們也不是只能由語言在內部使用的隱藏方法。若物件有實作,就能直接呼叫。

Generator object 則標準地提供這三個方法。把它延續成剛才的導覽員比喻:

外部不只能說「繼續」,還能說「今天到這裡」或「這裡發生了一個錯誤」。

呼叫 外部送進 generator 的指令 通常會看到的結果
.next(value) 正常繼續;value 會成為上一個 yield expression 的值 跑到下一個 yield 時回傳 { value, done: false };執行完則 done: true
.return(value) 要求在目前暫停點提早結束 先跑 finally,通常回傳 { value, done: true }
.throw(error) 在目前暫停點把 error 丟回 generator 內 catch 可處理它並繼續 yield;未處理時會在呼叫端拋出

因此,較精準的說法不是「Generator 比 iterator 多了一個 return()」,而是:一般 iterator 最少只要有 .next();Generator 則提供 .next().return().throw() 這套雙向流程控制介面。


.return(value):外部要求提早收工

.return() 可以直接呼叫。它不是 generator function 裡寫的 return,而是外部從暫停點送入一個「請結束」的要求。

正常情況下,generator 不會再執行原本下一行的工作,而是先執行 finally,再以 done: true 結束;傳入的 value 會成為最後的 value。

function* gen() {
  try {
    yield 1;
    yield 2;
  } finally {
    console.log("cleanup");
  }
}

const iterator = gen();

iterator.next();           // { value: 1, done: false }
iterator.return("stop");  // 印出 cleanup,回傳 { value: "stop", done: true }

這是清理資源的重要入口,例如停止讀取、關閉連線或釋放暫存狀態。不過「通常」二字不能省略:如果 finally 裡又有 yield,清理流程本身可以再暫停一次;此時第一次 .return() 可能先得到 { done: false },下一次恢復後才真正結束。


函式裡的 return 與外部的 .return() 不同

前面介紹的 .return()外部要求 generator 提早收工;這一節的 return 則是 generator function 自己執行到結束點。兩者最後都會得到 { value, done: true },但發動者不同。

就像普通 function 一樣,generator function 執行到內部的 return 時會結束。後面即使還有 yield,也不會再執行:

function* demo() {
  yield 1;
  return "結束";
  yield 2; // 永遠不會執行
}

const iterator = demo();

console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: "結束", done: true }

因此,上面 conversation() 裡的 return "對話結束" 代表它自己走到流程終點。這個最終 value 可以由手動 .next() 讀到;但 for...of 只處理每次 yield 交出的值,會略過這個結束時的 value。


.throw(error):從暫停點送入錯誤

.throw(error) 會讓剛才暫停的 yield 像在 generator 內部拋出了那個錯誤一樣。因此 generator 可以用 try...catch 決定要處理它、交出替代值、正常結束,或把錯誤繼續往外拋;不論如何,只要離開 scope,都會先經過 finally

簡單來說,.throw(error) 會讓錯誤在暫停的 yield 位置發生。若這個位置被 Generator 內部的 try...catch 包住,流程就會跳進 catch;處理完成後,可以繼續 yield、正常結束或再次拋出錯誤。

function* task() {
  try {
    yield "等待資料";
  } catch (error) {
    yield `已處理:${error.message}`;
  } finally {
    console.log("cleanup");
  }

  return "結束";
}

const iterator = task();

console.log(iterator.next());
// { value: "等待資料", done: false }

console.log(iterator.throw(new Error("連線失敗")));
// { value: "已處理:連線失敗", done: false }

console.log(iterator.next());
// 印出 cleanup
// { value: "結束", done: true }


為什麼它同時是 Iterator 與 Iterable?

剛才的 counter.next(),所以它是 iterator;它也能直接交給 for...of

for (const value of countToThree()) {
  console.log(value);
}
// 1
// 2
// 3

這表示 generator object 同時也是 iterable。概念上,它的 [Symbol.iterator]() 會回傳自己:這個物件既能回答「下一筆是什麼」,也能回答「從這裡開始迭代要用哪個 iterator」。

但要注意「每次呼叫 generator function 都會產生新的 generator」:

const first = countToThree();
const second = countToThree();

console.log(first === second); // false

first 讀到第二筆不會影響 second。這和 Array 很不一樣:Array 通常是可重複從頭迭代的 collection;單一 generator 則有自己的讀取位置,讀過的值不會自動回來。


Generator 提供暫停點;是否 lazy 要看流程怎麼安排

因為 consumer 每次呼叫 .next(),generator 才會從暫停處繼續,所以很容易讓人以為「Generator 一定是 lazy」,也就是總運算次數變少,或「延後到需要時才做」

暫停/恢復只決定何時開始或繼續執行,不限制它每次恢復後要做多少工作。

呼叫 generator function 時,函式本體確實不會立刻執行;它只會先建立 Generator Object。直到 consumer 呼叫 .next(),程式才開始執行,並在遇到下一個 yield 時暫停。

但這不代表「只要用了 Generator 或 Iterator,程式就會自動變成惰性求值」。

function* notVeryLazy(items) {
  const results = items.map(expensiveWork); // 昂貴的計算
  yield* results;
}

https://ithelp.ithome.com.tw/upload/images/20260907/20145251WIqlpaVkqd.png

可以把 .next() 想成客人按鈴點下一道菜。廚師收到通知後,可以只做一道菜就送出,也可以先把整桌菜全部做好,最後才送出第一道。Generator 只負責「收到 .next() 才開工,遇到 yield 就暫停」;一次開工後要做多少事情,仍由程式內容決定。

function* processOneByOne(items) {
  for (const item of items) {
    yield expensiveWork(item);
  }
}

const iterator = processOneByOne(items);

iterator.next(); // 只處理第 1 筆
iterator.next(); // 只處理第 2 筆
// 到此停止

上面這段才把工作拆在不同 yield 之間:每次 .next() 只處理目前需要的一筆,consumer 資料消費者不再索取資料時,後面的工作也不必執行,很微妙的一段相對比較。


實務判斷:什麼時候該用 Generator?

如果函式的責任是「做一件事,最後給一個結果」,普通 function 或 async function 通常更直接:

async function loadProfile(id) {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

不要因為流程裡有迴圈就急著使用 function*。Generator 適合的是 consumer 真正需要多次詢問「下一筆是什麼」的問題,例如 parser 逐個 token 交出結果、分頁資料逐頁處理,或無限序列只取前幾筆。

可以先問一句:呼叫端需要的是「一個最終結果」,還是「可自行決定何時索取下一筆的來源」?

前者選普通 function/Promise;後者才考慮 Generator。這也能避免把 Generator 當成一般非同步流程或 state 管理的替代品。


今日總結

  • 必懂:每次呼叫 generator function 都會得到新的 generator;它有 .next(),能從上一個 yield 繼續交出下一筆。
  • yield 可接收 .next(value) 傳入的值;generator function 裡的 return 會自己結束流程。
  • generator object 同時是 iterator 與 iterable,所以既可手動 .next(),也能直接放進 for...of
  • 一般 iterator 的必要方法只有 .next();Generator 還有可直接呼叫的 .return().throw(),形成雙向流程控制介面。
  • Generator 讓 producer 好寫,不等於它必然 lazy;是否延後工作要看實作。

下一篇預告

Generator 把狀態保存與恢復變得好寫;下一篇要問的是,consumer 不要下一筆時,producer 資料來源端是否真的不必繼續工作,它們之間有存在著什麼樣的行為關係,或者可以透過流程設計來達到?


參考資料


上一篇
Day 03|for...of 怎麼拿資料?next()、break 與提前離開時的收尾
下一篇
Day 05|資料何時才真的被產生?Iterator 的 pull 與 lazy 模式
系列文
30 天新世代 JavaScript 自我學習指南7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言