暫停一下再出發!全面解析 JavaScript Generator 實用技巧
什麼是 Generator?
Generator 的基本概念
在現實生活中,很多情境需要我們「做一點事、暫停一下、再繼續做下一點事」,例如:
- 填問卷:先回答前幾題,暫時休息,等想到答案後再繼續填。
- 做菜:準備食材、放進鍋裡煮,等熟了之後再進行下一個步驟。
在程式世界裡,如果我們希望「做一點事、暫停一下、再繼續做下一點事」就需要依賴 Generator。Generator 就像幫我們建立了一個「可隨時暫停和繼續」的函式。透過在函式宣告前面加上 *(例如 function* myGenerator())並在需要暫停的地方使用 yield 關鍵字,我們就可以一步步地控制這個函式的執行。
function* 與 yieldfunction*:這個宣告方式代表「這是一個生成器函式」,與普通的function最大差別在於,它能透過yield進行暫停與繼續。yield:可以想像成「把工作階段暫停,先把目前結果交出去,等之後再被叫起來繼續做」。程式每次跑到yield就會停住,直到有人呼叫next()才再往下繼續。
以下我們先舉一個簡單的「計數器生成器」範例,帶大家感受一下 Generator 的神奇之處:
function* counter() {
let count = 0;
while (true) {
yield count; // 暫停並返回當前 count
count++;
}
}
const c = counter();
console.log(c.next()); // { value: 0, done: false }
console.log(c.next()); // { value: 1, done: false }
console.log(c.next()); // { value: 2, done: false }
以上的程式中,每次呼叫 c.next(),就像對計數器說:「往下跑一步,並告訴我現在的數字」。
為什麼需要 Generator?
我們已經知道 Generator 可以在函式執行過程中隨時暫停、繼續,那這對我們到底有什麼幫助呢?以下舉幾個常見的使用場景:
-
循序執行(順序產生資料)
假設你在寫一支程式,需要依序產生一大串數字或資料,就像流水線一樣,每做一個就回傳一次給外面使用者。這時候,Generator 可以很直覺地實現「一個一個丟出來」的功能,而不需要一次把所有資料都生成完才回傳。 -
延遲計算(按需計算資料)
在面對龐大的 資料或複雜的運算時,有時候我們不想一次算到完,因為那樣可能會非常耗時或耗記憶體。透過 Generator,我們可以在需要用到結果的時候,才去計算下一步。以餐廳廚房出餐作為比喻:通常一般的餐廳不會一次煮好一桌菜才出餐,而是先做好前菜就立即出餐,等待客人吃完再出下一道菜,減少廚房忙亂,也可節省資源。
-
提高程式碼可讀性
在某些需要階段性處理的流程中,如果不用 Generator,可能會寫得一團亂,或需要很多旗標變數來控制執行時機。Generator 讓我們可以在邏輯上更容易地一段一段看,每次yield都代表一個「休息切點」,這樣程式結構會變得較清晰。
Generator 的運作原理
迭代器與生成器的關係
在探討 Generator 的運作前,我們先簡單認識一下「迭代器(Iterator)」的概念。
- 迭代器:一個符合 Iteration protocols 的物件,能夠逐一取出內部的資料,每次往前「走一步」,給你下一個資料,直到資料走完為止。
- 在 JavaScript 裡,迭代器會實作一個
next()方法,每次呼叫都回傳一個{ value, done }物件。value表示當前的資料,done表示是否已到末端。
以下我們來用個簡單的範例示範如何「手動」實作一個簡單的迭代器,用來取得陣列中每個元素:
function createIterator(array) {
let index = 0;
return {
[Symbol.iterator]() {
return this;
},
next() {
if (index < array.length) {
return { value: array[index++], done: false };
} else {
return { value: undefined, done: true };
}
},
};
}
const myIterator = createIterator([1, 2, 3]);
console.log(myIterator.next()); // { value: 1, done: false }
console.log(myIterator.next()); // { value: 2, done: false }
console.log(myIterator.next()); // { value: 3, done: false }
console.log(myIterator.next()); // { value: undefined, done: true }
看上去跟前一章節 Generator 範例中的使用方法完全一樣對吧? 這是因為 Generator 函數返回的 Generator Object ,其本質上就是一個迭代器物件。
Generator 幫我們自動實作了上述「迭代器介面」的繁瑣部分。只要我們用 function* 宣告函式,裡面寫上 yield,就能生成一個物件,內建了 next() 方法,無須手動管理索引或結束時機。每次呼叫 Generator 物件的 next(),回傳的結構也與一般迭代器相同: { value, done }。
function* myGenerator() {
yield "Hello";
yield "World";
}
const genObj = myGenerator();
console.log(genObj.next()); // { value: 'Hello', done: false }
console.log(genObj.next()); // { value: 'World', done: false }
console.log(genObj.next()); // { value: undefined, done: true }
可以看到,genObj 自帶 next() 方法,每次執行都回傳 { value, done },說明這個物件同時是「可迭代」的。
如果想更深入了解迭代器(Iterator)的規範與用法,建議可參考 MDN: Iterator 進行進一步閱讀。
yield 與 return
在 Generator 裏面,最常見也最重要的兩個關鍵字莫過於 yield 與 return。它們雖然都能「輸出」值,但實際上有著不同的意義與行為。
yield 的功能與特性:單向與雙向通信
- 單向:你可以在
yield後面寫一個值,例如yield "Hello",呼叫next()時,這個值會被「拋出」給外部。 - 雙向:如果外部在
next()裏面傳入參數,例如genObj.next("some data"),那麼該次執行的yield也能「接收」到這個參數。 - 範例程式(展示雙向通信):
function* twoWayGenerator() {
const firstData = yield "Send me something";
console.log("I received:", firstData);
const secondData = yield "One more time?";
console.log("I received:", secondData);
}
const gen = twoWayGenerator();
console.log(gen.next()); // { value: 'Send me something', done: false }
console.log(gen.next("Hi there")); // 在這裡傳入參數
// 主控台輸出: I received: Hi there
// => { value: 'One more time?', done: false }
console.log(gen.next("Yes!"));
// 主控台輸出: I received: Yes!
// => { value: undefined, done: true }
return 在 Generator 中的作用
-
生成器結束時的返回值
- 一旦
return someValue出現,整個 Generator 就直接結束,並透過{ value: someValue, done: true }回傳。 - 此後再呼叫
next(),done都會維持true,不會再有新的value。
- 一旦
-
return與yield的差異yield:暫停函式執行,但有機會再被「叫醒」繼續跑。return:一次性 結束函式,相當於「收工」。- 結束時也會回傳一個
{ value, done: true }物件,但從此之後就不再有任何新輸出了。
-
範例:比較
yield與returnfunction* checkYieldAndReturn() {
yield "First yield value"; // 先暫停一次
yield "Second yield value"; // 再暫停一次
return "Final return value"; // 結束並回傳
// 下面任何程式碼都不會再被執行到
yield "This will never show";
}
const gen = checkYieldAndReturn();
console.log(gen.next()); // { value: 'First yield value', done: false }
console.log(gen.next()); // { value: 'Second yield value', done: false }
console.log(gen.next()); // { value: 'Final return value', done: true }
// 之後再怎麼呼叫,都不會再有新的結果
console.log(gen.next()); // { value: undefined, done: true }可以看到,當遇到
return 'Final return value'時,就一次性結束了生成器。
- 如果想繼續吐出更多值,就不要使用
return; - 如果只想在某個狀況下結束整個生成器,就可使用
return來做「中斷且回傳」的動作。
Generator 的進階應用與技巧
yield*
在 Generator 內除了可 以使用 yield 把工作階段暫停,先把目前結果交出去,還有一個相當實用的關鍵字:yield*。它常被用來將「產值」委派給另外一個生成器(或任何可迭代物件),就像是呼叫「副司機」來幫忙開車一段,或是把工作轉給另一個人來完成。
1. 什麼是 yield*?
-
用於委派給其他生成器或可迭代對象:
當我們有兩個生成器,A 與 B,如果想要在 A 的某個階段,直接把控制權交給 B,讓 B 幫我們一口氣產出多個值,就能使用yield* B。 -
示例:遞迴結構(如樹狀結構的展開):
當你的資料結構是一顆樹,或是巢狀很深的結構,你想把所有值「攤平」來遍歷,就可以在程式裡碰到「子節點」時,使用yield*把子節點展開。舉一個最簡單的例子:
function* subGenerator() {
yield "A";
yield "B";
}
function* mainGenerator() {
yield "Start";
yield* subGenerator(); // 把控制權交給 subGenerator
yield "End";
}
for (const value of mainGenerator()) {
console.log(value);
}
// 輸出:
// Start
// A
// B
// End在
mainGenerator中,我們使用yield* subGenerator(),等於把執行流程委派給subGenerator。當subGenerator結束後,又會繼續回到mainGenerator往下執行。這樣就能讓程式結構更有彈性,不 用手動在 mainGenerator 裡一個個yield 'A'、yield 'B'。
2. yield* 的應用場景
- 嵌套生成器的簡化操作
- 如果沒有
yield*,我們可能要在一個生成器裡頭手動呼叫另一個生成器的next(),把值撈出來再yield出去,程式碼寫起來相對瑣碎。 yield*幫你自動完成「一個個撈出來再吐出去」的動作。
- 如果沒有
- 合併多個生成器
- 當你有多個生成器,例如
gen1,gen2,gen3,都要在同一支程式裡執行時,可以用yield* gen1(),yield* gen2(),yield* gen3()一條龍串起來,省去重複撰寫的麻煩。 - 例如,你希望能在一個迭代流程中把「標題列表」「內容列表」「註腳列表」都從不同生成器裡串接到一起,可以用
yield*依序把它們串回主生成器中。
- 當你有多個生成器,例如
傳遞參數與錯誤處理
前面我們談到,Generator 能透過 next() 將外部的參數「丟」到生成器裡,這讓 Generator 不只是一味產出資料,也能收資料。當我們更進一步想處理「錯誤」時,還可以用 throw 在 Generator 裡做相應的錯誤捕捉。
- 在
yield語句中傳遞參數- 呼叫
next(someValue)時,yield語句就能接收到這個someValue。 - 範例:
我把以上範例繪製成時序圖,方便大家觀察 console.log 的先後順序:
function* twoWayGenerator() {
const firstInput = yield "First output";
console.log("我收到了:", firstInput);
const secondInput = yield "Second output";
console.log("又收到了:", secondInput);
}
const gen = twoWayGenerator();
console.log(gen.next()); // { value: 'First output', done: false }
console.log(gen.next("Hello")); // "我收到了: Hello"
// { value: 'Second output', done: false }
console.log(gen.next("World")); // "又收到了: World"
// { value: undefined, done: true } - 透過這種機制,可以實現更靈活的互動,像是「請求 - 回應」的雙向交流。
- 呼叫
- 在 Generator 中使用
throw處理錯誤- Generator 物件也可以在外部呼叫
gen.throw(new Error("錯誤內容")),直接把錯誤丟到生成器函式裡。 - 在生成器內,可以用
try...catch來攔截這個錯誤。如以下範例:同樣地,我把以上範例繪製成時序圖方便大家觀察 error 被印出來順序function* errorHandlerGen() {
try {
yield "Start";
} catch (err) {
console.log("捕捉到錯誤:", err.message);
}
yield "End";
}
const g = errorHandlerGen();
console.log(g.next()); // { value: 'Start', done: false }
console.log(g.throw(new Error("Oops")));
// "捕捉到錯誤: Oops"
// => { value: 'End', done: false }
console.log(g.next()); // { value: undefined, done: true } - 一旦在外部
throw(),就會把錯誤「注入」到生成器中當前的位置,若沒有try...catch,就會結束該生成器;有的話就能在catch中妥善處理,並繼續往下執行後續的程式。
- Generator 物件也可以在外部呼叫
AsyncGenerator
在繼續深入「控制流程」與「非同步操作」前,我們先來了解一下 AsyncGenerator。
這個特性是在 ECMAScript 2018(ES9)中正式引入的語法,讓我們能夠在生成器函式中直接使用 await,並搭配 for await...of 來處理非同步資料流。
以下是一個最簡單的 AsyncGenerator 範例:
// 一個簡單的 asyncGenerator,會等 1 秒後回傳一個數字
async function* asyncGen() {
let i = 0;
while (i < 3) {
await new Promise((resolve) => setTimeout(resolve, 1000)); // 模擬非同步工作
yield i++;
}
}
// for await...of 語法用法
(async function () {
for await (const num of asyncGen()) {
console.log(num);
}
console.log("All done!");
})();
async function*: 表示這是一個「非同步的生成器函式」,允許在函式內使用await。for await...of: 能自動消化「非同步的迭代結果」,每等到一次yield時,程式就會得到一個值並繼續往下執行。- 執行時機: 透過
await可以在生成器裡直接等待 Promise 完成,讓程式看起來像「同步」邏輯一般,但實際上是非同步運行。