流動的資料——使用 RxJS 構造複雜單頁應用的資料邏輯
我們經常見到這麼一些場景:
- 微博的列表頁面;
- 各類協同工具的任務看板,比如 Teambition。
這類場景的一個共同特點是:
- 由若干個小方塊構成;
- 每個小方塊需要以一個業務實體為主體(一條微博,一個任務),聚合一些其他關聯資訊(參與者,標籤等)。
這麼一個介面,我們考慮它的完全展示,可能會有這麼兩種方案:
- 服務端渲染,查詢所有資料,生成HTML之後傳送給瀏覽器;
- 前端渲染,查詢所有資料,傳送給瀏覽器生成HTML展示。
微博使用的前一種,並且引入了bigpipe機制來生成介面,而Teambition則使用後一種,主要差別還是由於產品形態。
➤業務上的挑戰
在前端渲染的情況下,這麼一種介面形態,所帶來的挑戰有哪些呢?
- 資訊量較大,導致查詢較複雜,其中有部分資料是可複用的,比如說,這麼一大片面板,可能幾百條任務,但是其中人員可能就20個,所有參與者都在這20個人裡面。
- 如果要做一些比較實時的互動,會比較麻煩,比如說,某個使用者修改了頭像,某個標籤定義修改了文字,都會需要去立刻更新當前介面所有的引用部分。
所以,這就要求我們的資料查詢是離散化的,任務資訊和額外的關聯資訊分開查詢,然後前端來組裝,這樣,一是可以減少傳輸資料量,二是可以分析出資料之間的關係,更新的時候容易追蹤。
除此之外,Teambition的操作會在全業務維度使用WebSocket來做更新推送,比如說,當前任務看板中,有某個東西變化了(其他人建立了任務、修改了欄位),都會由服務端推送訊息,來促使前端更新介面。
離散的資料會讓我們需要使用快取。比如說,介面建立起來之後,如果有人在其他端建立了任務,那麼,本地的看板只需收到這條任務資訊並建立檢視,並不需要再去查詢人員、標籤等關聯資訊,因為之前已經獲取過。所以,大致會是這個樣子:
某檢視元件的展示,需要聚合ABC三個實體,其中,如果哪個實體在快取中存在,就不去服務端拉取,只拉取無快取的實體。
這個過程帶給我們第一個挑戰:
● 查詢同一種資料,可能是同步的(快取中獲取),可能是非同步的(AJAX獲取),業務程式碼編寫需要考慮兩種情況。
WebSocket推送則用來保證我們前端快取的正確性。但是,我們需要注意到,WebSocket的程式設計方式跟AJAX是不一樣的,WebSocket是一種訂閱,跟主流程很難整合起來,而AJAX相對來說,可以組織得包含在主流程中。
例如,對同一種更新的不同發起方(自己修改一個東西,別人修改這個東西),這兩種的後續其實是一樣,但程式碼並不相同,需要寫兩份業務程式碼。
這樣就帶給我們第二個挑戰:
● 獲取資料和資料的更新通知,寫法是不同的,會加大業務程式碼編寫的複雜度。
我們的資料這麼離散,從檢視角度看,每塊檢視所需要的資料,都可能是經過比較長而複雜的組合,才能滿足展示的需要。
所以,第三個挑戰:
● 每個渲染資料,都是通過若干個查詢過程(剛才提到的組合同步非同步)組合而成,如何清晰地定義這種組合關係?
此外,我們可能面臨這樣的場景:
一組資料經過多種規則(過濾,排序)之後,又需要插入新的資料(主動新增了一條,WebSocket推送了別人新建的一條),這些新增資料都不能直接加進來,而是也必須走一遍這些規則,再合併到結果中。
這就是第四個挑戰:
● 對於已有資料和未來資料,如何簡化它們應用同樣規則的程式碼複雜度。
帶著這些問題,我們來開始今天的思考過程。
➤同步和非同步
在前端,經常會碰到同步、非同步程式碼的統一。假設我們要實現一個方法:當有某個值的時候,就返回這個值,否則去服務端獲取這個值。
通常的做法是使用Promise:
if (a) { return Promise.resolve(a) } else { return AJAX.get('a') } }
所以,我們處理這個事情的辦法就是,如果不確定是同步還是非同步,那就取非同步,因為它可以相容同步,剛才程式碼裡面的resolve就是強制把同步的東西也轉換為相容非同步的Promise。
我們只用Promise當然也可以解決問題,但RxJS中的Observable在這一點上可以一樣做到:
function getDataO() {
if (a) {
return Observable.of(a)
} else {
return Observable.fromPromise(AJAX.get('a'))
}
}
有人要說了,你這段程式碼還不如Promise,因為還是要從它轉啊,優勢在哪裡呢?
我們來看看剛才封裝出來的方法,分別是怎麼使用的呢?
getDataP().then(data => {
// Promise 只有一個返回值,響應一次
console.log(data)
})
getDataO().subscribe(data => {
// Observable 可以有多個返回值,響應多次
console.log(data)
})
在這一節裡,我們不對比兩者優勢,只看解決問題可以通過怎樣的辦法:
- getData(),只能做同步的事情;
- getDataP(),可以做同步和非同步的事情;
- getDataO(),可以做同步和非同步的事情。
結論就是,無論Promise還是Observable,都可以實現同步和非同步的封裝。
➤獲取和訂閱
通常,我們在前端會使用觀察者或者訂閱釋出模式來實現自定義事件這樣的東西,這實際上就是一種訂閱。
從檢視的角度看,其實它所面臨的是:
得到了一個新的任務資料,我要展示它
至於說,這個東西是怎麼得到的,是主動查詢來的,還是別人推送過來的,並不重要,這不是它的職責,它只管顯示。
所以,我們要給它封裝的是兩個東西:
- 主動查詢的資料;
- 被動推送的資料。
然後,就變成類似這麼一個東西:
service.on('task', data => {
// render
})
這麼一來,檢視這裡就可以用相同的方式應對兩種不同來源的資料了,service內部可以去把兩者統一,在各自的回撥裡面觸發這個自定義事件task。
但我們似乎忽略了什麼事,檢視除了響應這種事件之外,還需要去主動觸發一下初始化的查詢請求:
service.on('task', data => {
// render
})
service.getData() // 加了這麼一句來主動觸發請求,這樣看起來還是挺彆扭的,回到上一節裡面我們的那個Observable示例:
getDataO().subscribe(data => {
// render
})
這麼一句好像就搞定了我們要求的所有事情。我們可以這麼去理解這件事:
- getDataO是一個業務過程;
- 業務過程的結果資料可以被訂閱。
這樣,我們就可以把獲取和訂閱這兩件事合併到一起,檢視層的關注點就簡單很多了。
➤可組合的資料管道
依據上一節的思路,我們可以把查詢過程和WebSocket響應過程抽象,融為一體。
說起來很容易,但關注其實現的話,就會發現這個過程是需要好多步驟的,比如說:
一個檢視所需要的資料可能是這樣的:
- data1跟data2通過某種組合,得到一個結果;
- 這個結果再去跟data3組合,得到最終結果。
我們怎麼去抽象這個過程呢?
注意,這裡面data1,data2,data3,可能都是之前提到過的,包含了同步和非同步封裝的一個過程,具體來說,就是一個RxJS Observable。
可以把每個Observable視為一節資料流的管道,我們所要做的,是根據它們之間的關係,把這些管道組裝起來,這樣,從管道的某個入口傳入資料,在末端就可以得到最終的結果。
RxJS給我們提供了一堆操作符用於處理這些Observable之間的關係,比如說,我們可以這樣:
const A$ = Observable.interval(1000)
const B$ = Observable.of(3)
const C$ = Observable.from([5, 6, 7])
const D$ = C$.toArray()
.map(arr => arr.reduce((a, b) => a + b), 0)
const E$ = Observable.combineLatest(A$, B$, D$)
.map(arr => arr.reduce((a, b) => a + b), 0)
上述的D就是通過C進行一次轉換所得到的資料管道,而E是把A,B,D進行拼裝之後得到的資料管道。
從以上的示意圖就可以看出它們之間的組合關係,通過這種方式,我們可以描述出業務邏輯的組合關係,把每個小粒度的業務封裝到資料管道中,然後對它們進行組裝,拼裝出整體邏輯來。
➤現在和未來
在業務開發中,我們時常遇到這麼一種場景:
已過濾排序的列表中加入一條新資料,要重新按照這條規則走一遍。
我用一個簡單的類比來描述這件事:
每個進教室的同學都可以得到一顆糖。
這句話表達了兩個含義:
在這句斷言產生之前,對於已經在教室裡的每個人,都應當去給他們發一顆糖; 在這句斷言形成以後,再進入這個教室的每個人,都應當得到一顆糖。
這裡面,第一句表達的是現在,第二句表達的是未來。我們編寫業務程式的時候,往往會把現在和未來分開考慮,而忽略了他們之間存在的深層次的一致性。
我們想通了這個事情之後,再反過來考慮剛才這個問題,能得到的結論是:
● 進入本列表的資料都應當經過某種過濾規則和某種排序規則
這才是一個合適的業務抽象,然後再編寫程式碼就是:
const final$ = source$.map(filterA).map(sorterA)
其中,source代表來源,而final代表結果。來源經過filterA變換、sorterA變換之後,得到結果。
然後,我們再去考慮來源的定義:
const source$ = start$.merge(patch$)
來源等於初始資料與新增資料的合併。
然後,實現出filterA和sorterA,就完成了整個這段業務邏輯的抽象定義。給start和patch分別進行定義,比如說,start是一個查詢,而patch是一個推送,它就是可執行的了。最後,我們在final上新增一個訂閱,整個過程就完美地對映到了介面上。
很多時候,我們編寫程式碼都會考慮進行合適的抽象,但這兩個字代表的含義在很多場景下並不相同。
很多人會懂得把程式碼劃分為若干方法,若干型別,若干元件,以為這樣就能夠把整套業務的運轉過程抽象出來,其實不然。
業務邏輯的抽象是與業務單元不同的方式,前者是血脈和神經,後者是肢體和器官,兩者需要結合在一起,才能夠成為鮮活的整體。
一般場景下,業務單元的抽象難度相對較低,很容易理解,也容易獲得關注,所以通常都能做得還不錯,比如最近兩年,對於元件化之類的話題,都能夠談得起來了,但對於業務邏輯的抽象,大部分專案是做得很不夠的,值得深思。
➤檢視如何使用資料流
以上,我們談及的都是在業務邏輯的角度,如何使用RxJS來組織資料的獲取和變更封裝,最終,這些東西是需要反映到檢視上去的,這裡面有些什麼有意思的東西呢?
我們知道,現在主流的MV*框架都基於一個共同的理念:MDV(模型驅動檢視),在這個理念下,一切對於檢視的變更,首先都應當是模型的變更,然後通過模型和檢視的對映關係,自動同步過去。
在這個過程中,我們可能會需要通過一些方式定義這種關係,比如Angular和Vue中的模板,React中的JSX等等。
在這些體系中,如果要使用RxJS的Observable,都非常簡單:
data$.subscribe(data => { // 這裡根據所使用的檢視庫,用不同的方式響應資料 // 如果是 React 或者 Vue,手動把這個往 state 或者 data 設定 // 如果是 Angular 2,可以不用這步,直接把 Observable 用 async pipe 繫結到檢視 // 如果是 CycleJS …… })
這裡面有幾個點要說一下:
Angular2對RxJS的使用是非常方便的,形如:let todo of todos$ | async這種程式碼,可以直接繫結一個Observable到檢視上,會自動訂閱和銷燬,比較簡便優雅地解決了“等待資料”,“資料結果不為空”,“資料結果為空”這三種狀態的差異。Vue也可以用外掛達到類似的效果。
CycleJS比較特別,它整個執行過程就是基於類似RxJS的機制,甚至包括檢視,看官方的這個Demo:
import {run} from '@cycle/xstream-run';
import {div, label, input, hr, h1, makeDOMDriver} from '@cycle/dom';
function main(sources) {
const sinks = {
DOM: sources.DOM.select('.field').events('input')
.map(ev => ev.target.value)
.startWith('')
.map(name =>
div([
label('Name:'),
input('.field', {attrs: {type: 'text'}}),
hr(),
h1('Hello ' + name),
])
)
};
return sinks;
}
run(main, { DOM: makeDOMDriver('#app-container') });
這裡面,注意DOM.select這段。這裡,明顯是在介面還不存在的情況下就開始select,開始新增事件監聽了,這就是我剛才提到的預先定義規則,統一現在與未來:如果介面有.field,就立刻新增監聽,如果沒有,等有了就新增。
那麼,我們從檢視的角度,還可以對RxJS得出什麼思考呢?
- 可以實現非同步的計算屬性。
- 我們有沒有考慮過,如何從檢視的角度去組織這些資料流?
一個分析過程可以是這樣:
- 檢閱某檢視,發現它需要資料a,b,c;
- 把它們的來源分別定義為資料流A,B,C;
- 分析A,B,C的來源,發現A來源於D和E;B來源於E和F;C來源於G;
- 分別定義這些來源,合併相同的部分,得到多條直達檢視的管道流;
- 然後定義這些管道流的組合過程,做合適的抽象。
➤小結
使用RxJS,我們可以達到以下目的:
- 同步與非同步的統一;
- 獲取和訂閱的統一;
- 現在與未來的統一;
- 可組合的資料變更過程。
還有:
- 資料與檢視的精確繫結;
- 條件變更之後的自動重新計算。
➤Teambition SDK
Teambition 新版資料層使用RxJS構建,不依賴任何展現框架,可以被任何展現框架使用,甚至可以在NodeJS中使用,對外提供了一整套Reactive的API,可以查閱文件和程式碼來了解詳細的實現機制。
基於這套機制,可以很輕鬆實現一套基於Teambition平臺的獨立檢視,歡迎第三方開發者發揮自己的想象,用它構建出各種各樣有趣的東西。我們也會逐步新增一些示例。
➤如何理解整個機制
怎麼理解這麼一套機制呢,可以想象一下這張圖:
把Teambition SDK看作一個CPU,API就是他對外提供的引腳,檢視元件接在這些引腳上,每次呼叫API,就如同從一個引腳輸入資料,但可能觸發多個引腳對外發送資料。細節可以參見SDK的設計文件。
另外,對於RxJS資料流的組合,也可以參見這篇文章(https://zhuanlan.zhihu.com/p/19763358?columnSlug=wille),你點開連結之後可能心想:這兩者有什麼關係!
翻到最後那個圖,從側面看到多個波疊加,你想象一下,如果把檢視的狀態理解為一個時間軸上的流,它可以被視為若干個其他流的疊加,這麼多流疊加起來,在當前時刻的值,就是能夠表達我們所見檢視的全部狀態資料。
這麼想一遍是不是就容易理解多了?
我第一次看到RxJS相關理念大概是5年前,當時老趙他們在討論這個,我看了幾天之後的感覺就是對智商形成了巨大考驗,直到最近一兩年才算是入門了,不過僅限與業務應用,背後的深層數學理論仍然是不通的。現在的程度,大概相當於一個勉強能應用四則運算解應用題的小學生吧。
還有一個問題是,雖然剛才又是貼圖又是貼連結,顯得好厲害,但我大學時候的數位電路和訊號系統都是掛了的,但最近回頭想這些東西,發現突然好像能理解了,果然很多東西背後的思想是一致的。