前端必看,使用 RxJS 構造復雜單頁應用的數據邏輯
RxJS 簡介
RxJS 是 JavaScript 的 ReactiveX 庫。總的來說,它用于描述數據變化與時間的關系,是可用于異步操作的 lodash。
這句話比較難懂,可以通過了解它的使用場景來理解。
我們經常看到這些場景:
- 微博的列表頁面
- 各類協同工具的任務看板,比如 Teambition
這類場景的一個共同特點是:
- 由若干個小方塊構成
- 每個小方塊需要以一個業務實體為主體(一條微博,一個任務),聚合一些其他關聯信息(參與者,標簽等)
這么一個界面,考慮到它的完全展示,可能會有這么兩種方案:
- 服務端渲染,查詢所有數據,生成 HTML 之后發送給瀏覽器
- 前端渲染,查詢所有數據,發送給瀏覽器生成 HTML 展示
微博使用的前一種,并且引入了 bigpipe 機制來生成界面,而 Teambition 則使用后一種,主要差別還是由于產品形態。
Teambition 業務場景
- 復雜的單頁應用
- 主要功能都集中在一個頁面中
- 同一份數據存在多份副本
- 全業務都有與服務端的實時數據同步
業務面臨的問題
- 同一份數據在視圖的多個地方使用
- 廣泛地使用了本地數據緩存
- 每個業務場景都有服務端的 WebSocket 推送來更新數據
- WebSocket 推送可能導致很多視圖需要更新
先來看一個例子,現在有項目、任務這樣的組織方式。任務是掛在項目下面的,如果需要查詢一條任務,要先把它所屬的項目(A)查詢出來,此時切換到另一個項目(B),并點開其中一條任務,這個時候如果再切換回去。可以知道,剛剛已經請求過項目 A 的數據,那現在切換回去是否還需要再次請求呢?其實是可以不用,但是如果不請求會出現什么問題呢?畢竟不知道剛剛的過程中項目 A 是否有被修改。
所以,這就要求我們的數據查詢是離散化的,任務信息和額外的關聯信息分開查詢,然后前端來組裝,這樣,一是可以減少傳輸數據量,二是可以分析出數據之間的關系,更新的時候容易追蹤。
除此之外,Teambition 的操作會在全業務維度使用 WebSocket 來做更新推送,比如說,當前任務看板中,有某個東西變化了(其他人創建了任務、修改了字段),都會由服務端推送消息,來促使前端更新界面。
離散的數據會讓我們需要使用緩存。比如說,界面建立起來之后,如果有人在其他端創建了任務,那么,本地的看板只需收到這條任務信息并創建視圖,并不需要再去查詢人員、標簽等關聯信息,因為之前已經獲取過。所以,大致會是這個樣子:
某視圖組件的展示,需要聚合 ABC 三個實體,其中,如果哪個實體在緩存中存在,就不去服務端拉取,只拉取無緩存的實體。
這個過程帶來第一個挑戰:
查詢同一種數據,可能是同步的(緩存中獲取),可能是異步的(AJAX 獲取),業務代碼編寫需要考慮兩種情況。
WebSocket 推送則用來保證前端緩存的正確性。但是,需要注意到,WebSocket 的編程方式跟 AJAX 是不一樣的,WebSocket 是一種訂閱,跟主流程很難整合起來,而 AJAX 相對來說,可以組織到包含在主流程中。
例如,對同一種更新的不同發起方(自己修改一個東西,別人修改這個東西),這兩種的后續其實是一樣,但代碼并不相同,需要寫兩份業務代碼。
這樣就帶來第二個挑戰:
獲取數據和數據的更新通知,寫法是不同的,會加大業務代碼編寫的復雜度。
數據這么離散,從視圖角度看,每塊視圖所需要的數據,都可能是經過比較長而復雜的組合,才能滿足展示的需要。
所以,第三個挑戰:
每個渲染數據,都是通過若干個查詢過程(剛才提到的組合同步異步)組合而成,如何清晰地定義這種組合關系?
此外,可能面臨這樣的場景:
一組數據經過多種規則(過濾,排序)之后,又需要插入新的數據(主動新增了一條,WebSocket 推送了別人新建的一條),這些新增數據都不能直接加進來,而是也必須走一遍這些規則,再合并到結果中。
這就是第四個挑戰:
對于已有數據和未來數據,如何簡化它們應用同樣規則的代碼復雜度。
帶著這些問題,來開始今天的思考過程。
一、數據緩存中心化
第一點做的是數據緩存中心化,在前端把所有數據的緩存當做一個中心,但這里面就會出現兩種情況:
- 上層視圖第一次調用的時候,緩存不一定存在。這就需要向服務端請求數據之后,把數據拆解并緩存,這是一個異步加載的過程。
- 緩存存在的情況下,直接從緩存拼裝數據給界面顯示,這是同步的過程。
那么問題就來了,同樣是請求一個項目的數據,有可能是同步,也有可能是異步的,代碼該如何寫,因為同步和異步的代碼寫法不一樣。還有一個問題是 WebSocket 的消息是直接合并進緩存的。這個對前面的情況又會產生什么影響呢?
1. 同步與異步的統一
在前端,經常會碰到同步、異步代碼的統一。假設要實現一個方法:當有某個值的時候,就返回這個值,否則去服務端獲取這個值。
通常的做法是使用 Promise:
function getDataP() {
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'))
}
}
2. 跟 Promise 的差別
有人要說了,你這段代碼還不如 Promise,因為還是要從它轉啊,優勢在哪里呢?
來看看剛才封裝出來的方法,分別是怎么使用的呢?
getDataP().then(data => {
// Promise 只有一個返回值,響應一次
console.log(data)
})
getDataO().subscribe(data => {
// Observable 可以有多個返回值,響應多次
console.log(data)
})
在這一節里,不對比兩者優勢,只看解決問題可以通過怎樣的辦法:
- getData(): T{},只能做同步的事情
- getDataP(): Promise<T> {},可以做同步和異步的事情
- getDataO(): Observable<T> {},可以做同步和異步的事情
結論就是,無論 Promise 還是 Observable,都可以實現同步和異步的封裝。
二、數據層的 Reactive API
主要是完成以下任務:
- 獲取數據
- 訂閱并持續響應
- 數據的變更推送到有關聯的訂閱
獲取與訂閱的統一
通常,我們在前端會使用觀察者或者訂閱發布模式來實現自定義事件這樣的東西,這實際上就是一種訂閱。
從視圖的角度看,其實它所面臨的是:
得到了一個新的任務數據,我要展示它
至于說,這個東西是怎么得到的,是主動查詢來的,還是別人推送過來的,并不重要,這不是它的職責,它只管顯示。所以,要給它封裝的是兩個東西:
- 主動查詢的數據
- 被動推送的數據
然后,就變成類似這么一個東西:
// 普通觀察者(或者訂閱/發布)模式
service.on('task', data => {
// render
console.log(data)
})
這么一來,視圖這里就可以用相同的方式應對兩種不同來源的數據了,service 內部可以去把兩者統一,在各自的回調里面觸發這個自定義事件 task。
但我們似乎忽略了什么事,視圖除了響應這種事件之外,還需要去主動觸發一下初始化的查詢請求:
// 普通觀察者(或者訂閱/發布)模式
service.on('task', data => {
// render
console.log(data)
})
service.getData() // 訂閱之后,還要加這么一句來主動單獨觸發請求
這樣看起來還是挺別扭,回到上一節里面的那個 Observable 示例:
getDataO().subscribe(data => {
// render
console.log(data)
})
這里使用了 RxJS,可以先直接調用這個方法,然后立刻訂閱這個方法的返回結果,這段代碼起到兩個作用,先是請求數據,然后訂閱數據。也就是說,如果以后這條數據有變化,會隨著程序執行的過程持續地拿到變化后的數據。這么一句好像就搞定了我們要求的所有事情。可以這么去理解這件事:
- getDataO 是一個業務過程
- 業務過程的結果數據可以被訂閱
這樣,就可以把獲取和訂閱這兩件事合并到一起,視圖層的關注點就簡單很多了。
三、組件的狀態控制
1. 數據的流式封裝
因為這兩年 React 開始流行了,所以大家就開始關注視圖和它的狀態之間的關系 —— 某一個時間的狀態可能是通過第一個數據跟第二個數據之間進行某種關系的組合,然后再跟另外一個數據進行組合,最后得到一個數據,把這個數據拿到視圖上去展示,基本上來講是這樣的關系。
依據上一節的思路,可以把查詢過程和 WebSocket 響應過程抽象,融為一體。說起來很容易,但關注其實現的話,就會發現這個過程是需要好多步驟的,比如說:
data1 data2 data3
| | |
------------ |
| |
-----------------
|
state
一個視圖所需要的數據可能是這樣的:
- data1 跟 data2 通過某種組合,得到一個結果
- 這個結果再去跟 data3 組合,得到最終結果
怎么去抽象這個過程呢?
注意,這里面 data1,data2,data3,可能都是之前提到過的,包含了同步和異步封裝的一個過程,具體來說,就是一個 RxJS Observable。
可以把每個 Observable 視為一節數據流的管道,我們所要做的,是根據它們之間的關系,把這些管道組裝起來,這樣,從管道的某個入口傳入數據,在末端就可以得到最終的結果,數據在管道中就流動起來了。
const A$ = Observable.interval(1000)
const B$ = Observable.of(3)
const C$ = Observable.from([5, 6, 7])
2. 可組合的數據通道
RxJS 給我們提供了一堆操作符用于處理這些 Observable 之間的關系,比如說,可以通過一些方式(操作符)把管道連接起來
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 進行拼裝之后得到的數據管道
A ------> |
B ------> | -> E
C -> D -> |
從以上的示意圖就可以看出它們之間的組合關系,通過這種方式,可以描述出業務邏輯的組合關系,把每個小粒度的業務封裝到數據管道中,然后對它們進行組裝,拼裝出整體邏輯來。因此最后得到的數據就始終是經過這種關系所組合得到的數據。
3. 現在和未來的統一
在業務開發中,我們時常遇到這么一種場景:
已過濾排序的列表中加入一條新數據,要重新按照這條規則走一遍。
我用一個簡單的類比來描述這件事:
每個進教室的同學都可以得到一顆糖
這句話表達了兩個含義:
- 在這句斷言產生之前,對于已經在教室里的每個人,都應當去給他們發一顆糖
- 在這句斷言形成以后,再進入這個教室的每個人,都應當得到一顆糖
這里面,第一句表達的是現在,第二句表達的是未來。我們編寫業務程序的時候,往往會把現在和未來分開考慮,而忽略了他們之間存在的深層次的一致性。
想通了這個事情之后,再反過來考慮剛才這個問題,能得到的結論是:
進入本列表的數據都應當經過某種過濾規則和某種排序規則
這才是一個合適的業務抽象,然后再編寫代碼就是:
const final$ = source$.map(filterA).map(sorterA)
其中,source 代表來源,而 final 代表結果。來源經過 filterA 變換、sorterA 變換之后,得到結果。
然后,再去考慮來源的定義:
const source$ = start$.merge(patch$)
來源等于初始數據與新增數據的合并。
然后,實現出 filterA 和 sorterA,就完成了整個這段業務邏輯的抽象定義。給 start 和 patch 分別進行定義,比如說,start 是一個查詢,而 patch 是一個推送,它就是可運行的了。最后,在 final 上添加一個訂閱,整個過程就完美地映射到了界面上。
很多時候,我們編寫代碼都會考慮進行合適的抽象,但這兩個字代表的含義在很多場景下并不相同。
很多人會懂得把代碼劃分為若干方法,若干類型,若干組件,以為這樣就能夠把整套業務的運轉過程抽象出來,其實不然。
業務邏輯的抽象是與業務單元不同的方式,前者是血脈和神經,后者是肢體和器官,兩者需要結合在一起,才能夠成為鮮活的整體。
一般場景下,業務單元的抽象難度相對較低,很容易理解,也容易獲得關注,所以通常都能做得還不錯,比如最近兩年,對于組件化之類的話題,都能夠談得起來了,但對于業務邏輯的抽象,大部分項目是做得很不夠的,值得深思。
四、數據與視圖的綁定
以上,談及的都是在業務邏輯的角度,如何使用 RxJS 來組織數據的獲取和變更封裝,最終,這些東西是需要反映到視圖上去的,這里面有些什么有意思的東西呢?
最近幾年有很多關于視圖層的新東西出現,這和之前 jQuery 直接操作數組不一樣。比如說 Angular、React 和 Vue.js。從這幾個主流的框架中,可以得到一個理念 —— MDV(模型驅動視圖) 。即任何東西都是在改動了數據之后,由視圖層的框架自己按照定義好的規則來把視圖改變。而不需要每次修改數據后,再手動改動視圖。 所以在這個理念下,一切對于視圖的變更,首先都應當是模型的變更,然后通過模型和視圖的映射關系,自動同步過去 。
但是要考慮一點,到底是用什么東西驅動視圖改變?其實不同的框架所采用的方式是不一樣的,例如 Angular、Vue.js 與 React 就采用了不一樣的方式。
- Angular 是在每一個異步事件完成之后,對比一下現在的數據是否和之前的數據一樣,如果不一樣,把現在的數據拿去重繪之前的視圖;
- Vue.js 會讓數據的 get 和 set 做一件事情,比如說 a.b = 1,會在內部監控到即時的 a.b 的賦值,并在給 a.b 賦值的時候,它會更新界面
- React 是通過一個東西去收斂回來,因為它是單向數據,也就是輸出一條數據,通過某個地方轉向,然后再收斂回來,通過這種方式去改變視圖。
假如說現在有一個場景,就是某一個屬性是依賴于另外一些東西通過計算得到的,比如說界面上現在有三個輸入框,第三個輸入框里面的值要始終等于第一個跟第二個的和,如果在 Vue.js 上就需要定義一個 Computed Property,就是指某個屬性是依賴于其他一些數據,通過計算得到的。這個計算是同步的,假如這個數據是隔幾秒再傳過來,這個關系不好定義。因為要計算屬性,只能用當前的數據而不能是異步的,所以這里就可以用剛才的例子,就是先定義一個流,然后把這個流的數據往上面賦值。
再來看看這些前端 MV* 框架的目的:定義數據和視圖的關系,當數據變更之后,自動更新視圖。如果把下面數據的變更都用 RxJS 去實現,就是變成數據的管道,數據在管道里面流動,上層的就只需要訂閱數據,然后更新界面上的狀態。
1. 數據流與視圖的結合
現在來看這樣一個偽代碼,比如說現在有一個請求的數據,既有請求又有訂閱,拿到數據之后如何操作,如果是 React 或者 Vue.js 的話,就手動把拿到的數據往 state 或 data 里面設置;如果是 Angular 的話就更簡單,可以直接把 Observable 用 async pipe 綁定到視圖。在這些體系中,如果要使用 RxJS 的 Observable,都非常簡單:
data$.subscribe(data => {
// 這里根據所使用的視圖庫,用不同的方式響應數據
// 如果是 React 或者 Vue,手動把這個往 state 或者 data 設置
// 如果是 Angular 2,可以不用這步,直接把 Observable 用 async pipe 綁定到視圖
// 如果是 CycleJS ……
})
在這個過程中,可能會需要通過一些方式定義這種關系,比如 Angular 和 Vue 中的模板,React 中的 JSX 等等。
這里面有幾個點要說一下:
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,就立刻添加監聽,如果沒有,等有了就添加。
2. 整體狀態
整個應用的結構如下所示,最下面是服務端,然后前端有一個數據緩存,數據緩存之上提供了一個 Reactive API,應注意到,這個 API 不是調用一次就沒了,它可以持續不斷地返回數據,然后再把這個切到視圖上。
那么,從視圖的角度,還可以對 RxJS 得出什么思考呢?
- 可以實現異步的計算屬性。
- 有沒有考慮過,如何從視圖的角度去組織這些數據流?
一個分析過程可以是這樣:
- 檢閱某視圖,發現它需要數據 a,b,c
- 把它們的來源分別定義為數據流 A,B,C
- 分析 A,B,C 的來源,發現 A 來源于 D 和 E;B 來源于 E 和 F;C 來源于 G
- 分別定義這些來源,合并相同的部分,得到多條直達視圖的管道流
- 然后定義這些管道流的組合過程,做合適的抽象
五、如何理解整個機制?
怎么理解這么一套機制呢,可以想象一下這張圖:
把 Teambition SDK 看作一個 CPU,API 就是他對外提供的引腳,視圖組件接在這些引腳上,每次調用 API,就如同從一個引腳輸入數據,但可能觸發多個引腳對外發送數據。細節可以參見 SDK 的設計文檔。
翻到最后那個圖,從側面看到多個波疊加,想象一下,如果把視圖的狀態理解為一個時間軸上的流,它可以被視為若干個其他流的疊加,這么多流疊加起來,在當前時刻的值,就是能夠表達所見視圖的全部狀態數據。
這么想一遍是不是就容易理解多了?
Teambition SDK
Teambition 新版數據層使用 RxJS 構建,不依賴任何展現框架,可以被任何展現框架使用,甚至可以在NodeJS 中使用,對外提供了一整套 Reactive 的 API,可以查閱 文檔和代碼 來了解詳細的實現機制。
基于這套機制,可以很輕松實現一套基于 Teambition 平臺的獨立視圖,歡迎第三方開發者發揮自己的想象,用它構建出各種各樣有趣的東西。我們也會逐步添加一些示例。
六、小結
使用 RxJS,可以達到以下目的:
- 同步與異步的統一
- 獲取和訂閱的統一
- 現在與未來的統一
- 可組合的數據變更過程
- 數據與視圖的精確綁定
- 條件變更之后的自動重新計算
來自:https://my.oschina.net/osccreate/blog/788185