ES6中引入很多新特性,其中關於非同步操作的處理就引入了Promise和生成器。眾所周知,Promise可以在一定程度上解決被廣為詬病的回撥地獄問題。但是在處理多個非同步操作時採用Promise鏈式呼叫的語法也會顯得不是那麼優雅和直觀。而生成器在Promise的基礎上更進一步,允許我們用同步的方式來描述我們的非同步流程。
基本介紹
Generator函式和普通函式完全不同,有其與眾不同的獨特語法。一個簡單的Generator函式就長下面這個樣子:
function* greet() { yield 'hello' }
複製程式碼
在第一次呼叫Generator函式的時候並不會執行Generator函式內部的程式碼,而是會返回一個生成器物件。在前面的文章中,我們也提過,通過呼叫這個生成器物件的next函式可以開始執行Generator函式內部的邏輯,在遇到yield語句會暫停函式的執行,同時向外界返回yield關鍵字後面的結果。暫停之後在需要恢復Generator函式執行時同樣可以通過呼叫生成器物件的next方法恢復,同時向next方法傳入的引數會作為生成器內部當前暫停的yield語句的返回值。如此往復,直到Generator函式內部的程式碼執行完畢。舉例:
function* greet() {
let result = yield 'hello'
console.log(result)
}
let g = greet()
g.next() // {value: 'hello', done: false}
g.next(2) // 列印結果為2,然後返回{value: undefined, done: true}
複製程式碼
第一次呼叫next方法傳入的引數,生成器內部是無法獲取到的,或者說沒有實際意義,因為此時生成器函式還沒有開始執行,第一次呼叫next方法是用來啟動生成器函式的。
yield語法要點
yield 後面可以是任意合法的JavaScript表示式,yield語句可以出現的位置可以等價於一般的賦值表示式(比如a=3)能夠出現的位置。舉例:
b = 2 + a = 3 // 不合法
b = 2 + (a = 3) // 合法
b = 2 + yield 3 // 不合法
b = 2 + (yield 3) // 合法
複製程式碼
yield關鍵字的優先順序比較低,幾乎yield之後的任何表示式都會先進行計算,然後再通過yield向外界產生值。而且yield是右結合運算子,也就是說yield yield 123等價於(yield (yield 123))。
關於生成器物件
Generator函式返回的生成器物件是Generator函式的一個例項,也就是說返回的生成器物件會繼承Generator函式原型鏈上的方法。舉例:
function* g() {
yield 1
}
g.prototype.greet = function () {
console.log('hello')
}
let g1 = g()
console.log(g1 instanceof g) // true
g1.greet() // 'hello'
複製程式碼
執行生成器物件的[Symbol.iterator]方法會返回生成器物件本身。
function* greet() {}
let g = greet()
console.log(g[Symbol.iterator]() === g) // true
複製程式碼
生成器物件還具有以下兩個方法:
- return方法。和迭代器介面的return方法一樣,用於在生成器函式執行過程中遇到異常或者提前中止(比如在for...of迴圈中未完成時提前break)時自動呼叫,同時生成器物件變為終止態,無法再繼續產生值。也可以手動呼叫來終止迭代器,如果在呼叫return方法傳入引數,則該引數會作為最終返回物件的value屬性值。
如果剛好是在生成器函式中的try程式碼塊中函式執行暫停並且具有finally程式碼塊,此時呼叫return方法不會立即終止生成器,而是會繼續將finally程式碼塊中的邏輯執行完,然後再終止生成器。如果finally程式碼塊中包含yield語句,意味著還可以繼續呼叫生成器物件的next方法來獲取值,直到finally程式碼塊執行結束。舉例:
function* ff(){
yield 1;
try{ yield 2 }finally{ yield 3 }
}
let fg = ff()
fg.next() // {value: 1, done: false}
fg.return(4) // {value: 4, done: true}
let ffg = ff()
ffg.next() // {value: 1, done: false}
ffg.next() // {value: 2, done: false}
ffg.return(4) // {value: 3, done: false}
ffg.next() // {value: 4, done: true}
複製程式碼
從上面的例子中可以看出,在呼叫return方法之後如果剛好觸發finally程式碼塊並且finally程式碼中存在yield語句,就會導致在呼叫return方法之後生成器物件並不會立即結束,因此在實際使用中不應該在finally程式碼塊中使用yield語句。
- throw方法。呼叫此方法會在生成器函式當前暫停執行的位置處丟擲一個錯誤。如果生成器函式中沒有對該錯誤進行捕獲,則會導致該生成器物件狀態終止,同時錯誤會從當前throw方法內部向全域性傳播。在呼叫next方法執行生成器函式時,如果生成器函式內部丟擲錯誤而沒有被捕獲,也會從next方法內部向全域性傳播。
yield*語句
yield* 語句是通過給定的Iterable物件的[Symbol.iterator]方法返回的迭代器來產生值的,也稱為yield委託,指的是將當前生成器函式產生值的過程委託給了在yield*之後的Iterable物件。基於此,yield* 可以用來在Generator函式呼叫另外一個Generator函式。舉例:
function* foo() {
yield 2
yield 3
return 4
}
function* bar() {
let ret = yield* foo()
console.log(ret) // 4
}
複製程式碼
上面的例子中,被代理的Generator函式最終執行完成的返回值最終會作為代理它的外層Generator函式中yield*語句的返回值。
另外,錯誤也會通過yield*在被委託的生成器函式和控制外部生成器函式的程式碼之間傳遞。舉例:
function* delegated() {
try {
yield 1
} catch (e) {
console.log(e)
}
yield 2
throw "err from delegate"
}
function* delegate() {
try {
yield* delegated()
} catch (e) {
console.log(e)
}
yield 3
}
let d = delegate()
d.next() // {value: 1, done: false}
d.throw('err')
// err
// {value: 2, done: false}
d.next()
// err from delegate
// {value: 3, done: false}
複製程式碼
最後需要注意的是yield*和yield之間的區別,容易忽視的一點是yield*並不會停止生成器函式的執行。舉例:
function* foo(x) {
if (x < 3) {
x = yield* foo(x + 1)
}
return x * 2
}
let f = foo()
f.next() // {value: 24, done: true}
複製程式碼
使用Generator組織非同步流程
使用Generator函式來處理非同步操作的基本思想就是在執行非同步操作時暫停生成器函式的執行,然後在階段性非同步操作完成的回撥中通過生成器物件的next方法讓Generator函式從暫停的位置恢復執行,如此往復直到生成器函式執行結束。
也正是基於這種思想,Generator函式內部才得以將一系列非同步操作寫成類似同步操作的形式,形式上更加簡潔明瞭。而要讓Generator函式按順序自動完成內部定義好的一系列非同步操作,還需要通過額外的函式來執行Generator函式。對於每次返回值為非Thunk函式型別的生成器函式,可以用co模組來自動執行。而對於遵循callback的非同步API,則需要先轉化為Thunk函式然後再整合到生成器函式中。比如我們有這樣的API:
logAfterNs = (seconds, callback) =>
setTimeout(() => {console.log('time out');callback()}, seconds * 1000)
複製程式碼
非同步流程是這樣的:
logAfterNs(1, function(response_1) {
logAfterNs(2, function () {
...
})
})
複製程式碼
首先我們需要將非同步API轉化為Thunk形式,也就是原來的API:logAfterNs(...args, callback),我們需要改造為:thunkedLogAfterNs(...args)(callback)
function thunkify (fn) {
return function (...args) {
return function (callback) {
args.push(callback)
return fn.apply(null, args)
}
}
}
let thunkedLogAfterNs = thunkify(logAfterNs)
function* sequence() {
yield thunkedLogAfterNs(1)
yield thunkedLogAfterNs(2)
}
複製程式碼
轉化為使用生成器函式來改寫我們的非同步流程之後,我們還需要一個函式來自動管理並執行我們的生成器函式。
function runTask(gen) {
let g = gen()
function next() {
let result = g.next()
if (!result.done) result.value(next)
}
next()
}
runTask(sequence)
複製程式碼
更好的async/await
ES7引入的async/await語法是Generator函式的語法糖,只是前者不再需要執行器。直接執行async函式就會自動執行函式內部的邏輯。async函式執行結果會返回一個Promise物件,該Promise物件狀態的改變取決於async函式中await語句後面的Promise物件狀態以及async函式最終的返回值。接下來重點講一下async函式中的錯誤處理。
await關鍵字之後可以是Promise物件,也可以是原始型別值。如果是Promise物件,則將Promise物件的完成值作為await語句的返回值,一旦其中有Promise物件轉化為Rejected狀態,async函式返回的Promise物件也會隨之轉化為Rejected狀態。舉例:
async function aa() {await Promise.reject('error!')}
aa().then(() => console.log('resolved'), e => console.error(e)) // error!
複製程式碼
如果await之後的Promise物件轉化為Rejected,在async函式內部可以通過try...catch捕獲到對應的錯誤。舉例:
async function aa() {
try {
await Promise.reject('error!')
} catch(e) {
console.log(e)
}
}
aa().then(() => console.log('resolved'), e => console.error(e))
// error!
// resolved
複製程式碼
如果async函式中沒有對轉化為Rejected狀態的Promise進行捕獲,則在外層對呼叫aa函式進行捕獲並不能捕獲到錯誤,而是會把aa函式返回的Promise物件轉化為Rejected狀態,在前一個例子中也說明了這一點。
在實驗中還嘗試使用函式物件作為await關鍵字之後的值,結果發現await在遇到這種情況時也是按照普通值進行處理,就是await表示式的結果就是該函式物件。
async function bb(){
let result = await (() => {});
console.log(result);
return 'done'
}
bb().then(r => console.log(r), e => console.log(e))
// () => {}
// done
複製程式碼
總結
文章中我們介紹了Generator函式的基本用法和注意事項,並且也舉了一個實際的例子來說明如何使用Generator函式來描述我們的非同步流程,最後還簡單介紹了async函式的使用。總而言之,ES6之後也提供了更多管理非同步流程的方式,使得我們的程式碼組織起來更加清晰,更加高效!