一、Reactive?
請先看一個非常簡單的小應用,它允許用戶在一個搜索輸入框里輸入關鍵詞,然后在其下方的結果區域實時顯示從Flicker網站搜索得到的圖片,當用戶輸入的關鍵詞發生變化,顯示的圖片也會隨即跟着發生變化。

這實際上便是一種reactive能力。而類似這種能自動對外部環境的變化作出響應的系統我們稱之為反應型系統(Reactive System)。典型的外部環境變化包括外部輸入信號的變化、事件的發生,而且系統的響應通常是實時的。互動系統是最重要的一種反應型系統,它不但能響應外部環境的變化,還會將自己的內部狀態通過某種方式反饋給外部觀察者。
二、如何構建Reactive System?
如果要開發上面的這個Flicker實時搜索小應用,應該怎么做呢?
別忙,程序員在動手開發之前應該首先把軟件要做什么盡可能搞清楚。雖然這是個很小的應用,但我們仍然可以把它做的事情用一個簡單但更清晰的流程圖表示出來:

用戶在輸入框鍵入各種關鍵詞向Flicker發出搜索請求,它們就像一道不斷流向Flicker服務器的”請求流”,服務器處理完請求后向客戶端返回搜索結果,形成了一道“應答流”,客戶端將“應答流”轉換成“圖片流”注入到網頁中的結果區域。只要用戶的輸入發生變化,這些流中的內容也跟着改變,網頁中顯示的圖片也就跟着變化。
功能不復雜,試着把它變成程序。
Callback Style
通過處理相應的事件便能響應用戶的輸入,那么就可以采用大家所熟悉的注冊event callback的方式來做:
//處理response的回調函數
var responseHandler = function (answer)
{
//根據response創建image nodes
var nodes = buildImages(answer);
//用image nodes動態更新顯示區域
swapChildren($('images'), nodes);
}
//搜索框回調函數
var searchHandler = function (txt)
{
//發送request,並注冊該次request的回調
requestFlickerSearch(txt, responseHandler);
}
//使指定的輸入框具有實時flicker搜索能力
function enableFlickerSearch(nodeId)
{
//注冊搜索輸入框回調
registerCallback($(nodeId), searchHandler);
}
enableFlickerSearch('search');
X Style
上面這種方式是大家最熟悉的,也是最常用的。但今天我要給大家看看另外一種不同的方式,暫時稱之為X Style吧。
核心代碼如下:
//將指定的輸入框包裝成具有flicker search能力的輸入框,
//並返回相關聯的實時圖片流
function makeFlickerImagesStream(searchEditId)
{
//根據指定的搜索輸入框內容創建request stream
var requestE = extractValueE(searchEditId).
calmE(1000).
mapE(flickrSearchRequest);
//根據request stream創建response stream
var responseE = getForeignWebServiceObjectE(requestE);
//根據response stream創建image DOM stream
var imagesE = responseE.mapE(createImageNodes);
return imagesE;
}
function start()
{
//創建一個實時圖片流,流中的內容根據"search"輸入框內容的變化而變化
var imageNodes = makeFlickerImagesStream("search");
//用imageNodes圖片流里的內容動態更新images節點
insertDomE(imageNodes,"images");
}
注意到makeFlickerImageStream的實現里有requestE, responseE, imagesE這樣的變量,從名字上很容易將它們和之前流程圖中的“請求流”“應答流”“圖片流”對應起來。
目前為止,這兩種方式看起來代碼量差不多,沒感覺X風格有什么特別的。 好,假設這個Flicker實時搜索框被做成了獨立的控件,對於使用者來說是一個黑盒子,內部實現不可見,如果想添加一個黃色圖片過濾功能,不是一古腦兒顯示搜索得到的所有圖片,而是過濾掉黃色圖片只在結果區域顯示健康圖片,這時該怎么做?
對於callback風格的實現來說,應用代碼只有這一行,其它的都不可見。
enableFlickerSearch('search');
你能想到一種不修改搜索框控件內部實現的方案嗎?至少我沒有想到。
一個比單純修改模塊內部實現更好的方案也許是同時修改接口和控件實現以便讓用戶去定制如何處理response:
enableFlickerSearch('search', customResponseHandler);
那么用戶可以在這個定制的處理函數中先過濾圖片再顯示出來。
對於X風格實現,start函數才是應用層的代碼。如果要過濾掉黃色圖片,很簡單,只要將最后一行代碼改變一下就行了:
function start()
{
//創建一個實時圖片流,流中的內容根據"search"輸入框內容的變化而變化
var imageNodes = makeFlickerImagesStream("search");
//用指定的健康圖片判斷函數healthy
//對imageNodes進行過濾得到健康圖片流
var healthyImageNodes =
imageNodes.filterE(healthy);
//用過濾得到的健康圖片流里的內容動態更新images節點
insertDomE(healthyImageNodes,"images");
}
看見了嗎?完全不需要修改控件內部的實現就輕松實現了新功能。
三、另外一個例子-Drag and Drop
現在請看另一個鼠標拖拽的小例子。
對於這樣一種交互操作,如果拋開實現細節,用自然語言通常會怎么描述它呢?我想極有可能是這樣:
當鼠標左鍵按下時便開始移動拖拽,直到鼠標左鍵抬起時停止。
這句話實際上定義出了拖拽交互的功能規約(function specification),有了規約便可以寫出代碼。
這次先來看看X風格的代碼實現:
function start() {
//為節點創建和它綁定的拖拽消息流
var posE = dragE($('dragTarget'));
//根據拖拽消息去調整節點的位置
posE.doE(function(m) {
$('dragTarget').style.left = m.clientX;
$('dragTarget').style.top = m.clientY;
});
}
這段代碼很簡單,注釋里的解釋應該清楚了。但是,最關鍵的用來創建拖拽消息流的dragE是怎么實現的呢?它在這里:
function dragE(target)
{
//鼠標左鍵按下消息流
var mousedown = extractEventE(target,'mousedown');
//鼠標左鍵抬起消息流
var mouseup = extractEventE(document, "mouseup");
//鼠標移動消息流
var mousemove = extractEventE(document, "mousemove");
//返回一個拖拽消息流:
//每當有鼠標左鍵按下消息時它就輸出鼠標移動消息,
//直到鼠標左鍵抬起才停止輸出鼠標移動消息,
//當下一次鼠標左鍵按下時又重復以上模式。
return mousedown.thenE(function (_)
{
return mousemove.untilE(mouseup);
});
}
這些代碼暫時看不懂沒關系,只要注意最后return出去的表達式中的thenE和untilE,這和之前自然語言描述中的“當...發生時便開始... ,直到....”一一對應,代碼就象對自然語言的直接翻譯!
如果換用callback的方式怎么做呢?這里就不寫了,讀者可以自己嘗試一下,看看最終出來的代碼的語義和自然語言的描述差別有多大。
四、Functional Reactive Programming
上面兩例中X風格的代碼主要描述系統中數據(消息)流的結構關系,至於環境變化如何導致某些數據流變化,這些變化了的數據流又如何導致其它的相關數據流變化,壓根兒不需要關心。這是一種編程范式,稱之為Reactive Programming。
而主要利用函數式編程(Functional Programming)的思想和方法(函數、高階函數)來支持Reactive Programming就是所謂的Functional Reactive Programming,簡稱FRP。
現在可以給X正名了,它就是FRP。
FRP vs. Callback
程序員在寫代碼之前,首先會需要一個功能規約,而規約的描述形式並不固定,可以是流程圖(第一個例子),可以是自然語言(第二個例子),也可以是其它的方式,只要能精准方便地表達意圖就好。
當有了規約之后,FRP編程就像照鏡子,代碼基本上就是規約的直接映射。

