前言
JavaScript 2015中引入了Generator Function(相關內容可以參考前作ES6 generator函式與co一瞥與ES6 generator函式與co再一瞥),並且在加入了Symbol.iterator
之後,使得構造擁有自定義迭代器的集合變得相當容易(可以參考前作在JavaScript中實現LINQ——一次“失敗”的嘗試)。
前幾天在群裡@徐叔提出了這樣一個問題:
1 2 3 4 5 |
function* listen(element) { element.addEventListener('click', function(e) { // 這裡怎麼把e通過外面的listen給yield出去? }) } |
音錘思婷……
我理解,叔叔寫listen
的目的是為了把事件源抽象成一個“可以被遍歷的集合”。
JavaScript裡的迭代器模式
要理解JS裡的迭代器模式,首先必須從GeneratorFunction
和Symbol.iterator
說起。
JS的迭代器模式和C#有些許不同(原諒我經常用C#力的介面來做例子,其實只是因為我覺得它這些介面設計得比較工整良好,而且強型別語言也挺適合做例子),C#中使用兩個介面IEnumerable
和IEnumerator
來實現迭代器模式,分別定義為
1 2 3 4 5 6 7 8 |
public interface IEnumerable<T> { IEnumerator<T> GetEnumerator() } public interface IEnumerator<T> { T Current { get; } bool MoveNext() // 省略其他無關緊要的 } |
實現了IEnumerable
的型別可以享受到foreach
語法糖,foreach
展開後就是通過對IEnumerator
不斷地MoveNext()
來完成迭代過程,這很好理解。
JS的迭代器模式圍繞Symbol.iterator
,任何物件只要實現了Symbol.iterator
就可以享受for-of
語法糖。
在迭代過程方面,C#只用IEnumerator
一個介面同時實現了迭代和取值兩個操作,但JS裡用了兩個介面,這裡舉個例子
1 2 3 4 5 |
var array = [1, 2, 3, 4, 5] var iter = array[Symbol.iterator]() for (var it = iter.next(); !it.done; it = iter.next()) { console.log(it) } |
可以看到呼叫Symbol.iterator
所得到的iter
物件只是負責next()
工作,而其不斷next
所得到的it
物件則負責value
和done
工作。
也就是說,在不借助yield
的情況下,要實現Symbol.iterator
只需要構造一個滿足上述介面的物件就OK了,舉個例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
var fakeArray = { _values: [1, 2, 3, 4, 5], [Symbol.iterator]() { var _values = this._values var _index = 0 var iter = { next() { var it = { value: _values[_index], done: _index >= _values.length } if (!it.done) { _index++ } return it } } return iter } } for (var n of fakeArray) { console.log(n) } |
然後我們嘗試一下,能不能用yield *
語法來實現它和Generator
的無縫銜接:
1 2 3 4 5 6 7 8 9 |
function* gen() { yield '1-1' yield '1-2' yield* fakeArray yield '1-3' } for (var t of gen()) { console.log(t) } |
耶,成功了,解糖後手工遍歷呢?
1 2 3 4 |
var iter = gen() for (var it = iter.next(); !it.done; it = iter.next()) { console.log(it) } |
用迭代器模式實現事件源是否可行
先說結論,我認為是:僅從上面所討論的範圍來看,不可行。
使用迭代器模式,無外乎是為了能工用for-of
語法(或者解糖以後自己不斷next()
)來遍歷集合。我們知道迭代器模式是一種典型的“Pull”模型,迭代過程是不斷從集合裡把東西拉出來,直到什麼都拉不出來了(怎麼聽起來這麼膈應)。
事件源是一個非同步的東西,只有當事件發生的時候才會有貨,但我們並不知道事件什麼時候發生,因此當被“拉”的時候,不知道該把什麼東西交給迭代器。
這時候有同學要問了,之前我們不是用co通過yield
來處理非同步的東西嗎,這不是證明yield/generator
是可以處理非同步問題的嗎?
其實只要看過我之前文章或者對co有了解的同學肯定就會知道,co是對yield/generator
的“誤用”,我之所以加引號是因為在Unity的C#裡甚至官方就直接用yield
和IEnumerator
來實現了官方的協程API(我就不吐槽了您趕緊把C#版本升級了用async/await
吧),據我瞭解Python也有這麼幹的。這說明這個“誤用”是一個有據可循的東西。
在co這樣的語境下,yield/generator
已經完全不是為了構造自定義集合以及配合for-of
語法糖實現迭代器模式而用的,所以我們費了老鼻子勁實現的Symbol.iterator
到底還有沒有卵用?
我要說,如果跳出上面所討論的範圍來看呢,還是有點兒卵用的。
“黑化”之後的產物
我們先設定一個“目標語法”
1 2 3 4 5 6 7 |
function* eventListeningByCoroutine() { var eventSource = someMagicFunction() while (true) { var e = yield eventSource.take() document.querySelector('#logger').innerHTML = e.pageX + ', ' + e.pageY } } |
看到沒,用一個while (true)
,死命地從eventSource
里拉東西出來,由於這個拉的過程是不確定(非同步)的,我們只好加了yield
。
所以現在模型建立了,我們剩下兩個問題,一個是someMagicFunction
如何實現,一個是startCoroutine
如何實現。
如果看過我之前寫的ES6 generator函式與co再一瞥,嗯,也可以起一個新名字,叫做《手把手教你實現一個山寨的co),那麼應該很快就能寫出上面的startCoroutine
函式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
function startCoroutine(generatorFunction) { var iter = generatorFunction() function step(data) { var it = iter.next(data) if (it.done) { return } var callback = it.value callback(function(val) { step(val) }) } step() } |
具體過程就不展開分析了,呃,我的意思是大概這樣↓
然後更關鍵的是someMagicFunction
怎麼實現
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
function someMagicFunction() { var taker var iter = { take: function() { return function(callback) { taker = function(e) { callback(e) } } } } function put(e) { if (!taker) { return // dropped } var _taker = taker taker = null // cleaning up _taker(e) } document.querySelector('#main').addEventListener('click', function(e) { put(e) }) return iter } |
完整演示在這裡runjs/yzbro1a1。
嗯,其實我就是劣質地抄了一個js-csp,它是一個CSP(Communicating sequential processes)的實現,相當於Clojure裡的core.async
和Go裡的chan
。這裡的例子也基本就是js-csp的其中一個例子的簡化版而已。
在CSP中,事件源被抽象為一個channel
(或者像erlang裡好像叫mailbox之類的,很形象),發生事件的時候往裡面put
,監聽事件這個事情體現為源源不斷地(while-true)從裡面take
——注意,這個take
是一個“阻塞”操作,體現為它必須冠以yield
。
與Observable
(RxJS)對比
從上面可以看到,只靠迭代器模式是不能用來抽象非同步事件源的(至少吧,以我當前的理解能力,是不能的)。
本質上是因為迭代器模式使用的是“Pull”模型,什麼時候發生迭代完全是由迭代者本身什麼時候去“拉”資料決定的;而觀察者模式是“Push”模型,什麼時候發生迭代是由資料來源本身決定的,這也使得它非常適合“事件流”、“訊息推送”這類的持續、非同步資料的迭代,也就是所謂的“Reactive Programming”。
那為什麼最後的DEMO就用更類似“Pull”的方式實現了呢?因為startCoroutine
和someMagicFunction
這兩者之間實現了訊息傳遞,startCoroutine
接管了yield
和迭代中“什麼時候該next()
”的過程,someMagicFunction
向反過來向它傳送“你可以繼續拉了”的訊息(注意:上面的例子中實現為回撥函式),這倆一推一拉,好不默契(???
值得注意的一點是不論CSP還是Observable都會存在一個“什麼時候push”的問題,在RxJS和js-csp中,體現為它們有一個Scheduler的存在,在RxJS中它決定subscribe
什麼時候被髮射,在js-csp中它決定taker
什麼時候被滿足。RxJS內建的Scheduler就有諸如Rx.Scheduler.immediate
, Rx.Scheduler.currentThread
, Rx.Scheduler.default
等好幾種,並且對於不同的Observable它根據策略會預設選擇不同的Scheduler。
當然最後實現了一個劣質的CSP的DEMO,也算填了一個我兩年前學習Go以及第一次看到js-csp的時候就開的坑——是啊,在我腦海裡開了坑,但沒敢告訴你們,免得你們又吐槽我挖坑不填(逃