進階 Javascript 生成器

jkest發表於2017-12-14

我曾一度認為沒有必要去學習 Javascript 的生成器( Generator ),認為它只是解決非同步行為的一種過渡解決方案,直到最近對相關工具庫的深入學習,才逐漸認識到其強大之處。可能你並沒有手動去寫過一個生成器,但是不得不否認它已經被廣泛使用,尤其是在 redux-sagaRxJS 等優秀的開源工具。


可迭代物件和迭代器

首先需要明確的是生成器其實來源於一種設計模式 —— 迭代器模式,在 Javascript 中迭代器模式的表現形式是可迭代協議,而這也是 ES2015 中迭代器和可迭代物件的來源,這是兩個容易讓人混淆的概念,但事實上 ES2015 對其做了明確區分。

定義

實現了 next 方法的物件被稱為迭代器next 方法必須返回一個 IteratorResult 物件,該物件形如:

{ 
value: undefined, done: true
}複製程式碼

其中 value 表示此次迭代的結果,done 表示是否已經迭代完畢。

實現了 @@iterator 方法的物件稱為可迭代物件,也就是說該物件必須有一個名字是 [Symbol.iterator] 的屬性,這個屬性是一個函式,返回值必須是一個迭代器

String, Array, TypedArray, MapSet 是 Javascript 中內建的可迭代物件,比如,Array.prototype[Symbol.iterator]Array.prototype.entries 會返回同一個迭代器:

const a = [1, 3, 5];
a[Symbol.iterator]() === a.entries();
// trueconst iter = a[Symbol.iterator]();
// Array Iterator {
}
iter.next() // {
value: 1, done: false
}
複製程式碼

ES2015 中新增的陣列解構也會預設使用迭代器進行迭代:

const arr = [1, 3, 5];
[...a];
// [1, 3, 5]const str = 'hello';
[...str];
// ['h', 'e', 'l', 'l', 'o']複製程式碼

自定義迭代行為

既然可迭代物件是實現了 @@iterator 方法的物件,那麼可迭代物件就可以通過重寫 @@iterator 方法實現自定義迭代行為:

const arr = [1, 3, 5, 7];
arr[Symbol.iterator] = function () {
const ctx = this;
const {
length
} = ctx;
let index = 0;
return {
next: () =>
{
if (index <
length) {
return {
value: ctx[index++] * 2, done: false
};

} else {
return {
done: true
};

}
}
};

};
[...arr];
// [2, 6, 10, 14]複製程式碼

從上面可以看出,當 next 方法返回 {
done: true
}
時,迭代結束。

生成器既是可迭代物件也是迭代器

有兩種方法返回生成器:

const counter = (function* () { 
let c = 0;
while(true) yield ++c;

})();
counter.next();
// {
value: 1, done: false
},counter 是一個迭代器
counter[Symbol.iteratro]();
// counterGen {[[GeneratorStatus]]: "suspended"
}, counter 是一個可迭代物件
複製程式碼

上面的程式碼中的 counter 就是一個生成器,實現了一個簡單的計數功能。不僅沒有使用閉包也沒有使用全域性變數,實現過程非常優雅。


生成器的基本語法

生成器的強大之處在於能方便地對生成器函式內部的邏輯進行控制。在生成器函式內部,通過 yieldyield* ,將當前生成器函式的控制權移交給外部,外部通過呼叫生成器的 nextthrowreturn 方法將控制權返還給生成器函式,並且還能夠向其傳遞資料。

yield 和 yield* 表示式

yieldyield* 只能在生成器函式中使用。生成器函式內部通過 yield 提前返回,前面的計數器就是利用這個特性向外部傳遞計數的結果。需要注意的是前面的計數器是無限執行的,只要生成器呼叫 next 方法,IteratorResultvalue 就會一直遞增下去,如果想計數個有限值,需要在生成器函式裡面使用 return 表示式:

const ceiledCounter = (function* (ceil) { 
let c = 0;
while(true) {
++c;
if (c === ceil) return c;
yield c;

}
})(3);
ceiledCounter.next();
// {
value: 1, done: false
}
ceiledCounter.next();
// {
value: 2, done: false
}
ceiledCounter.next();
// {
value: 3, done: true
}
ceiledCounter.next();
// {
value: undefined, done: true
}
複製程式碼

yield 後可以不帶任何表示式,返回的 valueundefined

const gen = (function* () { 
yield;

})();
gen.next();
// {
value: undefined, done: false
}
複製程式碼

生成器函式通過使用 yield* 表示式用於委託給另一個可迭代物件,包括生成器。

委託給 Javascript 內建的可迭代物件:

const genSomeArr = function* () { 
yield 1;
yield* [2, 3];

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

委託給另一個生成器(還是利用上面的 genGreet 生成器函式):

const genAnotherArr = function* () { 
yield* genSomeArr();
yield* [4, 5];

};
const anotherArr = genAnotherArr();
greetWorld.next();
// {
value: 1, done: false
}
greetWorld.next();
// {
value: 2, done: false
}
greetWorld.next();
// {
value: 3, done: false
}
greetWorld.next();
// {
value: 4, done: false
}
greetWorld.next();
// {
value: 5, done: false
}
greetWorld.next();
// {
value: undefined, done: true
}
複製程式碼