而寫callback風格的代碼,就得費力地咀嚼規約,艱難地消化,最后擠出的代碼就像一坨纏來繞去的線團,它呈現出來的樣子和程序真正的意圖相去甚遠。

為什么?
為什么callback風格的代碼會表現成這樣,背后的原因是什么?請讀者帶着這個問題邊思考邊往下看,在文章的后面作者會給出自己的結論。
五、Flapjax
本文的所有示例都利用了一個叫Flapjax的庫。簡單來說,它就是一個支持FRP的JavaScript框架。接下來將以它為參考詳細介紹FRP的各種概念。
1、基本思想
引起實時互動系統發生變化的最根本的自變量是時間,變化的環境和各種事件(比如鍵盤、鼠標、網絡)歸根結底都是由時間的變化引起的,時間是本質特征,從一開始就要考慮。
那些對外部環境具有反應能力、隨時間變化的數據被抽象為一種叫信號(Signal)的概念。
2、信號
信號分為兩種,分別叫事件流(Event Stream)和行為(Behavior)。
Event Stream

事件流可以看成時間軸上無限長的數據流,這個里面流淌的數據代表着一個個事件,它們在時間軸上是離散的。
Behavior

行為也是隨時間變化的數據,它在時間軸上是連續的。
信號的類型
信號(EventStream和Behavior)都是First-Class Value,所以它們也有自己的類型。 信號所代表的隨時間變化的數據的類型X決定了信號的類型。記為:EventStream X 或 Behavior X。
比如:
- 代表輸入框內容變化的事件流,類型為EventStream String
- 隨時間不停變化的數值,類型為Behavior Number
函數角度看信號
如果從函數式編程的角度來看,behavior可以看成這樣一個函數:輸入某個時刻,它計算得到在那個時刻自身的采樣值。

