天天看點

流動的資料——使用 RxJS 構造複雜單頁應用的資料邏輯

感謝作者徐飛的授權釋出。

作者:徐飛,網名民工精髓V,曾任Teambition前端架構師、蘇甯雲計算中心前端架構師。有十年以上大型企業應用前端架構及開發經驗,熟悉AngularJS等架構,對Web元件化有一些思考。部落格位址:https://github.com/xufei/blog/issues。

我們經常見到這麼一些場景:

  • 微網誌的清單頁面;
  • 各類協同工具的任務看闆,比如 Teambition。
流動的資料——使用 RxJS 構造複雜單頁應用的資料邏輯

這類場景的一個共同特點是:

  • 由若幹個小方塊構成;
  • 每個小方塊需要以一個業務實體為主體(一條微網誌,一個任務),聚合一些其他關聯資訊(參與者,标簽等)。

這麼一個界面,我們考慮它的完全展示,可能會有這麼兩種方案:

  • 服務端渲染,查詢所有資料,生成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響應過程抽象,融為一體。

說起來很容易,但關注其實作的話,就會發現這個過程是需要好多步驟的,比如說:

流動的資料——使用 RxJS 構造複雜單頁應用的資料邏輯

一個視圖所需要的資料可能是這樣的:

  • data1跟data2通過某種組合,得到一個結果;
  • 這個結果再去跟data3組合,得到最終結果。

我們怎麼去抽象這個過程呢?

注意,這裡面data1,data2,data3,可能都是之前提到過的,包含了同步和異步封裝的一個過程,具體來說,就是一個RxJS Observable。

可以把每個Observable視為一節資料流的管道,我們所要做的,是根據它們之間的關系,把這些管道組裝起來,這樣,從管道的某個入口傳入資料,在末端就可以得到最終的結果。

RxJS給我們提供了一堆操作符用于處理這些Observable之間的關系,比如說,我們可以這樣:

const A$ = Observable.interval()
const B$ = Observable.of()
const C$ = Observable.from([, , ])

const D$ = C$.toArray()
  .map(arr => arr.reduce((a, b) => a + b), )
const E$ = Observable.combineLatest(A$, B$, D$)
   .map(arr => arr.reduce((a, b) => a + b), )
           

上述的D就是通過C進行一次轉換所得到的資料管道,而E是把A,B,D進行拼裝之後得到的資料管道。

流動的資料——使用 RxJS 構造複雜單頁應用的資料邏輯

從以上的示意圖就可以看出它們之間的組合關系,通過這種方式,我們可以描述出業務邏輯的組合關系,把每個小粒度的業務封裝到資料管道中,然後對它們進行組裝,拼裝出整體邏輯來。

現在和未來

在業務開發中,我們時常遇到這麼一種場景:

已過濾排序的清單中加入一條新資料,要重新按照這條規則走一遍。

我用一個簡單的類比來描述這件事:

每個進教室的同學都可以得到一顆糖。

這句話表達了兩個含義:

在這句斷言産生之前,對于已經在教室裡的每個人,都應當去給他們發一顆糖;

在這句斷言形成以後,再進入這個教室的每個人,都應當得到一顆糖。

這裡面,第一句表達的是現在,第二句表達的是未來。我們編寫業務程式的時候,往往會把現在和未來分開考慮,而忽略了他們之間存在的深層次的一緻性。

我們想通了這個事情之後,再反過來考慮剛才這個問題,能得到的結論是:

進入本清單的資料都應當經過某種過濾規則和某種排序規則

這才是一個合适的業務抽象,然後再編寫代碼就是:

const final$ = source$.map(filterA).map(sorterA)
           

其中,source代表來源,而final代表結果。來源經過filterA變換、sorterA變換之後,得到結果。

然後,我們再去考慮來源的定義:

來源等于初始資料與新增資料的合并。

然後,實作出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得出什麼思考呢?

  1. 可以實作異步的計算屬性。
  2. 我們有沒有考慮過,如何從視圖的角度去組織這些資料流?

一個分析過程可以是這樣:

  • 檢閱某視圖,發現它需要資料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平台的獨立視圖,歡迎第三方開發者發揮自己的想象,用它建構出各種各樣有趣的東西。我們也會逐漸添加一些示例。

如何了解整個機制

怎麼了解這麼一套機制呢,可以想象一下這張圖:

流動的資料——使用 RxJS 構造複雜單頁應用的資料邏輯

把Teambition SDK看作一個CPU,API就是他對外提供的引腳,視圖元件接在這些引腳上,每次調用API,就如同從一個引腳輸入資料,但可能觸發多個引腳對外發送資料。細節可以參見SDK的設計文檔。

另外,對于RxJS資料流的組合,也可以參見這篇文章,你點開連結之後可能心想:這兩者有什麼關系!

翻到最後那個圖,從側面看到多個波疊加,你想象一下,如果把視圖的狀态了解為一個時間軸上的流,它可以被視為若幹個其他流的疊加,這麼多流疊加起來,在目前時刻的值,就是能夠表達我們所見視圖的全部狀态資料。

這麼想一遍是不是就容易了解多了?

我第一次看到RxJS相關理念大概是5年前,當時老趙他們在讨論這個,我看了幾天之後的感覺就是對智商形成了巨大考驗,直到最近一兩年才算是入門了,不過僅限與業務應用,背後的深層數學理論仍然是不通的。現在的程度,大概相當于一個勉強能應用四則運算解應用題的國小生吧。

還有一個問題是,雖然剛才又是貼圖又是貼連結,顯得好厲害,但我大學時候的數字電路和信号系統都是挂了的,但最近回頭想這些東西,發現突然好像能了解了,果然很多東西背後的思想是一緻的。

130+位講師,16大分論壇,中國科學院院士陳潤生、滴滴出行進階副總裁章文嵩、聯想集團進階副總裁兼CTO芮勇、上交所前總工程師白碩等專家将親臨2016中國大資料技術大會,票價折扣即将結束,預購從速。