基於 RxJs 的前端數據層實踐

近來前端社區有愈來愈多的人開始關注前端數據層的設計。DaoCloud 也遇到了這方面的問題。咱們調研了不少種解決方案,最終採用 RxJs 來設計一套數據層。這一想法並不是咱們的獨創,社區裏已有不少前輩、大牛分享過關於用 RxJs 設計數據層的構想和實踐。站在巨人的肩膀上,才能走得更遠。所以咱們也打算把咱們的經驗公佈給你們,也算是對社區的回饋吧。

做者簡介


瞬光javascript

DaoCloud 前端工程師前端

一名中文系畢業的非典型程序員。vue

咱們遇到了什麼困難

DaoCloud Enterprise(下文簡稱 DCE) 是 DaoCloud 的主要產品,它是一個應用雲平臺,也是一個很是複雜的單頁面應用。它的複雜性主要體如今數據和交互邏輯兩方面上。在數據方面,DCE 須要展現大量數據,數據之間依賴關係繁雜。在交互邏輯方面,DCE 中有着大量的交互操做,並且幾乎每個操做幾乎都是牽一髮而動全身。可是交互邏輯的複雜最終仍是會表現爲數據的複雜。由於每一次交互,本質上都是在處理數據。一開始的時候,爲了保證數據的正確性,DCE 裏寫了不少處理數據、檢測數據變化的代碼,結果致使應用很是地卡頓,並且代碼很是難以維護。java

在整理了應用數據層的邏輯後,咱們總結出瞭如下幾個難點。本文會用較大的篇幅來描述咱們所遇到的場景,這是由於如此複雜的前端場景比較少見,只有充分理解咱們所遇到的場景,才能充分理解咱們使用這一套設計的緣由,以及這一套設計的優點所在。node

>>>> 應用的難點

數據來源多

DCE 的獲取數據的來源不少,主要有如下幾種:程序員

  1. 後端、 Docker 和 Kubernetes 的 APIweb

    API 是數據的主要來源,應用、服務、容器、存儲、租戶等等信息都是經過 API 獲取的。ajax

  2. WebSocketredux

    後端經過 WebSocket 來通知應用等數據的狀態的變化。後端

  3. LocalStorage

    保存用戶信息、租戶等信息。

  4. 用戶操做

    用戶操做最終也會反應爲數據的變化,所以也是一個數據的來源。

數據來源多致使了兩個問題:

  1. 複用處理數據的邏輯比較困難

    因爲數據來源多,所以獲取數據的邏輯經常分佈在代碼各處。好比說容器列表,展現它的時候咱們須要一段代碼來格式化容器列表。可是容器列表以後還會更新,因爲更新的邏輯和獲取的邏輯不同,因此就很難再複用以前所使用的格式化代碼。

  2. 獲取數據的接口形式不統一

    現在咱們調用 API 時,都會返回一個 Promise。但並非全部的數據來源都能轉換成 Promise,好比 WebSocket 怎麼轉換成 Promise 呢?結果就是在獲取數據的時候,要先調用 API,而後再監聽 WebSocket 的事件。或許還要同時再去監聽用戶的點擊事件等等。等於說有多個數據源影響同一個數據,對每個數據源都要分別寫一套對應的邏輯,十分囉嗦。

聰明的讀者可能會想到:只要把處理數據的邏輯和獲取數據的邏輯解耦不就能夠了嗎?很好,如今咱們有兩個問題了。

數據複雜

DCE 數據的複雜主要體如今下面三個方面:

  1. 從後端獲取的數據不能直接展現,要通過一系列複雜邏輯的格式化。

  2. 其中部分格式化邏輯還包括髮送請求。

  3. 數據之間存在着複雜的依賴關係。所謂依賴關係是指,必需要有 B 數據才能格式化 A 數據。下圖是 DCE 數據依賴關係的大致示意圖。


