用 RxJS 實現一個協同編輯的表格應用

大搜車無線開發中心發表於2018-02-09

用 RxJS 實現一個協同編輯的表格應用
下面這張圖幾乎可以代表所有軟體的模型了:

輸入 -> 計算過程 -> 輸出
複製程式碼

如果輸入和輸出都是數字,那麼這個軟體有可能是一個數學計算型別的軟體;如果輸入和輸出都是字串,那麼這個軟體有可能是一個文字處理型別的軟體。這些都是比較純粹的型別,可以把注意力集中在演算法的實現上。再來看一個略複雜的情況,輸入的個數不止一個,有使用者的點選操作、使用者的鍵盤輸入、使用者聲音和影象的變化、來自資料庫的資料以及一系列隨時間和空間變化的條件,輸出則是各種螢幕上的影象。

大家應該已經看出來了,最後一種軟體型別就是 web 前端應用。將上面的模型具體一下:

輸入 -> store -> element tree -> 輸出
複製程式碼

store 可以理解為存放資料的地方,element tree 則表示能表達檢視的一棵樹。element tree 到輸出的複雜度已經被 HTML + CSS 等技術解決了,而 store 到 element tree 的複雜度已經被前端 MV* 框架解決了。然而從繁多複雜的輸入到 store 的複雜度如何解決呢?RxJS 是一個值得嘗試的選擇。

第一次聽說 RxJS 的時候就被它吸引了,它可以把各種各樣的輸入(尤其是和和時間相關的輸入)通過包裝、組合和轉換變成成有用的資料,根據上面的軟體模型,RxJS 是整個複雜度解決方案的最後一塊拼圖。

一個相對複雜的示例

為了能發揮出 RxJS 的威力,做了一個互動複雜一點示例,倉庫地址是 github.com/xxapp/rxjs-…,它是一個支援協同編輯的表格應用,支援拖拽選擇單元格,編輯單元格並且支援多個使用者同時編輯,可以在 github 專案首頁看到實際效果圖。

先來分析下需求:

  1. 一個基礎的表格
  2. 根據滑鼠的拖動顯示單元格選區
  3. 當一個單元格被連續點了兩次,進入編輯狀態
  4. 當一個單元格被點了一次再按下鍵盤按鍵,也進入編輯狀態
  5. 退出編輯狀態時,更新表格內容,並把更新內容同步到其它正在編輯的使用者
  6. 顯示當前同時編輯的使用者數

按照常規的思路,可以提煉出下面這些資料:

  1. 表格的行數和列數
  2. 選區的起止位置資訊
  3. 一個表示哪個單元格正在被編輯的狀態
  4. 表格內容,一個二維陣列
  5. 當前同時編輯表格的使用者數

如果我們能讓這些資料在正確的時間表示正確的值的話,我們就可以得出正確的效果了。按照常規方法,我們可以監聽各種事件,然後修改上面這些資料,重新渲染。這裡 RxJS 用的是另一種思路,將軟體開發比作自然水源的運輸和過濾處理,RxJS 不是大自然的搬運工(更不生產水),RxJS 是大自然的流水線,只要流水線建成,水會自己流進流水線,出來的時候就是能直接飲用的水了。在軟體開發中想建這樣的流水線就需要考慮如何將輸入轉換成需要的資料,RxJS 為我們提供了建設流水線的基礎能力,比如對資料來源和事件的封裝與流操作符。

最後我們只需要訂閱這個流進行渲染就好了 stream$.subscribe(renderFn)

流水線

表格的行數和列數

RxJS 可以封裝靜態資料,如果有一天這個靜態資料需要改為從後端獲取,這種包裝的價值就體現出來了,因為渲染程式碼始終從 subscribe 獲取資料,不關心資料是同步的還是非同步的。

const tableFrame$ = Rx.Observable.of([ROW_COUNT, COLUMN_COUNT]);
複製程式碼

選區的起止位置資訊

效果圖如下,我們需要滑鼠按下時的位置和滑鼠移動過程中的位置,直到滑鼠鬆開。

selection

這個功能涉及的事件型別比較多,轉換過程相對複雜一些,可以用 Marble 圖來表示這個過程。

   mousedown                        mouseup
       ↓switchMap                      ↑takeUntil
---mousemove--mousemove--mousemove--mousemove-----|-->
                 map(getPosition)
