學習JavaScript中的非同步Generator
非同步的generators和非同步iteration已經到來! 這是錯誤的, 它們現在還在階段 3,這表示他們很有可能在JavaScript未來的一個版本中釋出。 在他們釋出之前,你可以通過Babel來在你的專案中使用還在階段3的建議內容。
網站基本上還是一些分散執行的應用,因為任何在語言上的修改都會造成永久的影響,所以所有的未來的版本都需要向後相容。因此,被加入到ECMAScript標準的特性,它必須是十分的可靠,而且它的語法需要很優雅。
考慮到這一點,我們希望非同步generator和迭代器可以顯著地影響我們如何構建今後的程式碼,同時也解決現在的問題。讓我們開始瞭解非同步generator是如何工作的,它在我們的正式開發中又會遇到什麼樣的問題。
總結: 非同步的Generators是如何工作的呢
簡而言之,非同步的generators和普通的generator函式很像,但是它可以yield Promises。如果你想很瞭解ES2015的generator函式,那麼可以先去看一下Chris Aquino的部落格,再去看一下Jafar Husain的一篇非同步程式設計的很棒的演講
總的來說,普通的generator函式基本上就是一個迭代器和觀察者 模式的集合。generator是一個可以中止的函式,你可以通過呼叫.next()
來一步步執行。可以同通過.next()
來多次從generator輸出內容,也可以通過.next(valueToPush)
當然generators也有它的缺點:它在呼叫.next()
的時候必須立即(同步)返回資料。換句話來說,就是程式碼在呼叫.next()
的時候就需要得到資料。在generator需要時能夠生成新資料的情況下是可以的,但是沒有辦法處理迭代一個非同步的(或者臨時的)資料來源,它們需要自己控制在下一次資料準備好的時候執行下一次。
WebSocket訊息機制就是一個很好的非同步獲取資料的例子。如果我們已經接收到了所有的資料,那麼我們當然可以同步地遍歷它們。但是,我們也可能會遇到我們並不知道什麼時候會接收到資料,所以我們需要一個機制去等待資料接收完成後去遍歷。非同步generators和非同步迭代器可以讓我們做到這個。
**簡單的來說就是:**generator函式適用於資料可以被使用者控制的情況,非同步generators適用於允許資料來源本身控制的情況。
一個簡單的例子: 生成和使用AsyncGenerator
讓我們用一個例子來練習我們的非同步方案。我們需要編寫一個非同步的generator函式,它可以重複的等待一個隨機的毫秒數後生成一個新的數字。在幾秒鐘中時間裡,它可能會從0開始生成5個左右的數字。首先我們先通過建立一個Promise來建立一個定時器:
// 建立一個Promise,並在ms後resolves
var timer = function(ms) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
};
執行timer(5000)
會返回一個Promise,並且會在5秒後resolve。現在我們可以寫一個非同步generator:
// Repeatedly generate a number starting
// from 0 after a random amount of time
var source = async function\*() {
var i = 0;
while (true) {
await timer(Math.random() \* 1000);
yield i++;
}
};
如此複雜的功能卻可以寫的如此優雅!我們的非同步generator函式等待一個隨機的時間後yield
並減小i的值。如果我們沒有非同步generator,我們可以像下面一樣使用普通的generator函式,通過yield
Promises來實現:
var source = function\*() {
var i = 0;
while (true) {
yield timer(Math.random() \* 1000)
.then(() => i++);
}
};
當然,這裡還有一些特殊情況和引用需要我們處理,所以最好有一個專門的函式型別!現在是時候編寫使用程式碼了;因為我們需要await
操作符,所以我們將會建立一個非同步的run()
函式。
// 把所有都集合到一起
var run = async function() {
var stream = source();
for await (let n of stream) {
console.log(n);
}
};
run();
// => 0
// => 1
// => 2
// => 3
// ...
這是多麼神奇,只有20行不到的程式碼。首先,我們先運行了非同步generator函式source
,它返回了一個特殊的AsyncGenerator
物件。然後,我們使用一個語法上叫“非同步迭代”的for await...of
迴圈遍歷source
生成的物件。
但是我們還可以再改進一下: 假設我們是想要輸出source
生成的數字。我們可以在for await...of
迴圈裡面直接輸出它們,但是我們最好在迴圈的外面“轉換”stream 的值,像是使用.map()
一樣來轉換數組裡的值。它是如此的簡單:
// Return a new async iterator that applies a
// transform to the values from another async generator
var map = async function\*(stream, transform) {
for await (let n of stream) {
yield transform(n);
}
};
接下來我們只需要再往run()
函式中加一行程式碼就好了:
// Tie everything together
var run = async function() {
var stream = source();
+ // Square values generated by source() as they arrive
+ stream = map(stream, n => n \* n);
for await (let n of stream) {
console.log(n);
}
};
當我們執行 run()
就會輸出:
// => 0
// => 1
// => 4
// => 9
// ...
多麼感人啊!但是隻是用於計算數字有一點大材小用了。
中級例子: 在WebSockets中使用AsyncIterator(非同步迭代器)
我們一般是通過繫結事件來監聽WebSocket的資料:
var ws = new WebSocket('ws://localhost:3000/');
ws.addEventListener('message', event => {
console.log(event.data);
});
但是如果可以把WebSocket的資訊當做stream,這樣就可以用我們上面的辦法“iterate”這些資訊。不幸的是,WebSockets還沒有非同步迭代器的功能,但是我們只需要寫短短的幾行就可以自己來實現這個功能。我們的run()
函式大概的樣子如下:
// Tie everything together
var run = async () => {
var ws = new WebSocket('ws://localhost:3000/');
for await (let message of ws) {
console.log(message);
}
};
Now for that polyfill.你可能會回憶起Chris Aquino’s blog series中寫到的內容,一個物件要使用for...of
迴圈,必須要有Symbol.iterator
屬性。同樣的,一個物件要想使用for await...of
迴圈,它必須要有Symbol.asyncIterator
屬性。下面就是具體的實現:
// Add an async iterator to all WebSockets
WebSocket.prototype[Symbol.asyncIterator] = async function\*() {
while(this.readyState !== 3) {
yield (await oncePromise(this, 'message')).data;
}
};
這個非同步迭代器會等待接受資訊,然後會對WebSocket的MessageEvent
返回的資料的data
屬性進行yield
。oncePromise()
函式有一點黑科技:它返回了一個Promise,當事件觸發時它會被resolves,然後立即移除事件監聽。
// Generate a Promise that listens only once for an event
var oncePromise = (emitter, event) => {
return new Promise(resolve => {
var handler = (...args) => {
emitter.removeEventListener(event, handler);
resolve(...args);
};
emitter.addEventListener(event, handler);
});
};
這樣看上去有一點低效,但是證明了websocket的資訊接收確實可以用我們的非同步迭代器實現。如果你在http://localhost:3000 有一個執行的WebSocket服務,那麼你可以通過呼叫run()
來監聽資訊流:
run();
// => "hello"
// => "sandwich"
// => "otters"
// ...
高階例子: 重寫 RxJS
現在是時候面對最後的挑戰了。反應型函式程式設計 (FRP)在UI程式設計和JavaScript中被大量使用, RxJS是這種程式設計方式中最流行的框架。RxJS中模型事件來源例如Observable–它們很想一個一個事件流或者lazy array,它們可以被類似陣列語法中的map()
和filter()
處理。
自從FRP補充了JavaScript中的非阻塞式理念,類RxJS的API很有可能會加入到JavaScript未來的一個版本中。同時,我們可以使用非同步generators編寫我們自己的類似RxJS的功能,而這僅僅只需要80行程式碼。下面就是我們要實現的目標:
監聽所有的點選事件
過濾點選事件只獲取點選anchor標籤的事件
只允許不同的點選Only allow distinct clicks
將點選事件對映到點選計數器和點選事件
每500ms只可以觸發一次點選
列印點選的次數和事件
這些問題都是RxJS解決了的問題,所以我們將要嘗試重新實現。下面是我們的實現:
// Tie everything together
var run = async () => {
var i = 0;
var clicks = streamify('click', document.querySelector('body'));
clicks = filter(clicks, e => e.target.matches('a'));
clicks = distinct(clicks, e => e.target);
clicks = map(clicks, e => [i++, e]);
clicks = throttle(clicks, 500);
subscribe(clicks, ([ id, click ]) => {
console.log(id);
console.log(click);
click.preventDefault();
});
};
run();
為了使上面的函式正常執行,我們還需要6個函式:streamify()
, filter()
, distinct()
, map()
, throttle()
和 subscribe()
。
// 把所有的event emitter放入一個stream
var streamify = async function\*(event, element) {
while (true) {
yield await oncePromise(element, event);
}
};
streamify()
像是一個WebSocket非同步迭代器: oncePromise()
使用 .addEventListener()
去監聽事件一次, 然後resolves Promise. 通過while (true)
迴圈 , 我們可以一直監聽事件。
// Only pass along events that meet a condition
var filter = async function\*(stream, test) {
for await (var event of stream) {
if (test(event)) {
yield event;
}
}
};
filter()
會只允許通過test的事件被 yield
. map()
幾乎是相同的:
// Transform every event of the stream
var map = async function\*(stream, transform) {
for await (var event of stream) {
yield transform(event);
}
};
map()
可以簡單地在yield之前變換事件。distinct()
展示了非同步generator的其中一個強大的功能:它可以儲存區域性變數!
var identity = e => e;
// 只允許與最後一個不相同的事件通過
var distinct = async function\*(stream, extract = identity) {
var lastVal;
var thisVal;
for await (var event of stream) {
thisVal = extract(event);
if (thisVal !== lastVal) {
lastVal = thisVal;
yield event;
}
}
};
最後,強大的throttle()
函式和distinct()
很像:它記錄最後一個事件的時間,且只允許超過最後一次yield
事件一個確定的時間的事件通過。
// 只允許超過最後一次事件確定時間的事件通過。
var throttle = async function\*(stream, delay) {
var lastTime;
var thisTime;
for await (var event of stream) {
thisTime = (new Date()).getTime();
if (!lastTime || thisTime - lastTime > delay) {
lastTime = thisTime;
yield event;
}
}
};
我們做了這麼多,最後,我們還需要打印出每次的點選事件和當前的次數。subscribe()
做了一些零碎的事情:它在每一次事件迴圈的時候執行,並執行callback,所以沒有必要使用yield
。
// 每次事件到達都呼叫一次回撥函式
var subscribe = async (stream, callback) => {
for await (var event of stream) {
callback(event);
}
};
到這裡,我們已經寫了一個我們自己的反應型函式式管道!
你可以在這裡獲取到所有的例子的程式碼和要點。
挑戰
非同步generators是如此的優雅。而generator函式允許我們從迭代器中回去資料,非同步generators可以讓我們迭代“推送”過來的資料。這是多麼好的非同步資料結構的抽象。當然,也有一些注意事項。
首先,對一個objects增加支援for await...of
的功能有一些粗糙,除非你可以避免使用yield
和await
。尤其是,使用.addEventListener()
轉換任何東西都很棘手,因為你不可以在一個回撥中使用yield
操作:
var streamify = async function\*(event, element) {
element.addEventListener(event, e => {
// 這裡將無法執行,因為yield
// 不可以在一個普通函式中被使用
yield e;
});
};
同樣的,你也不可以在.forEach()
和其他函式型的方法中使用yield
。這是一個固有的限制因為我們不能保證在generator已經完成後不使用yield
。
為了繞過這個問題,我們寫了一個oncePromise()
函式來幫組我們。撇開一些潛在的效能問題,需要注意的是Promise的回撥總是在當前的呼叫堆疊結束之後執行。在瀏覽器端,類似microtasks一樣執行Promise的回撥是不會出現問題的,但是一些Promise的polyfill在下一次事件迴圈執行之前是不會執行callback。因此,呼叫.preventDefault()
函式有時候會沒有有效果,因為可能DOM時間已經冒泡到瀏覽器了。
JavaScript現在已經有了多個非同步流資料型別:Stream
, AsyncGenerator
和最後的Observable
。雖然三個都是屬於“推送”資料來源,但是在處理回撥和控制底層資源上還是有一些微妙的語義上的不同。如果你想了解更多關於反應函式式語法的細節,可以瀏覽General Theory of Reactivity.
更多
在程式語言的競賽中,JavaScript不是一個懶鬼。ES2015中的變數的解構賦值,ES2016中的非同步函式,而現在的非同步迭代器可以使JavaScript使用優雅的解決複雜UI和I/O程式設計的問題而不是使用充滿不可控的多執行緒方案。
除此之外,還有很多的新內容和新特性!所以請關注部落格和TC39 proposals repo來獲取最新的好東西。同時,你也可以通過在Babel中開啟Stage 3 提案的方式在你的程式碼中使用非同步generator函式。
你是否有興趣學習網頁平臺的下一代的JavaScript? 歡迎來我們的前端訓練營, 或者 我們可以提供企業培訓g!