以格式化應用列表爲例,總共有這麼幾個步驟。讀者不須要徹底搞清楚,領會大意便可:

  1. 獲取應用列表的數據

  2. 獲取服務列表的數據。這是由於應用是由服務組成的,應用的狀態取決於服務的狀態,所以要格式化應用的狀態,就必須獲取服務列表的數據。

  3. 獲取任務列表的數據。服務列表裏其實也不包含服務的狀態,服務的狀態取決於服務的任務的狀態,所以要格式化服務的狀態,就必須獲取任務列表的數據。

  4. 格式化任務列表。

  5. 根據服務的 id 從任務列表中找到服務所對應的任務,而後根據任務的狀態,得出服務的狀態。

  6. 格式化 服務列表。

  7. 根據應用的 id 從服務列表中找到應用所對應的服務,而後根據服務的狀態,得出應用的狀態。順便還要把每一個應用的服務的數據塞到每一個應用裏,由於以後還要用到。

  8. 格式化應用列表。

  9. 完成!

這其中摻雜了同步和異步的邏輯,很是繁瑣,很是難以維護(肺腑之言)。何況,這還只是處理應用列表的邏輯,服務、容器、存儲、網絡等等列表須要獲取呢,而且邏輯也不比應用列表簡單。因此說,要想解耦獲取和處理數據的邏輯並不容易。由於處理數據這件事自己,就包括了獲取數據的邏輯。

如此複雜的依賴關係,常常會發送重複的請求。好比說我以前格式化應用列表的時候請求過服務列表了,下次要獲取服務列表的時候又得再請求一次服務列表。

聰明的讀者會想:我把數據緩存起來保管到一個地方,每次要格式化數據的時候,不要從新去請求依賴的數據,而是從緩存裏讀取數據,而後一股腦傳給格式化函數,這樣不就能夠了嗎?很好!如今咱們有三個問題了!

數據更新困難

緩存是個很好的想法。可是在 DCE 裏很難作,DCE 是一個對數據的實時性和一致性要求很是高的應用。

DCE 中幾乎全部數據都是會被全局使用到的。好比說應用列表的數據,不只要在應用列表中顯示,側邊欄裏也會顯示應用的數量,還有不少下拉菜單裏面也會出現它。因此若是一處數據更新了,另外一處沒更新,那就很是尷尬了。

還有就是以前提到的應用和服務的依賴關係。因爲應用是依賴服務的,理論上來講服務變了,應用也是要變的,這個時候也要更新應用的緩存數據。但事實上,由於數據的依賴樹實在是太深了(好比上圖中的應用和主機),有些依賴關係不那麼明顯,結果就會忘記更新緩存,數據就會不一致。

何時要使用緩存、緩存保存在哪裏、什麼時候更新緩存,這些是都是很是棘手的問題。

聰明讀者又會想:我用 redux 之類的庫,弄個全局的狀態樹,各個組件使用全局的狀態,這樣不就能保證數據的一致了嗎?這個想法很好的,可是會遇到上面兩個難點的阻礙。redux 在面對複雜的異步邏輯時就無能爲力了。

>>>> 結論

結果咱們會發現這三個難點每一個單獨看起來都有辦法能夠解決,可是合在一塊兒彷佛就成了無解死循環。所以,在通過普遍調研以後,咱們選擇了 RxJs。


爲何 RxJs 能夠解決咱們的困難