同樣地,event stream也可以看成這樣一個函數:輸入也是某個時刻,如果在這個時刻有事件發生就把相應的event數據返回出來,否則就返回一個特殊的nothing表示沒有任何事件發生。

3、Event Stream
基本API
//為指定DOM節點創建鼠標左鍵按下的事件流
es1 = extractEventE(targetDom, "mousedown");
//為指定DOM節點創建內容動態更新事件流,
//每當節點內容發生變化,這個事件流里都會產生相應的事件
es2 = extractValueE(searchEdit);
//創建會且只會發生一個事件的事件流,
//這個唯一的事件所攜帶的信息是調用者傳入的參數
es3 = oneE("hello world");
//永遠也不會有事件發生的事件流
nothingEs = zeroE();
//將事件流中的動態內容注入到指定的dom節點上,
//此后只要事件流里有新的事件發生,
//這個節點的內容就會跟着新事件的內容自動發生變化。
insertDomE(es2, "result");
轉換和組合
除了基本API,框架還提供了相應的API對事件流進行轉換或者將多個事件流組合成新的更復雜的事件流。常用的有:
-
過濾
filterE(sourceEs, pred)會用謂詞函數pred對事件流sourceEs進行過濾生成一個新的事件流,所有出現在sourceEs並且被pred判斷為true的事件都會出現在新生成的事件流中。
-
映射
mapE(f, sourceEs)會用轉換函數f對事件流sourceEs進行轉換生成一個新的事件流,所有出現在sourceEs中的事件都會被f轉換成新的事件並出現在新生成的事件流中。
-
合並
mergeE(es1, es2)會把兩個事件流es1和es2合並成一個新的事件流,所有出現在es1或者es2中的事件都會出現在新生成的事件流中。
-
直到
es1.untilE(es2)會根據兩個事件流es1和es2生成一個新的事件流,es1出現的所有事件都會出現在新生成的事件流中,直到es2的第一個事件發生,此后結果事件流中將不再發生任何事件。就好像es2中的事件是一個永久的阻斷信號,它一旦出現,意味着此后es1中的事件都被阻斷不再進入結果事件流中。
-
當...發生時便...
srcEs.thenE(f)會根據事件流srcEs和函數f生成一個新的事件流,這里的f必須是一個輸入為event輸出為EventStream的函數。每當srcEs中出現一個事件,都會把這個事件傳給f計算得到一個臨時事件流,接下來這個臨時事件流中的事件都會出現在最終的結果事件流中,直到srcEs中再次有新的事件發生,又會重新調用f得到另一個臨時事件流並替換掉老的,同時結果事件流開始接收這個最新產生的臨時事件流中的事件。srcEs中不斷有事件發生,上述過程也就不斷重復。在前文鼠標拖拽的例子中,正是這個方法使得拖拽事件流的表達非常接近自然語言的描述。熟悉FP的讀者一定已經發現,如果把事件流看成是[Monad](http://en.wikipedia.org/wiki/Monad(functionalprogramming))的話,那么thenE就相當於它的bind操作。實際上在Flapjax里,這個方法的名字確實是叫bindE,為了使代碼的可讀性更好,作者對Flapjax做了小小的修改和擴展,為bindE取了個別名thenE。本文所有示例使用的都是這個定制版Flapjax。
4、Behavior
基本API
//最基本的behavior就是時間本身!
//timerB創建一個表示系統時間值的behavior,
//輸入參數表示它每隔多長時間(毫秒)更新一次
behavior1 = timerB(1000);
//創建一種特殊的常量型behavior
//任何時刻它的值都等於你傳進去的值
cb = constantB("老子是個常量");
//valueNow獲取behavior在當前時刻的值
v = valueNow(behavior1);
//為指定DOM節點創建相關的behavior,
//它的值會自動隨着節點的內容變化而變化
domb = extractValueB(dom);
//把behavior的動態內容注入到dom節點上,
//此后只要behavior的值發生變化,
//這個dom節點的內容就會自動發生變化。
insertDomB(behavior1, "timer");
Lift
Number類型的數據有加減乘除等運算操作,和它對應的Behavior Number類型的行為也有相應的addB、subB、mulB、divB這些運算操作。比如這樣一個調用
b3 = addB(b1, b2);
就會根據b1和b2這兩個行為生成一個新的行為b3,並且任何時刻b3的值都會等於同時刻b1和b2的值之和。其它運算符的語義可以依此類推。有了這些操作后就意味着behavior是可以直接運算的!這使得我們可以像寫普通運算表達式那樣方便地創建復雜行為。
addB可以看成是把+這個原本作用在Number類型數據上的運算提升(lift)后得到的作用在Behavior Number類型上的運算,其它運算符同此類比。

Flapjax提供了lift這個API,它能將計算普通值的函數提升為計算相應behavior的函數。比如標准數學庫中的sqrt函數的類型是輸入參數為Number類型輸出結果為Number類型:
Math.sqrt: Number -> Number
它的提升版本(lifted version)可以這樣得到:
sqrtB = lift(Math.sqrt);
得到的sqrtB是一個新的函數,類型是輸入參數為Behavior Number輸出結果為Behavior Number:
sqrtB: Behavior Number -> Behavior Number
很明顯,lift是一個輸入參數為函數輸出結果也為函數的高階函數。
接下來就可以直接調用sqrtB:
//b1是個behavior,b2是調用生成的新的behavior, //b2在任何時刻的值都等於b1同時刻值的平方根。 b2 = sqrtB(b1);
實際上之前的addB的定義是這樣的:
addB = lift(function (v1,v2){
return v1 + v2;
});
請看這個演示,它通過以上介紹的方式定義了多個behavior,並將它們在網頁上呈現出來。
5、更多的信號和組合操作
Flapjax還提供了更多的信號:
mouseLeftB mouseTopB getWebServiceObjectE(requestE) ......
更多的組合操作:
collectE calmE switchB condB ......
具體請查閱參考手冊。
六、FRP的優勢
在FRP的編程模型里,基本信號可以在不做任何修改的情況下被轉換或者組合成新的復雜信號,而新的復雜信號又可以在不做任何修改的情況下被轉換或者組合成更復雜的信號,就像樂高積木的搭建,這個過程可以一直進行下去,直到構建出足夠復雜的信號以滿足系統的需求。

這正是程序員夢寐以求的組合能力(Composability)!
現在重新回到前文提出的那個問題,為什么callback風格的代碼總是像一坨線團一樣那么地雜亂?讓我們先來看看一個通用的event-driven框架大概是什么樣的:

請讀者回想一下,程序中注冊的事件回調處理函數的返回值一般都是什么類型?想必要么是void要么是一個表示執行狀態的狀態碼,不太可能讓返回值可以隨意使用各種數據類型,這是因為一個事件驅動框架要通用地處理各種callback,就只能讓它的返回值類型足夠通用,void便是首選。因此,回調與回調之間是無法直接傳遞類型豐富的數據的,它們只能通過修改應用程序的共享狀態來間接地通訊,這迫使程序員不得不把應用邏輯分割得支離破碎,從而喪失了核心的組合能力!這便是callback風格程序的最大問題。
Callback模型關注控制流,但它對控制流的描述不具有很好的組合性。FRP模型換了一個視角,關注數據流,且數據流的組合能力極佳,使得代碼更接近於只描述做什么(what)的聲明式(declarative)代碼,而不是描述怎么做(how)的命令式(imperative)代碼,相當簡潔和直觀,更符合人的自然思維!
七、實現簡述
實現一個FRP框架最關鍵的是要在信號之間合理有序地傳播變化,通常分為兩種做法:
1、Push方式
信號主動把變化傳播給受影響的其它信號。
- 優點:外部的變化是同步處理的,系統的反應延遲小。
- 缺點:
- 所有構建出來的信號不管程序是否真的需要,都會參與更新和計算,有可能存在大量無用功;
- 無用信號必須顯式地從數據流網絡中刪除才有可能被回收,資源管理較麻煩,常常造成資源泄漏。
2、Pull方式
信號主動去查詢可能會影響到自己的信號是否發生了變化,如果發生變化就進行重計算。
- 優點:
- 按需計算,只有真正被程序需要(即被取值)的信號才參與更新和計算。
- 信號在程序里沒有了任何引用就會被自動回收掉,資源管理簡單。
- 缺點:
- 外部的變化采用異步處理,系統的反應延遲依賴於pull的頻率。
- 當沒有變化發生時,仍然會以固定頻率pull,也存在一定的消耗。
Flapjax采用的是push的方式。
八、其它的FRP框架
FRP最早發源於Haskell社區,在Haskell社區里有許多關注點不同實現方式各異的FRP框架,比如:
- Fran
- Yampa
- Reactive
- ....
FrTime是用Racket(Scheme)語言實現的一款FRP框架,它背后的團隊和Flapjax的團隊是同一個,所以它們在理念、設計和實現上都極為相似。
LuaTime是筆者在幾年前為Lua語言實現的FRP框架,它在API的設計上主要參考了FrTime,但是底層的實現采用了pull的方式。這個項目計划在今年年內開源。
九、相關研究
- Microsoft Reactive Extensions
微軟用於解決實時互動、異步編程問題的跨語言的開發框架,基本思想和FRP極為類似:將變化的數據抽象出來,稱之為IObservable,並為其定義各種轉換和組合操作。它背后的主要設計者是Haskell社區的大牛。 - Arrowlets
FRP關注數據流,Arrowlets和callback一樣關注控制流,但它利用[Arrow](http://en.wikipedia.org/wiki/Arrow(computerscience) )這種計算模型使得控制流具備了很好的組合能力。 - Promise or Future
在JavaScript社區中很流行的對異步編程的解決方案,有無數的實現庫。 - Sandglass
筆者所在團隊設計的一種基於Behavior Tree模型的AI編程語言,為Behavior Tree引入了協作式多任務機制和顯式的時間控制機制,支持以同步化的思維來寫異步程序,能編譯成標准的JavaScript。
參考鏈接:http://www.infoq.com/cn/articles/functional-reactive-programming
