對Cycle.js 的一些思考

blank

對Cycle.js 的一些思考

本篇文章主要是介紹Cycle.js,然後談一談這兩個多月來使用Cycle 進行開發的一些思考和總結。文章觀點較多(也許也有點雜),代碼也較多,總體來說比較乾。

Cycle.js是個不一樣的框架。 Cycle 完全使用響應式編程作為編程範式,框架的核心依賴於xstream,而基於該框架的應用也必須從xstream/most/RxJS 三個Observable/Stream 庫挑選一個。 Cycle 擁抱函數式編程,應用代碼不直接執行副作用,而是輸出「描述對象」通知「驅動」來執行副作用。

Cycle 可以分為兩個部分:

  1. 定義部分:定義了應用的輸入/輸出格式,定義了「應用以run(App, drivers)的方式運行」
  2. 模塊部分:@cycle/run核心模塊,提供run函數;以及@cycle/dom、@cycle/http等驅動模塊。

這兩個部分中,我個人認為定義部分較為重要。通過本文後面的分析,可以發現,從定義部分出發,模塊部分的出現是非常合理的:官方模塊提供了一些常用的API 和副作用驅動,實現了不同Cycle 應用都需要的共同功能,例如官方模塊@ cycle/dom 提供了執行「更新DOM 的副作用」的服務,而@cycle/http 提供了執行「HTTP 請求的副作用」的服務。

Cycle對於應用的抽像如下:應用函數( main )是一個純函數,其中輸入( sources )是來自外部的讀副作用,輸出( sinks )是對外部的寫副作用。在程序外部的所有I/O副作用都是由驅動( drivers )管理,比如處理DOM副作用和HTTP副作用。

blank

Cycle與其他框架的不同點就在於輸入和輸出都使用「流」作為容器。下面我們就從不同方面來分析「流」的引入對應用開發思路的改變。 (如果對流還不是很熟悉,可以看看xstreamRxJS的文檔。)

用流定義應用輸出

當應用運行之後,應用是不斷變化的。對於前端頁面應用來說, window.document ,或者說真實DOM的狀態是最重要的,因為它決定了頁面上顯示的元素,決定了用戶看到的內容。我們將真實DOM 看作一個流:在應用生命週期中任意一個時刻觀察該流,我們可以看到真實DOM 在此時刻上的狀態;而如果從整個生命週期來看,這個流又像是一個數組,該數組按時間順序記錄了真實DOM 的每一個狀態。注意我們的容器是可以跨越時間的,雖然頁面中只會存在一個document對象,但容器仍然記錄了不同時間點document的狀態。如果將整個應用看作一個函數,那麼該函數的任務便是生成該DOM 流。除了頁面內容,一個頁面也會有其他類型的輸出,例如聲音輸出、通知輸出等,我們將不同類型的輸出稱為「在不同channel 上的輸出」(sinks on different channels),此時我們的應用的任務變為了「為每個channel 生成一個流」。

interfaceNaiveSinks{DOM: Stream<Document>sound: Stream<Sound>notification: Stream<Notification>HTTP: Stream<HTTPRequest>}

Virtual DOM的引入

前面DOM字段對應的類型為Stream<Document> ,這意味著我們需要對window.document進行原地修改,然後在不同時刻將該對象emit出去,換句話說,我們需要手動維護真實DOM。當頁面複雜的時候,手動維護真實DOM 既繁瑣又容易出錯,且副作用的引入以及對全局對象的依賴導致代碼難以測試和理解。現在很多流行的框架(React、Vue)都引入了Virtual DOM,Cycle 也一樣,通過官方模塊提供了Virtual DOM。在引入了@cycle/dom模塊之後,我們可以按照需求創建DOM channel的驅動,一般稱創建的驅動為domDriver 。 domDriver是一個函數,其接受一個虛擬文檔對象(vdom)的流為參數,該函數的邏輯如下:每當vdom 流emit 一個vdom 對象,該函數會負責調用相關DOM API,將真實DOM 修改為此時vdom 指定的狀態。