yield 表示式是有返回值的,接下來解釋具體行為。

next 、throw 和 return 方法

生成器函式外部正是通過這三個方法去控制生成器函式的內部執行過程的。

next

生成器函式外部可以向 next 方法傳遞一個引數,這個引數會被當作上一個 yield 表示式的返回值,如果不傳遞任何引數,yield 表示式返回 undefined

const canBeStoppedCounter = (function* () { 
let c = 0;
let shouldBreak = false;
while (true) {
shouldBreak = yield ++c;
console.log(shouldBreak);
if (shouldBreak) return;

}
};
canBeStoppedCounter.next();
// {
value: 1, done: false
}
canBeStoppedCounter.next();
// undefined,第一次執行 yield 表示式的返回值// {
value: 2, done: false
}
canBeStoppedCounter.next(true);
// true,第二次執行 yield 表示式的返回值// {
value: undefined, done: true
}
複製程式碼

再來看一個連續傳入值的例子:

const greet = (function* () { 
console.log(yield);
console.log(yield);
console.log(yield);
return;

})();
greet.next();
// 執行第一個 yield表示式greet.next('How');
// 第一個 yield 表示式的返回值是 "How",輸出 "How"greet.next('are');
// 第二個 yield 表示式的返回值是 "are",輸出"are"greet.next('you?');
// 第三個 yield 表示式的返回值是 "you?",輸出 "you"greet.next();
// {
value: undefined, done: true
}
複製程式碼

throw

生成器函式外部可以向 throw 方法傳遞一個引數,這個引數會被 catch 語句捕獲,如果不傳遞任何引數,catch 語句捕獲到的將會是 undefinedcatch 語句捕獲到之後會恢復生成器的執行,返回帶有 IteratorResult

const caughtInsideCounter = (function* () { 
let c = 0;
while (true) {
try {
yield ++c;

} catch (e) {
console.log(e);

}
}
})();
caughtInsideCounter.next();
// {
value: 1, done: false
}
caughtIndedeCounter.throw(new Error('An error occurred!'));
// 輸出 An error occurred!// {
value: 2, done: false
}
複製程式碼

需要注意的是如果生成器函式內部沒有 catch 到,則會在外部 catch 到,如果外部也沒有 catch 到,則會像所有未捕獲的錯誤一樣導致程式終止執行:

return

生成器的 return 方法會結束生成器,並且會返回一個 IteratorResult,其中 donetruevalue 是向 return 方法傳遞的引數,如果不傳遞任何引數,value 將會是 undefined

const g = (function* () { 
yield 1;
yield 2;
yield 3;

})();
g.next();
// {
value: 1, done: false
}
g.return("foo");
// {
value: "foo", done: true
}
g.next();
// {
value: undefined, done: true
}
複製程式碼

通過上面三個方法,使得生成器函式外部對生成器函式內部程式執行流程有了一個非常強的控制力。


生成器的非同步應用

生成器函式與非同步操作結合是非常自然的表達:

const fetchUrl = (function* (url) { 
const result = yield fetch(url);
console.log(result);

})('https://api.github.com/users/github');
const fetchPromise = fetchUrl.next().value;
fetchPromise .then(response =>
response.json()) .then(jsonData =>
fetchUrl.next(jsonData));
// {login: "github", id: 9919, avatar_url: "https://avatars1.githubusercontent.com/u/9919?v=4", gravatar_id: "", url: "https://api.github.com/users/github", 

}
複製程式碼

在上面的程式碼中,fetch 方法返回一個 Promise 物件 fetchPromisefetchPromise 通過一系列的解析之後會返回一個 JSON 格式的物件 jsonData,將其通過 fetchUrlnext 方法傳遞給生成器函式中的 result,然後列印出來。

生成器的侷限性

從上面的過程可以看出,生成器配合 Promise 確實可以很簡潔的進行非同步操作,但是還不夠,因為整個非同步流程都是我們手動編寫的。當非同步行為變的更加複雜起來之後(比如一個非同步操作的佇列),生成器的非同步流程管理過程也將會變得難以編寫和維護。

需要一種能自動執行非同步任務的工具進行配合,生成器才能真正派上用場。實現這種工具通常有兩種思路:

  • 通過不斷進行回撥函式的執行,直到全部過程執行完畢,基於這種思路的是 thunkify 模組;
  • 使用 Javascript 原生支援的 Promise 物件,將非同步過程扁平化處理,基於這種思路的是 co 模組;

下面來分別理解和實現。

thunkify

thunk 函式的起源其實很早,而 thunkify 模組也作為非同步操作的一種普遍解決方案,thunkify原始碼非常簡潔,加上註釋也才三十行左右,建議所有學習非同步程式設計的開發者都去閱讀一遍。

理解了 thunkify 的思想之後,可以將其刪減為一個簡化版本(只用於理解,不用於生產環境中):

const thunkify = fn =>
{
return (...args) =>
{
return callback =>
{
return Reflect.apply(fn, this, [...args, callback]);

};

};

};
複製程式碼