------pos1-------pos2-------pos3-------pos4-------|-->
        distinctUntilChanged(isPositionEqual)
------pos1-------pos2------------------pos4-------|-->
                      scan
-------------------------------------posRange-----|-->
複製程式碼

首先我們想讓每個滑鼠事件都進入流水線,RxJS 提供了 fromEvent 的包裝方法將其包裝成“流”,然後可以看到這裡使用了很多流操作符,如 swithMap、takeUntil、map 和 scan 等等。“2 號流水線”的程式碼實現如下。

mousedown$
    .switchMap(() => mousemove$.takeUntil(mouseup$))
    .map(e => getPosition(e.target))
    .distinctUntilChanged((p, q) => isPositionEqual(p, q))
    .scan((acc, pos) => {
        if (!acc) {
            return { startRow: pos.row, startColumn: pos.column, endRow: pos.row, endColumn: pos.column };
        } else {
            return Object.assign(acc, { endRow: pos.row, endColumn: pos.column });
        }
    }, null);
複製程式碼

單元格正在被編輯的狀態

上面提到有兩種方式可以進入編輯狀態,所以我們要打造一個由兩條分支匯聚到一起的一條流水線,一個關鍵的流操作符是 merge。

第一個分支是連續兩次點選同一個單元格,這個單元格就會進入編輯狀態。入門了 RxJS 後,實際上程式碼就可以解釋其自身的功能的。為了正在學習的同學理解,在貼程式碼之前先說明一下 bufferCount 這個運算子的功能,使用 Marble:

---1-------2-------3-------4------|-->
         bufferCount(2, 1)
-----------[1, 2]--[2, 3]--[3, 4]-|-->
複製程式碼

接下來上程式碼:

const click$ = Rx.Observable.fromEvent(table, 'click').filter(e => e.target.nodeName === 'TD');
const doubleClick$ = click$
    .bufferCount(2, 1)
    .filter(([e1, e2]) => e1.target.id === e2.target.id)
    .map(([e]) => e);
複製程式碼

第二個分支是在一個單元格上按下鍵盤按鍵,進入編輯狀態。操作符都不需要(需要給單元格設定 tabindex 屬性):

const keyDown$ = Rx.Observable.fromEvent(table, 'keydown').filter(e => e.target.nodeName === 'TD');
複製程式碼

兩個分支都有了,讓它們合併也很簡單:

doubleClick$.merge(keyDown$)
複製程式碼

表格內容

表格內容的變化也有兩個途徑,一個是當前使用者的編輯,另一個是其它使用者的編輯。

得到當前使用者輸入的值很簡單,對於來自於其它使用者輸入的值,這裡搭建了一個簡單的 websocket 服務,當一個使用者修改了一個單元格的值後,就通過伺服器向其他正在編輯的使用者廣播更新。socket.io 這個庫使用非常方便,和 RxJS 結合得也非常好。比如我們可以用下面的方法將來自其他使用者的資料封裝成一個流:

const socket = io();
const dataSync$ = Rx.Observable.fromEvent(socket, 'sync');
複製程式碼

當前同時編輯表格的使用者數

這個使用者數是服務端維護的,也需要 websocket 來實時地將使用者數推送給前端。

const socket = io();
const dataSync$ = Rx.Observable.fromEvent(socket, 'uid');
複製程式碼

使用資料

前面說了 RxJS 解決了從輸入到 store 的複雜度,那資料怎麼用就和 RxJS 沒關係了,這個例子使用了原生 DOM 操作將資料渲染成 UI,當然也可以使用一些前端框架來實現這個過程。

最後

學習 RxJS 需要轉換思想,其中一部分來源於函式式的程式設計思想。就像從 jQuery 轉到 angular 一樣,習慣了原來的寫法,這個轉換的過程就會相當痛苦。在寫這個例子的時候,思想就有點轉變不過來,總想著搞一個狀態,然後修改這個狀態。除了轉換思想外,另一個難點是決定什麼時候用什麼運算子,著實需要費一番功夫。

如果把原始碼中用於渲染的程式碼去掉,只看 RxJS 的實現部分,可以發現程式碼結構十分單一且一致,就像樂高積木一樣,不管多麼複雜的邏輯,都可以通過組合來實現,這留給我們很大的想象空間,RxJS 的魅力也在於此。

相關文章