引入Virtual DOM 之後,應用可以避免陷入瑣碎的DOM 增刪改查之中,而更專注於所要展現的內容本身。應用的任務從「生成真實DOM 流」變為了「生成虛擬DOM 流」。類似的,其他channel 也需要有對應的驅動以執行副作用。一個channel 的驅動往往是高度可複用的(例如domDriver 的接口非常簡單,但是通過該驅動,應用可以在頁面上繪製任意的用戶界面),大部分常見的channel 的驅動已經有官方模塊提供。

我們修改Sinks 類型簽名,如下:

interface Sinks { DOM : Stream < VNode > // VNode是vdom的类型名称// 这里就暂时不考虑其他channel 输出,例如HTTP: Stream<HTTPRequest> }

從run函數的視角看應用輸出

當我們的頁面開始運行時,run 函數開始運行,其調用應用函數,拿到應用返回的sinks,將sinks.DOM 作為參數調用domDriver 函數。這樣,每當虛擬文檔流emit 一個vdom 對象時,domDriver 都會對真實文檔對象進行相應的更新,頁面就會展示新的內容,所有的DOM 副作用都在domDriver 中執行,一切運行得相當完美。其他channel 上的應用輸出和DOM channel 擁有類似的流程。

用流定義應用輸入

我們同樣使用流來作為應用輸入的容器。這裡我們以前端應用中最常見的鼠標點擊為例,使用流來抽象「所有的鼠標點擊事件」是比較直觀的,每當用戶進行鼠標點擊時,就會有一個MouseEvent 對像在事件流中被emit出來,該對象包含了這次鼠標事件的所有訊息,包括鼠標所點擊的元素、鼠標的按鍵等。這些事件流將會作為應用的輸入。

誰來生成應用的輸入?

應用在某個channel 的輸入由「該channel 相應的driver 」進行提供。例如,鼠標點擊事件、鼠標移動事件來源於真實DOM,而真實DOM 由DOMDriver 進行維護,故這些鼠標事件流需要由DOMDriver 進行生成。

這樣我們的DOMDriver 的大致函數簽名如下:

interfaceNaiveDOMDriver{(vdom$: Stream<VNode>):Stream<Event>}

lazy & queryable

一個應用只會對滿足特定條件的鼠標事件感興趣,例如一個簡單的計數器應用只對「+1」「-1」這兩個按鈕上的左鍵點擊事件感興趣。而對於DOMDriver 來說,因為不知道具體的應用會對哪些事件感興趣,所以該驅動仍然需要生成潛在的所有可能的事件流。生成一個「所有事件的流」顯然是一種低效的做法,例如鼠標移動時mousemove 事件產生的頻率非常高,但大部分的應用從來不關心鼠標移動事件,如果驅動生成了這樣一個鼠標移動事件流,那麼該流很可能沒有任何消費者。

DOMDriver 採取了性能更優的事件流生成機制,該機制有下面兩個特性:

  • lazy:事件流只在被需要的時候才進行構建;
  • queryable:該機制允許不同應用根據需求以查詢的方式來獲取相應的事件流。

DOMDriver 並沒有直接返回一個流對象,而是返回一個DOMSource 對象。一個DOMSource 可以代表「應用渲染得到的頁面的一部分」