在說明咱們如何用 RxJs 解決上面三個難題以前,首先要說明 RxJs 的特性。畢竟 RxJs 目前仍是個比較新的技術,大部分人可能尚未接觸過,因此有必要給你們普及一下 RxJs。

  1. 統一了數據來源

    RxJs 最大的特色就是能夠把全部的事件封裝成一個 Observable,翻譯過來就是可觀察對象。只要訂閱這個可觀察對象,就能夠獲取到事件源所產生的全部事件。想象一下,全部的 DOM 事件、ajax 請求、WebSocket、數組等等數據,通通能夠封裝成同一種數據類型。這就意味着,對於有多個來源的數據,咱們能夠每一個數據來源都包裝成 Observable,統一給視圖層去訂閱,這樣就抹平了數據源的差別,解決了第一個難題。

  2. 強大的異步同步處理能力

    RxJs 還提供了功能很是強大且複雜的操做符( Operator) 用來處理、組合 Observable,所以 RxJs 擁有十分強大的異步處理能力,幾乎能夠知足任何異步邏輯的需求,同步邏輯更不在話下。它也抹平了同步和異步之間的鴻溝,解決了第二個難題。

  3. 數據推送的機制把拉取的操做變成了推送的操做

    RxJs 傳遞數據的方式和傳統的方式有很大不一樣,那就是改「拉取」爲「推送」。本來一個組件若是須要請求數據,那它必須主動去發送請求才能得到數據,這稱爲「拉取」。若是像 WebSocket 那樣被動地接受數據,這稱爲「推送」。若是這個數據只要請求一次,那麼採用「拉取」的形式獲取數據就沒什麼問題。可是若是這個數據以後須要更新,那麼「拉取」就無能爲力了,開發者不得不在代碼裏再寫一段代碼來處理更新。

    可是 RxJs 則不一樣。RxJs 的精髓在於推送數據。組件不須要寫請求數據和更新數據的兩套邏輯,只要訂閱一次,就能獲得如今和未來的數據。這一點改變了咱們寫代碼的思路。咱們在拿數據的時候,不是拿到了數據就萬事大吉了,還須要考慮將來的數據什麼時候獲取、如何獲取。若是不考慮這一點,就很難開發出具有實時性的應用。

    如此一來,就能更好地解耦視圖層和數據層的邏輯。視圖層今後不用再操心任何有關獲取數據和更新數據的邏輯,只要從數據層訂閱一次就能夠獲取到全部數據,從而能夠只專一於視圖層自己的邏輯。

  4. BehaviorSubject 能夠緩存數據。

    BehaviorSubject 是一種特殊的 Observable。若是 BehaviorSubject 已經產生過一次數據,那麼當它再一次被訂閱的時候,就能夠直接產生上次所緩存的數據。比起使用一個全局變量或屬性來緩存數據,BehaviorSubject 的好處在於它自己也是 Observable,因此異步邏輯對於它來講根本不是問題。這樣一來第三個難題也解決了。

這樣一來三個問題是否是都沒有了呢?不,這下其實咱們有了四個問題。


咱們是怎麼用 RxJs 解決困難的

相信讀者看到這裏確定是一臉懵逼。這就是第四個問題。RxJs 學習曲線很是陡峭,能參考的資料也不多。咱們在開發的時候,甚至都不肯定怎麼作纔是最佳實踐,能夠說是摸着石頭過河。建議你們閱讀下文以前先看一下 RxJs 的文檔,否則接下來確定十臉懵逼。

RxJs 真是太 TM 難啦!Observable、Subject、Scheduler 都是什麼鬼啦!Operator 怎麼有這麼多啊!每一個 Operator 後面只是加個 Map 怎麼變化這麼大啊!都是 map,爲何這個 map_.map 還不同啦!文檔還只有英文噠(如今有中文了)!我昨天還在寫 jQuery,怎麼一會兒就要寫這麼難的東西啊啊啊!!!(劃掉)

——來自實習生的吐槽

首先,給你們看一個總體的數據層的設計。熟悉單向數據流的讀者應該不會以爲太陌生。


  1. 從 API 獲取一些必須的數據

  2. 由事件分發器來分發事件

  3. 事件分發器觸發控制各個數據管道

  4. 視圖層拼接數據管道,得到用來展現的數據

  5. 視圖層經過事件分發器來更新數據管道

  6. 造成閉環

能夠看到,咱們的數據層設計基本上是一個單向數據流,確切地說是「單向數據樹」。