從上面的程式碼可以看出,thunkify 函式適用於回撥函式是最後一個引數的非同步函式,下面我們構造一個符合該風格的非同步函式便於我們除錯:

const asyncFoo = (id, callback) =>
{
console.log(`Waiting for ${id
}
...`
) return setTimeout(callback, 2000, `Hi, ${id
}
`
)
};
複製程式碼

首先是基本使用:

const foo = thunkify(asyncFoo);
foo('Juston')(greetings =>
console.log(greetings));
// Waiting for Juston...// ... 2s later ...// Hi, Juston複製程式碼

接下來我們模擬實際需求,實現每隔 2s 輸出一次結果。首先是構造生成器函式:

const genFunc = function* (callback) { 
callback(yield foo('Carolanne'));
callback(yield foo('Madonna'));
callback(yield foo('Michale'));

};
複製程式碼

接下來實現一個自動執行生成器的輔助函式 runGenFunc

const runGenFunc = (genFunc, callback, ...args) =>
{
const g = genFunc(callback, ...args);
const seqRun = (data) =>
{
const result = g.next(data);
if (result.done) return;
result.value(data =>
seqRun(data));

} seqRun();

};
複製程式碼

注意 g.next().value 是一個函式,並且接受一個回撥函式作為引數,runGenFunc 通過第 7 行的程式碼實現了兩個關鍵步驟:

  • 將上一個 yield 表示式的結果返回之生成器函式
  • 執行當前 yield 表示式

最後是呼叫 runGenFunc 並且將 genFunc 、需要用到的回撥函式 callback 以及其他的生成器函式引數(這裡的生成器函式只有一個回撥函式作為引數)傳入:

runGenFunc(genFunc, greetings =>
console.log(greetings));
// Waiting for Carolanne...// ... 2s later ...// Hi, Carolanne// Waiting for Madonna...// ... 2s later ...// Hi, Madonna// Waiting for Michale...// ... 2s later ...// Hi, Michale複製程式碼

可以看到輸出結果確實如期望的那樣,每隔 2s 進行一次輸出。

從上面的過程來看,使用 thunkify 模組進行非同步流程的管理還是不夠方便,原因在於我們不得不自己引入一個輔助的 runGenFunc 函式來進行非同步流程的自動執行。

co

co 模組可以幫我們完成非同步流程的自動執行工作。co 模組是基於 Promise 物件的。co 模組的原始碼同樣非常簡潔,也比較適合閱讀。

co 模組的 API 只有兩個:

  • co(fn*).then(val =>
    )

    co 方法接受一個生成器函式為唯一引數,並且返回一個 Promise 物件,基本使用方法如下:

    const promise = co(function* () { 
    return yield Promise.resolve('Hello, co!');

    })promise .then(val =>
    console.log(val)) // Hello, co! .catch((err) =>
    console.error(err.stack));
    複製程式碼
  • fn = co.wrap(fn*)

    co.wrap 方法在 co 方法的基礎上進行了進一步的包裝,返回一個類似於 createPromise 的函式,它與 co 方法的區別就在與可以向內部的生成器函式傳遞引數,基本使用方法如下。

    const createPromise = co.wrap(function* (val) { 
    return yield Promise.resolve(val);

    });
    createPromise('Hello, jkest!') .then(val =>
    console.log(val)) // Hello, jkest! .catch((err) =>
    console.error(err.stack));
    複製程式碼

co 模組需要我們將 yield 關鍵字後面的物件改造為一個 co 模組自定義的 yieldable 物件,通常可以認為是 Promise 物件或基於 Promise 物件的資料結構。

瞭解了 co 模組的使用方法後,不難寫出基於 co 模組的自動執行流程。

只需要改造 asyncFoo 函式讓其返回一個 yieldable 物件,在這裡即是 Promise 物件:

const asyncFoo = (id) =>
{
return new Promise((resolve, reject) =>
{
console.log(`Waiting for ${id
}
...`
);
if(!setTimeout(resolve, 2000, `Hi, ${id
}
`
)) {
reject(new Error(id));

}
});

};
複製程式碼

然後就可以使用 co 模組進行呼叫,由於需要向 genFunc 函式傳入一個 callback 引數,所以必須使用 co.wrap 方法:

co.wrap(genFunc)(greetings =>
console.log(greetings));
複製程式碼

上述結果與期望一致。

其實 co 模組內部的實現方式與 thunkify 小節中的 runGenFunc 函式有異曲同工之處,都是使用遞迴函式反覆去執行 yield 語句,知道生成器函式迭代結束,主要的區別就在於 co 模組是基於 Promise 實現的。


可能在實際工作中的大部分時候都可以使用外部模組去完成相應的功能,但是想理解實現原理或者不想引用外部模組,則深入理解生成器的使用就很重要了。在下一篇文章[觀察者模式在 Javascript 中的應用]中我會探究 RxJS 的實現原理,其中同樣涉及到本文所所提及的迭代器模式。最後附上相關參考資料,以供感興趣的讀者繼續學習。


參考資料

來源:https://juejin.im/post/5a31d4b66fb9a045211eb727

相關文章