  • 調用domSource.elements()可以獲取頁面中DOM節點流
  • 調用domSource.events('click')可以獲取該頁面部分中點擊事件流
  • domSource.select('some-css-selector')表示執行CSS選擇器以選取頁面的部分子結點,該函數返回一個新的DOMSource對象,該對象代表了範圍縮小之後的頁面元素。

應用獲取到DOMSource 之後,根據實際需求調用其API 來獲取所需要的事件流。至此,應用輸入輸出和DOMDriver 相關接口類型定義如下:

interfaceSources{DOM: DOMSource}interfaceSinks{DOM: Stream<VNode>}interfaceDOMDriver{(vdom$: Stream<VNode>):DOMSouce}

從run函數的視角看應用與驅動

從上面的類型定義可以看出,應用的DOM channel與DOMDriver恰好構成一個循環( cycle ,這也是Cycle.js名字的由來),一方的輸入恰好是對方的輸出。而我們的框架核心,也就是run 函數,代碼大致如下:

// 我们往往这样调用run函数// run(App, { DOM: makeDOMDriver('#app') }) function run ( App , { DOM : domDriver }) { // 新建一个vnode代理,「假装」虚拟文档流已经获取const vnodeProxy$ = xs . create () const domSource = domDriver ( vnodeProxy$ ) const sinks = App ({ DOM : domSource }) // 让代理模拟应用函数返回的虚拟文档流,也可以认为是「用应用返回的vnode流替换刚才假冒的代理」 vnodeProxy$ . special_imitate ( sinks . DOM ) }

上面的run 函數可以認為是@cycle/run 模塊提供的run 函數的簡化版。實際版本比較複雜,且可以處理所有channel 上的輸入輸出,簡化版本只考慮了DOM channel。

重新回顧

從上面的分析可以看出,如果我們基於Cycle 的定義來開發前端應用,那麼@cycle/run 和@cycle/dom 提供的功能正是我們需要的。

優美特性

Souces 和Sinks 是Cycle 應用和驅動之間的接口,同時也是父組件和子組件之間的接口。每一個Cycle 應用都可以像下面這樣,成為另一個應用的子組件。

blank

基於Cycle 對應用的定義,Cycle 中組件和應用並沒有多大的區別,而組件也只是一個輸入流到輸出流的映射函數而已。所以如果一個函數符合Sources 和Sinks 的接口,那麼具有下面這樣優美的特性:「函數即為組件,組件即為應用」。

一個簡單的計數器例子

定義了應用的輸入和輸出之後,我們就可以寫代碼來實現具體的應用了,下面是一個簡單的、用Cycle 實現的計數器應用。注意該例子中所有函數都是純函數,所有的變量都保持了immutable

該例子有一個在線版本,不過在線版本因為網站限制並沒有使用TypeScript而使用了JavaScript。

// CounterApp.tsximportxs,{Stream}from'xstream'interfaceSources{DOM:DOMSource}interfaceSinks{DOM:Stream<VNode>}functionCounterApp(sources:Sources):Sinks{constdomSource=sources.DOMconstclickDecButton$=domSource.select('.dec').events('click')constclickIncButton$=domSource.select('.inc').events('click')constdec$=clickDecButton$.mapTo(-1)constinc$=clickIncButton$.mapTo(+1)constchange$=xs.merge(dec$,inc$)constcount$=change$.fold((oldCount,delta)=>oldCount+delta,0)constvdom$=count$.map(count=><divclassName="simple-counter"><buttonclassName="dec">-1</button><p>Count:{count}</p><buttonclassName="inc">+1</button></div>)return{DOM:vdom$}}

Cycle 的內容較少,本文前面的部分就已經介紹了Cycle 的大部分內容。 Cycle 只規定了應用的輸入和輸出格式,但並沒有限定應用如何去管理狀態和數據,開發者可以選擇自己喜歡的方式來進行狀態管理。下面的部分就講講我對Cycle 狀態管理的一些思考總結。

狀態管理

將輸入直接映射為輸出是困難的,即使是計數器這樣比較簡單的應用,我們也需要一些中間步驟和中間狀態,才能實現整個應用。我認為狀態管理涵蓋了「狀態存儲」,「生命週期管理」,「狀態變更方式」,「狀態作用域/狀態傳遞」幾個方面,是應用開發中非常重要的一環。

React/Redux中的狀態管理

我們先來看看流行的框架/類庫是如何管理狀態的(我比較熟悉React 和Redux,所以這裡主要說這兩個)。 React/Redux提出了不同的方式來解決狀態管理問題,不過兩者都使用了更偏向於面向對象的方式:以某個對象為容器,狀態保存在容器中。