樹的最上面是樹根。樹根會從各個 API 得到數據。樹根的下面是樹幹。從樹幹分岔出一個個樹枝。每一個樹枝的終點都是一個能夠供視圖層訂閱的 BehaviorSubject,每一個視圖層組件能夠按本身的需求來訂閱各個數據。數據和數據之間也能夠互相訂閱。這樣一來,當一個數據變化的時候,依賴它的數據也會跟着變化,最終將會反應到視圖層上。

>>>> 設計詳細說明

  1. root(樹根)

    root 是樹根。樹根有許多觸鬚,用來吸取營養。咱們的 root 也差很少。一個應用總有一些數據是關鍵的數據,好比說認證信息、許可證信息、用戶信息。要使用咱們的應用,咱們首先得知道你登陸沒登陸,付沒付過錢對不對?因此,這一部分數據是最底層數據,若是不先獲取這些數據,其餘的數據便沒法獲取。而這些數據一旦改變,整個應用其餘的數據也會發生根本的變化。比方說,若是登陸的用戶改變了,整個應用展現的數據確定也會大變樣。

    在具體的實現中,root 經過 zip 操做符彙總全部的 api 的數據。爲了方便理解,本文中的代碼都有所簡化,實際場景確定遠比這個複雜。

    // 從各個 API 獲取數據
    const license$ = Rx.Observable.fromPromise(getLicense());
    const auth$ = Rx.Observable.fromPromise(getAuth());
    const systemInfo$ = Rx.Observable.fromPromise(getSystemInfo());
    // 經過 zip 拼接三個數據,當三個 API 所有返回時,root$ 將會發出這三個數據
    const root$ = Rx.Observable.zip(license$, auth$, systemInfo$);複製代碼

    當全部必須的的數據都獲取到了,就能夠進入到樹幹的部分了。

  2. trunk(樹幹)

    trunk 是咱們的樹幹,全部的數據都首先流到 trunk ,trunk 會根據數據的種類,來決定這個數據須要流到哪個樹枝中。簡而言之,trunk 是一個事件分發器。全部事件首先都彙總到 trunk 中。而後由 trunk 根據事件的類型,來決定哪些數據須要更新。有點相似於 redux 中根據 action 來觸發相應 reducer 的概念。

    之因此要有這麼一個事件分發器,是由於 DCE 的數據都是牽一髮而動全身的,一個事件發生時,每每須要觸發多個數據的更新。此時有一個統一的地方來管理事件和數據之間的對應關係就會很是方便。一個統一的事件的入口,能夠大大下降將來追蹤數據更新過程的難度。

    在具體的實現中,trunk 是一個 Subject。由於 trunk 不但要訂閱 WebSocket,同時還要容許視圖層手動地發佈一些事件。當有事件發生時,不管是 WebSocket 事件仍是視圖層發佈的事件,通過 trunk 的處理後,咱們均可以一視同仁。

    //一個產生 WebSocket 事件的 Observable
    const socket$ = Observable.webSocket('ws://localhost:8081');
    // trunk 是一個 Subject
    const trunk$ = new Rx.Subject()
     // 在 root 產生數據以前,trunk 不會發布任何值。trunk 以後的全部邏輯也都不會運行。
     .skipUntil(root$)
     // 把 WebSocket 推送過來的事件,合併到 trunk 中
     .merge(socket$)
     .map(event => {
       // 在實際開發過程當中,trunk 可能會接受來自各類事件源的事件
       // 這些事件的數據格式可能會大不相同,因此通常在這裏還須要一些格式化事件的數據格式的邏輯。
     });複製代碼
  3. branch(樹枝)

    trunk 的數據最終會流到各個 branch。branch 到底是什麼,下面就會提到。

    在具體的實現中,咱們在 trunk 的基礎上,用操做符對 trunk 所分發的事件進行過濾,從而建立出各個數據的 Observable,就像從樹幹中分出的樹枝同樣。

    // trunk 格式化好的事件的數據格式是一個數組,其中是須要更新的數據的名稱
    // 這裏經過 filter 操做符來過濾事件,給每一個數據建立一個 Observable。至關於於從 trunk 分岔出多條樹枝。
    // 好比說 trunk 發佈了一個 ['app', 'services'] 的事件,那麼 apps$ 和 services$ 就能獲得通知
    const apps$ = trunk$.filter(events => events.includes('app'));
    const services$ = trunk$.filter(events => events.includes('service'));
    const containers$ = trunk$.filter(events => events.includes('container'));
    const nodes$ = trunk$.filter(events => events.includes('node'));複製代碼

    僅僅如此,咱們的 branch 尚未什麼實質性的內容,它僅僅能接受到數據更新的通知而已,後面還須要加上具體的獲取和處理數據的邏輯,下面就是一個容器列表的 branch 的例子。

    // containers$ 就是從 trunk 分出來的一個 branch。
    // 當 containers$ 收到來自 trunk 的通知的時候,containers$ 後面的邏輯就會開始執行
    containers$
     // 當收到通知後,首先調用 API 獲取容器列表
     .switchMap(() => Rx.Observable.fromPromise(containerApi.list()))
     // 獲取到容器列表後,對每一個容器分別進行格式化。
     // 每一個容器都是做爲參數傳遞給格式化函數的。格式化函數中不包含任何異步的邏輯。
     .map(containers => containers.map(container, container => formatContainer(container)));複製代碼

    如今咱們就有了一個可以產生容器列表的數據的 containers$。咱們只要訂閱 containers$就能夠得到最新的容器列表數據,而且當 trunk 發出更新通知的時候,數據還可以自動更新。這是巨大的進步。

    如今還有一個問題,那就是如何處理數據之間的依賴關係呢?好比說,格式化應用列表的時候假如須要格式化好的容器列表和服務列表應該怎麼作呢?這個步驟在之前一直都十分麻煩,寫出來的代碼猶如意大利麪。由於這個步驟須要處理很多的異步和同步邏輯,這其中的順序還不能出錯,不然可能就會由於關鍵數據尚未拿到致使格式化時報錯。

    實際上,咱們能夠把 branch 想象成一個「管道」,或者「」。這兩個概念都不是新東西,你們應該比較熟悉。

    We should have some ways of connecting programs like garden hose—screw in another segment when it becomes necessary to massage data in another way.

    ——Douglas McIlroy

    若是數據是以管道的形式存在的,那麼當一個數據須要另外一個數據的時候,只要把管道接起來不就能夠了嗎?幸運的是,藉助 RxJs 的 Operator,咱們能夠很是輕鬆地拼接數據管道。下面就是一個應用列表拼接容器列表的例子。

    // apps$ 也是從 trunk 分出來的一個 branch
    apps$
     // 一樣也從 API 獲取數據
     .switchMap(() => Rx.Observable.fromPromise(appApi.list()))
     // 這裏使用 combineLatest 操做符來把容器列表和服務列表的數據拼接到應用列表中
     // 當容器或服務的數據更新時,combineLatest 以後的代碼也會執行,應用的數據也能獲得更新。
     .combineLatest(containers$, services$)
       // 把這三個數據一塊兒做爲參數傳遞給格式化函數。
       // 注意,格式化函數中仍是沒有任何異步邏輯,由於須要異步獲取的數據已經在上面的 combineLatest 操做符中獲得了。
     .map(([apps, containers, services]) => apps.map(app => formatApp(app, containers, services)));複製代碼
  4. 格式化函數

    格式化函數就是上文中的 formatAppformatContainer。它沒有什麼特別的,和 RxJs 沒什麼關係。

    惟一值得一提的是,之前咱們的格式化函數中充斥着異步邏輯,很難維護。因此在用 RxJs 設計數據層的時候咱們刻意地保證了格式化函數中沒有任何異步邏輯。即便有的格式化步驟須要異步獲取數據,也是在 branch 中經過數據管道的拼接獲取,再以參數的形式統一傳遞給格式化函數。這麼作的目的就是爲了將異步和同步解耦,畢竟異步的邏輯由 RxJs 處理更加合適,也更便於理解。

  5. fruit

    如今咱們只差緩存沒有作了。雖然咱們如今只要訂閱 apps$containers$ 就能獲取到相應的數據,可是前提是 trunk 必須要發佈事件才行。這是由於 trunk 是一個 Subject,假如 trunk 不發佈事件,那麼全部訂閱者都獲取不到數據。因此,咱們必需要把 branch 吐出來的數據緩存起來。 RxJs 中的 BehaviorSubject 就很是適合承擔這個任務。

    BehaviorSubject 能夠緩存每次產生的數據。當有新的訂閱者訂閱它時,它就會馬上提供最近一次所產生的數據,這就是咱們要的緩存功能。因此對於每一個 branch,還須要用 BehaviorSubject 包裝一下。數據層最終對外暴露的接口其實是 BehaviorSubject,視圖層所訂閱的也是 BehaviorSubject。在咱們的設計中,BehaviorSubject 叫做 fruit,這些通過層層格式化的數據,就好像果實同樣。

    具體的實現並不複雜,下面是一個容器列表的例子。

    // 每一個數據流對外暴露的一個藉口是 BehaviorSubject,咱們在變量末尾用$$,表示這是一個BehaviorSubject
    const containers$$ = new Rx.BehaviorSubject();
    // 用 BehaviorSubject 去訂閱 containers$ 這個 branch
    // 這樣 BehaviorSubject 就能緩存最新的容器列表數據,同時當有新數據的時它也能產生新的數據
    containers$.subscribe(containers$$);複製代碼
  6. 視圖層

    整個數據層到上面爲止就完成了,可是在咱們用視圖層對接數據層的時候,也走了一些彎路。通常狀況下,咱們只須要用 vue-rx 所提供的 subscriptions 來訂閱 fruit 就能夠了。

    <template>
     <app-list :data="apps"></app-list> </template>
    
    <script>
    import app$$ from '../branch/app.branch';
    
    export default {
     name: 'app',
     subscriptions: {
       apps: app$$,
     },
    };
    </script>複製代碼

    但有些時候,有些頁面的數據很複雜,須要進一步處理數據。遇到這種狀況,那就要考慮兩點。一是這個數據是否在別的頁面或組件中也要用,若是是的話,那麼就應該考慮把它作進數據層中。若是不是的話,那其實能夠考慮在頁面中單獨再建立一個 Observable,而後用 vue-rx 去訂閱這個 Observable。

    還有一個問題就是,假如視圖層須要更新數據怎麼辦?以前已經提到過,整個數據層的事件分發是由 trunk 來管理的。所以,視圖層若是想要更新數據,也必須取道 trunk。這樣一來,數據層和視圖層就造成了一個閉環。視圖層根本不用擔憂數據怎麼處理,只要向數據層發佈一個事件就能所有搞定。

    methods: {
     updateApp(app) {
       appApi.update(app)
         .then(() => {
           trunk$.next(['app'])
         })
     },
    },複製代碼

下面是整個數據層設計的全貌,供你們參考。


總結

以後的開發過程證實,這一套數據層很大程度上解決了咱們的問題。它最大的好處在於提升了代碼的可維護性,從而使得開發效率大大提升,bug 也大大減小。

咱們對 RxJs 的實踐也是剛剛開始,這一套設計確定還有不少可改進的地方。若是你們對本文有什麼疑惑或建議,能夠寫郵件給 bowen.tan@daocloud.io,還望你們不吝賜教。

點擊瞭解 DaoCloud Enterprise

相關文章
相關標籤/搜索