  • 狀態存儲:React中用組件實例的state字段來記錄該實例的內部狀態,代碼中使用this.state來獲取狀態;而Redux則用全局的store存放應用的全局狀態,外部使用store.getState()方法來獲取狀態。
  • 生命週期管理:React 中狀態的生命週期與組件實例生命週期一致,一個組件實例被創建時,一份組件狀態會被初始化,一個組件實例被銷毀時,一份組件狀態也同時被銷毀;Redux 中store 是單例,store 在整個應用生命週期中存在且僅存在一個。
  • 狀態變更方式:React中我們往往手動調用setState來進行變更狀態,且使用setState後會觸發組件的re-render;Redux中我們需要將變更用Action對象封裝(Action對像一般來說是包含type字段的普通JavaScript 對象,該對像也包含其他可選的字段用於描述應用所要進行的操作),然後在reducer 中實現狀態變更邏輯。
  • 作用域/狀態傳遞:React中組件實例的狀態只對組件實例本身可見,狀態也只能由組件實例調用this.setState來進行修改。組件實例也可以將狀態以props的方法傳遞給子組件,而要讓子組件也能修改組件實例的狀態,需要將包含setState的回調函數傳遞給子組件。在Redux中,狀態是全局狀態,對整個應用都是可見的。

在React/Redux中reactInstance.state或是store.getState()只能表達「某個時刻的狀態」 ,為了使得頁面內容不斷發生變化,這些狀態對像也必須不斷更新。 React/Redux 對狀態變更都是比較謹慎的:React 不推薦應用直接修改state 對象,而Redux 更是要求我們必須將修改操作封裝為Action 對象。

Cycle中的狀態管理

現在不一樣的是,我們有了Stream這個容器,一個變量就可以表達「整個應用生命週期中某個狀態的所有值」 。充分利用Stream 容器和JavaScript 的函數特性,我們可以做得更簡潔:「一個組件就是一個函數,函數的局部變量便是組件的狀態」。函數局部變量本身俱有的特性和組件狀態管理配合默契:

  • 生命週期管理:
    • 創建:「組件實例加載時狀態初始化」對應於「函數被調用時局部變量被創建」
    • 銷毀:「組件實例卸載時狀態銷毀」對應於「函數調用結束且外部不局部變量被回收」(這裡更嚴謹的說法是:調用組件函數,返回組件實例的輸出流,當輸出流不再被其他對象引用時,局部變量指向的對象將被回收。)
  • 狀態作用域:「函數局部變量僅在函數內可見」對應「組件實例的狀態僅對實例本身可見」,如果想讓「子組件共享父組件的狀態」,那麼只需「將父組件中的局部變量以函數參數的方式傳遞給子組件」即可。
  • 狀態存儲:局部變量使用語言本身的閉包機制進行存儲
  • 狀態變更方式:響應式編程(見下方)

而在Cycle 中,用來存放組件狀態的局部變量和其他局部變量並沒有任何差別,我們可以用局部變量來表達任何可以表達的內容:「狀態流」、「redux Action 對象流」、「事件流」、「HTTP 響應流」。例如在上面的計數器的例子,CounterApp 就用到了dec$ / change$ 等若干個流。得益於Stream 容器強大的表達能力,即便採用基本的「將狀態保存到函數局部變量上」的方式,我們的應用也可以應對非常複雜的情況。

流與依賴關係(響應式編程)

當我們使用流來表達應用的狀態時,響應式編程便開始發揮作用。讓我們再來看一看計數器例子中部分流的創建代碼:

constdec$=clickDecButton$.mapTo(-1)// LINE 1
constinc$=clickIncButton$.mapTo(+1)// LINE 2
constchange$=xs.merge(dec$,inc$)// LINE 3
constcount$=change$.fold((oldCount,delta)=>oldCount+delta,0)// LINE 4
constvdom$=count$.map(count=>...// LINE 5

上面的代碼創建了若干個流,且「流的創建代碼」中包含了「流的依賴關係」。

  • 從LINE 1 中我們可以看出「減一操作流」依賴於「點擊減一按鈕的事件流」(LINE 2 同理);
  • LINE 3 表明了「狀態修改流」依賴於「減一/加一操作流」;
  • LINE 4 則表明了count 依賴於「狀態修改流」;
  • 最後LINE 5 則表示應用的視圖依賴於應用的狀態。

函數中的各個數據流/事件流的依賴關係被顯示寫了出來,具體的流的計算規則由實際業務需求決定。

應用運行的時候,每當輸入流emit 一個新的值,依賴於輸入流的各個流都會得到「響應」(根據流被創建時的規則計算得到該流需要emit 的值),而依賴於輸入流的流可能又被其他流所依賴,這個響應的過程會不斷進行,直到所有流完成響應。

例如在計數器應用中,用戶每點擊一次「+1」按鈕,clickIncButton$ 就會emit 一個MouseEvent 對象,而依賴於clickIncButton$ 的inc$ 流中會根據流創建時指定的規則emit「+1」。 change$ 合併了inc$ 與dec$,inc$ emit 一個「+1」時,change$ 也會emit 一個「+1」。 count$ 流會將+1 和原先的count 值相加,生成新的count 值並emit 出來,最後vdom$ 會根據count 的變化而emit 新的vdom 對象,框架中的domDriver 會得到新的vdom 對象並更新頁面的真實DOM。

響應式編程提高了代碼的抽像等級,使得代碼更專注於業務邏輯中不同事件流的互相關係,而不用總是陷在一大堆瑣碎的具體實現上。來自"Why should I consider adopting RP?"

計數器的例子中,count$ 依賴且僅依賴於change$,而不依賴於具體的點擊事件或是具體的頁面內容;我們創建count$ 的時候,不需要關心頁面上的內容,不需要關心用戶點在了哪個按鈕上面,我們只需要了解「 這兒有一個change$ 記錄了所有count 的變化」以及「 count 的初始值為0」更足夠了。計數器的例子比較簡單,而下面這個較為複雜的例子可以讓我們看到響應式編程帶來的抽象級別的提升。

例子:軌跡查看器

blank

軌跡查看器對聚類前後的室內定位數據進行了可視化。這裡我們關注軌跡查看器的地圖居中功能,該居中功能大致如下:

  • 頁面大小發生變化時,居中顯示地圖
  • 點擊左上角的Centralize Map按鈕,居中顯示地圖
  • 從右側的Mobility Semantics Timeline中點擊其他樓層的軌跡時,居中顯示軌跡
  • 頁面第一次打開時,居中顯示地圖,但不使用過渡動畫(其他三種情況都使用過渡動畫)

用回調函數的方式來實現上述功能是非常繁瑣的,而在這裡適當地應用響應式編程可以大大簡化編碼。這裡我們從結果為導向來整理實現思路:

  1. 因為最後的效果是要對地圖進行縮放,且還要指定是否使用transition,根據D3的API文檔,我們使用這樣的一個對象來描述「所期望的縮放行為」{ useTransition: boolean; targetTransform: d3.ZoomTransform}
    1. useTransition比較容易計算,第一次加載地圖該值為false,其他時候均為true
    2. targetTransform可以通過viewBoxcontentBox來計算得到。
      1. viewBox和居中對象無關,直接通過獲取SVG元素的大小就可以計算得到。
      2. contentBox和居中的對象相關,居中地圖時我們需要獲取樓層地圖的大小,而居中軌跡時,我們則需要根據具體的軌跡ID來獲取對應的軌蹟的外接矩形的大小。

整理好思路之後,我們還需要注意兩點:

  1. 因為我們需要表示應用運行過程中「所有的地圖居中和軌跡居中」,所以我們需要正確地用Stream容器將這些對象包裝起來。
  2. 剛才的思路是「反過來」的,而具體代碼是「正」的。

具體代碼大致如下:

//注意該文件中代碼執行了副作用;不過該文件並不是cycle的應用代碼;//本文件中還請更多關注「變化的傳播」 (●ˇ∀ˇ●)//地圖居中訊息流其中的訊息包含useTransition, contentBox這兩個字段constmapCentralizeInfo$=xs.merge(//樓層居中來自於下面三種情況:// 1.第一次樓層地圖加載映射為false(表示不使用transition)floor$.take(1).mapTo(false),// 2.將去抖之後的「網頁窗口大小改變事件」映射為trueresize$.compose(debounce(200)).mapTo(true),// 3.用戶點擊了居中按鈕centralizeMap$.mapTo(true),).compose(sampleCombine(svg$)).map(([useTransition,svg])=>{constregionLayer=svg.selectAll('*[data-layer=region]').node()constcontentBox=regionLayer.getBBox()//此時樓層地圖的大小return{useTransition,contentBox}})//軌跡居中訊息流consttraceCentralizeInfo$=traceToCentralize$.compose(sampleCombine(svg$)).map(([trace,svg])=>{//根據traceIndex來獲取該軌蹟的大小consttraceNode=svg.select(`*[data-trace-index="${traceIndex}"]`).node()constcontentBox=traceNode.getBBox()return{useTransition:true,contentBox,}})xs//合併「地圖居中訊息流」和「軌跡居中訊息流」.merge(mapCentralizeInfo$,traceCentralizeInfo$).compose(sampleCombine(svg$)).addListener({next([{useTransition,contentBox},svg]){constsvgNode=svg.node()constviewBox={width:svgNode.clientWidth,height:svgNode.clientHeight}consttargetTransform=doCentralize(contentBox,viewBox)//到這里為止,我們求出了useTransition和targetTransform這兩個變量//這兩個變量描述了我們所期望的縮放行為if(useTransition){zoom.transform(svg.transition(),targetTransform)}else{zoom.transform(svg,targetTransform)}}})

上述代碼中,我們通過組合已有的流(例如樓層地圖的數據流,用戶點擊按鈕的事件流等),使用不同的流操作符(map/mapTo/merge等),得到若干中間狀態流(地圖居中訊息流和軌跡居中訊息流),然後合併這些中間流並添加居中地圖的監聽器。每一個流的創建代碼都包含了其所依賴的其他流,我們可以從流的創建代碼中清晰地看到流之間的依賴關係。隨著流的不斷創建,代碼的抽象級別也相應提高,到最後我們得到了這樣一個流,通過該流可以方便地計算「期望的縮放行為」 ,恰好對應前面實現思路中的第一行。

總結與一些其他感想

本文介紹了Cycle 對於應用輸入/輸出的定義,並圍繞該定義分析了Stream 容器的引入對應用開髮帶來的思路轉變,探究了Cycle 官方模塊出現的原因以及其作用。 Cycle 並沒有限制開發者如何去管理應用的狀態,本文通過對比和舉例的方式,介紹了一些我個人在日常開發中採用的狀態管理方式。 Cycle 擁抱響應式編程,如果能夠合理地構建數據流,並清晰地抽象代碼,基於Cycle 可以實現交互/邏輯非常複雜的應用。

最近一些日子我主要使用Cycle實現了一個簡易的SVG編輯器,切身體會到了響應式編程對於實現複雜交互的強大能力。不過,Cycle 也存在著許多的「坑」,這幾個月我也被坑過不少回:

  • Stream 都是lazy 的,如果一個流沒有消費者,那麼該流不會emit 任何對象。在依賴關係複雜的應用中,「某個組件的某個channel 處理錯誤」就可能導致「整個應用無法運行」,因為其他所有流都可能直接或間接依賴於出錯的那個流。
  • Stream<T>容易理解;而Stream<Stream<T>>或是Array<Stream<T>>則較為晦澀難懂。然而在較為複雜的應用中,嵌套的流或是流的列表又很常見,維護這些數據結構有著較高的學習門檻(不過在學會之後也不覺得太難)。在這個GitHub issue中Cycle作者也講到處理列表是非常棘手的一件事,該issue中也列舉了處理列表的若干方式。
  • 調試體驗較差。拋出異常的堆棧缺乏有效訊息,有的時候完全不知道自己錯在哪裡,在這個issue裡面也有人提到該問題。

總體而言,Cycle 是一個不太一樣的框架,有一定的學習門檻,但代碼實踐中也充滿了樂趣。 Cycle 擁抱響應式編程,也許直接採用Cycle 框架脫離了實際需求,其響應式的開發思想也值得一學。

What do you think?

Written by marketer

blank

Promise 使用技巧九則

blank

第4屆CSS大會3月31日相約廈門,與行業CSS專家面對面