Rxjs 接近業務本質的流式思惟 與 建模入門

本文介紹如何使用 Rx 的響應式編程思惟來對業務邏輯進行建模, 你會了解到響應式編程的優點和業務抽象能力, 學會將現有的業務流程以數據流的方式表達出來. 你的工具庫中不能少了 Rx 這件利器.react

Rx 學習曲線陡峭是總所周知的, 咱們接觸的大部分編程語言或框架都是面向對象的. 在面對 Rx 這響應式編程的方式, 會以爲無從入手, 筆者也是 Rx 的初學者, 拜讀過屢次徐飛Rx 的相關文章, 基本上都是雲裏霧裏. 主要緣由仍是思惟沒有轉換過來.git

若是你不理解響應式編程的奧妙,是很難在'面向場景編程'時考慮到 Rx 的優點. 筆者通常遵循'面向場景編程', 即在對應的場景考慮不一樣的技術或框架. 多是痛點尚未到難以忍受的地步,或許是現有應用還不夠複雜,我目前爲止還沒接觸到必需要應用 Rx 的場景.github

我以爲應該反過來,採起刻意學習的方式來學習 Rx, 以流的方式來思考,再將其放在現有的場景中看是否有更簡潔的解決方案或化學反應. 不得不說寫 Rx 是一個比較有趣的事情。 但也要認識到 Rx 不是萬金油,正如不少教程所說的 Rx 擅長複雜的異步協調,並非全部場景都適合,一些問題有更簡潔的解決方案typescript


Rx 的建模過程

對於 Rx 的入門者, 可使用下面的流程, 一步一步將業務邏輯轉換爲 Rx 數據流來進行表達.shell

流程圖 -> 數據流抽象 -> 實現
複製代碼

① 流程圖

首先從流程圖開始, 這個階段沒什麼特別的, 不論是響應式編程仍是其餘範式, 編碼以前都須要縷清業務邏輯.編程

這個階段使用流程圖來描述技術無關的事務過程, 讓業務邏輯更加清晰, 也方便咱們識別業務流程的主體和關鍵事件.redux

什麼是業務邏輯? wiki 上這樣定義:
Business logic or domain logic is that part of the program which encodes the real-world business rules that determine how data can be created, displayed, stored, and changed. It prescribes how business objects interact with one another, and enforces the routes and the methods by which business objects are accessed and updated.
Business Rules describe the operations, definitions and constraints that apply to an organization. The operations collectively form a process; every business uses these processes to form systems that get things done.bash



② 數據流抽象

Rx 的世界裏面一切皆流, 換句話說就是面向流編程. 和面向對象編程把現實世界的實體抽象爲對象同樣. 響應式編程將業務中的變更實體(想不到更好的詞, 或者變量?)抽象爲流併發

(1)首先須要識別什麼是變更實體? 變更實體通常是數據流的源頭, 它驅動着業務走向. 像河流同樣, 源頭可能不僅一個. 我認爲變更實體至少具有如下特徵之一:app

  • 它是變更的. 例如鼠標的位置, 商品的價格, 隨着時間的遷移狀態會進行變更
  • 它是業務的'輸入'. 變更實體是一個系統的輸入(外部事件)或者是另外一個流(衍生)的輸入.
  • 它是業務的參與者(或者說業務的主體).
  • 它表示業務的狀態. 例如一個 todo 列表, 這是一個純狀態的流

(2)接着識別變更實體之間的關係. 主體之間的關係也就是流之間的關係, 這是 Rx 建模的核心. 只有理解了主體之間的關係, 才能將主體與業務流程串聯起來, 才能真正地使用數據流的方式將業務表達出來. 在從新理解響應式編程一文中對'響應式編程'的定義和筆者的理解很是契合:

響應式編程是一種經過異步和數據流來構建事務關係的編程模型 . 事務關係是響應式編程的核心理念, 「數據流」和「異步」是實現這個核心理念的關鍵.

這種關係和麪向對象的類關係是不同的, 面向對象的關係通常是指依賴關係. 而數據流之間關係, 是業務之間的實際關係, 好比流程 b 依賴流程 a, 數據流是變更實體之間的溝通橋樑.

通常如下面的方法來構建流之間的關係:

  • 分治: 將業務劃分爲多個模塊(流), 一個大的流老是由小的流組成, 小的流職責更單一, 更容易理解和測試
  • 變換: 將流映射爲另一個流. 通常用於狀態變動或業務衍生(高階流變換)
  • 合併: 像河流同樣, 數據流最終是須要匯聚在一塊兒注入大海的. 拆分和合並的方式都是依賴於所要表達的業務邏輯

總的來講變更實體通常就是業務的'輸入', 咱們首先把它們肯定爲流, 再根據關係衍生出其餘流(輸出). 對於流自己來講, 本質上只有輸入和輸出的關係:

stream

例如 increment$和decrement$就是 action$的輸入, action$就是 count$的輸入, 以此類推. 響應式編程將複雜業務關係轉換成原始的輸出/輸出關係

(3)符合函數式編程的約束. 通常來講, 咱們說的響應式編程指的是函數式響應式編程(Functional reactive programming FRP), 因此須要符合函數式的一些約束:

  • 純函數(Pure): 函數只是輸入參數到輸出結果的映射, 不要產生反作用
    • 沒有共享狀態: 不依賴外部變量來維護流程的狀態.
    • 冪等性: 冪等性在複雜流程中很重要, 這使得整個流程可被重試
    • 沒有反作用: 可預測, 可測試.
  • 不可變性(Immuatability): 數據一旦產生, 就確定它的值不會變化, 這有利於代碼的理解. 易於併發
  • 聲明式(Declarative):
    • 函數式編程和命令式編程相比有較高的抽象級別, 他可讓你專一於定義與事件相互依存的業務邏輯, 而不是在實現細節上. 換句話說, 函數式編程定義關係, 而命令式編程定義步驟
    • 集中的邏輯. Rx 天然而然在一處定義邏輯, 避免其餘範式邏輯分散在代碼庫的各個地方. 另外 Rx 的 Observable 經過訂閱來建立資源, 經過取消訂閱來釋放資源, 通常開發幾乎不須要去關心資源的生命週期, 例如時間器.

這個階段將第一個階段的流程圖轉換爲 Rx 彈珠圖(Marble Diagrams)表示, 彈珠圖能夠描述流之間關係, 表現'時間'的流逝, 讓複雜的數據流更容易理解



③ 實現

這個階段就是把彈珠圖翻譯爲實現代碼, 根據需求在 rxjs 工具箱中查找合適的操做符. 當縷清了業務邏輯, 使用數據流進行建模後, 代碼實現就是一件很簡單的事情了.

能夠配合 Rxjs 官方的操做符決策樹選擇合適的操做符




下面使用例子來體會 Rx 的編程思惟:

Example 1: c := a + b

這是最簡單的實例, 咱們指望當 a 和 b 變更時可以響應到 c, 咱們按照上述的步驟對這個需求進行建模:

  • 流程:

    c=a+b

  • 數據流抽象: 從上能夠識別出兩個變更的實體 a 和 b, 因此 a 和 b 均可以視做流, 那麼 c 就是 a 和 b 衍生出來的流, 表示 a 和 b 的實時加法結果, 使用彈珠圖來描述三者的關係:

    a$: ----1------------2---------------
    b$: --2-------4------------6------8------
                  \ (a + b) /
    c$: ----3-----5------6-----8------10-----
    複製代碼
  • 代碼實現: 由彈珠圖能夠看出, c$流的輸出值就是a$和 b$輸出值的實時計算結果, 也就是說c$接收來自 a$和b$ 的最新數據, 輸出他們的和. 另外由本來的兩個流合併爲單個流, 在 rxjs 工具箱中能夠找到combineLatest操做符符合該場景. 代碼實現以下:

    const a$ = interval(1000);
    const b$ = interval(500);
    
    a$.pipe(combineLatest(b$))
      .pipe(map(([a, b]) => a + b))
      .subscribe(sum => console.log(sum));
    複製代碼



Example 2: 元素拖拽的例子

元素拖拽也是 Rx 的經典例子的的例子. 假設咱們須要先移動端和桌面端都支持元素拖拽移動.

流程圖

數據流抽象

這裏使用分治的方法, 將流程進行一步步拆解, 而後使用彈珠圖的形式進行描述.

由上面的流程圖能夠識別出來, down, move 以及 up 都是變更實體, 咱們能夠將他們視做'流'.

① down/move/up 都是抽象的事件, 在桌面端下是 mousedown/mousemove/mouseup, 移動端下對應的是 touchstart/touchmove/touchend. 咱們不區分這些事件, 例如接收到 mousedown 或 touchstart 事件都認爲是一個'down'事件. 因此事件監聽的數據流如:

# 1
mousedown$ : ---d----------d--------
touchstart$: -s---s-----------s-----
        \(merge)/
down$ : -s-d-s--------d--s-----
複製代碼

move 和 up 事件同理

② 接下來要識別 up$, move$, down$ 三個數據流之間的關係, down 事件觸發後咱們纔會去監聽 move 和 up 事件, 也就是說由 down$能夠衍生出 move$和 up$流. 在 up 事件觸發後整個流程就終止. up$流決定了整個流程的生命週期的結束

使用彈珠圖的描述三者的關係以下:

# 2
down$: -----d-------------------------
             \
up$ : ----------u|
move$: -m--m--m---|
複製代碼

③ 一個拖拽結束後還能夠從新再發起拖拽, 即咱們會持續監聽 down 事件. 上面的流程還規定若是當前拖拽還未結束, 其餘 down 事件應該被忽略, 在移動端下多點觸摸是可能致使多個 down 事件觸發的.

# 3
down$: ---d---d--d---------d------ # 中間兩個事件由於拖拽未完成被忽略
           \                \
up$: -----u| ------u|
move$: -m-mm-| m-m-m--|
複製代碼

實現:

有了彈珠圖後, 就是把翻譯問題了, 如今就打開 rxjs 的工具箱, 找找有什麼合適的工具.

首先是抽象事件的處理. 由#1 能夠看出, 這就是一個數據流合併, 這個適合使用merge

merge(fromEvent(el, 'touchstart'), fromEvent(el, 'mousedown'));
複製代碼

down$流的切換可使用exhaustMap操做符, 這個操做符能夠將輸出值映射爲Observable, 最後再使用exhaust操做符對Observable進行合併. 這能夠知足咱們'當一個拖拽未結束時, 新發起的 down$輸出會被忽略, 直到拖拽完結'的需求

down$
  .pipe(
    exhaustMap(evt => /* 轉換爲新的Observable流 */)
複製代碼

使用 exhaustMap 來將 down$輸出值轉換爲move$ 流, 並在 up$ 輸出後結束, 可使用takeUntil操做符:

down$
  .pipe(
    exhaustMap(evt => {
      evt.preventDefault();
      if (evt.type === 'mousedown') {
        // 鼠標控制
        const { clientX, clientY } = evt as MouseEvent;
        return mouseMove$.pipe(
          map(evt => {
            return {
              deltaX: (evt as MouseEvent).clientX - clientX,
              deltaY: (evt as MouseEvent).clientY - clientY,
            };
          }),
          takeUntil(mouseUp$),
        );
      } else {
        // 觸摸事件
        const { touches } = evt as TouchEvent;
        const touch = touches[0];
        const { clientX, clientY } = touch;

        const getTouch = (evt: TouchEvent) => {
          const touches = Array.from(evt.changedTouches);
          return touches.find(t => t.identifier === touch.identifier);
        };
        const touchFilter = filter((e: Event) => !!getTouch(e as TouchEvent));

        return touchMove$.pipe(
          touchFilter,
          map(evt => {
            const touch = getTouch(evt as TouchEvent)!;
            return {
              deltaX: touch.clientX - clientX,
              deltaY: touch.clientY - clientY,
            };
          }),
          takeUntil(touchUp$.pipe(touchFilter)),
        );
      }
    }),
  )
  .subscribe(delta => {
    el.style.transform = `translate(${delta.deltaX}px, ${delta.deltaY}px)`;
  });
複製代碼



Example 3: Todos

若是使用 rxjs 來建立 Todos 應用, 首先是流程圖:

數據流抽象:

首先識別變更的實體, 變更的實體就是 todos 列表, 因此能夠認爲 todos 列表就是一個流. 它從 localStorage 中恢復 初始化狀態. 由新增, 刪除等事件觸發狀態改變, 這些事件也能夠視做流

add$: --a-----a------
modify$: ----m----------
remove$ -------r-------
complete$: ------c----c---
             \(merge)/
update$ --a-m-cra--c--- # 各類事件合併爲update$流
              \(reduce)/
todos$: i-u-u-uuu--u---- # i 爲初始化數據, update$的輸出將觸發從新計算狀態
複製代碼

todos$流會響應到 view 上, 另外一方面須要持久化到本地存儲. 也就是說這是一個多播流.

todos$: i-u-u-uuu--u---- #
          \(debounce)/
save$ i--u--u---u----- # 存儲流, 使用debounce來避免頻繁存儲
複製代碼

並行渲染到頁面:

todos$: i-u-u-uuu--u---- #
       \(render)/
dom$: i--u--u---u----- # dom渲染, 假設也是流(cycle.js就是如此)
複製代碼

這個實例的數據流和 Redux 的模型很是像, add$, modify$, remove$和complete$就是 Action, todos 流會使用 相似 Reducer 的機制來處理這些 Action 生成新的 State

redux

代碼實現:

首先 add$, modify$以及 remove$和complete$能夠分別使用一個 Subject 對象來表示, 用於接收外部事件. 其實還能夠簡化爲一個流, 它們的區別只是參數

interface Action<T = any> {
  type: string;
  payload: T;
}

const INIT_ACTION = 'INIT'; // 初始化
const ADD_ACTION = 'ADD';
const REMOVE_ACTION = 'REMOVE';
const MODIFY_ACTION = 'MODIFY';
const COMPLETE_ACTION = 'COMPLETE';

const update$ = new Subject<Action>();

function add(value: string) {
  update$.next({
    type: ADD_ACTION,
    payload: value,
  });
}

function remove(id: string) {
  update$.next({
    type: REMOVE_ACTION,
    payload: id,
  });
}

function complete(id: string) {
  update$.next({
    type: COMPLETE_ACTION,
    payload: id,
  });
}

function modify(id: string, value: string) {
  update$.next({
    type: MODIFY_ACTION,
    payload: { id, value },
  });
}
複製代碼

建立todos$流, 對update$ 的輸出進行 reduce:

/** * 初始化Store */
function initialStore(): Store {
  const value = window.localStorage.getItem(STORAGE_KEY);
  return value ? JSON.parse(value) : { list: [] };
}

const todos$ = update$.pipe(
  // 從INIT_ACTION 觸發scan初始化
  startWith({ type: INIT_ACTION } as Action),
  // reducer
  scan<Action, Store>((state, { type, payload }) => { return produce(state, draftState => { let idx: number; switch (type) { case ADD_ACTION: draftState.list.push({ id: Date.now().toString(), value: payload, }); break; case MODIFY_ACTION: idx = draftState.list.findIndex(i => i.id === payload.id); if (idx !== -1) { draftState.list[idx].value = payload.value; } break; case REMOVE_ACTION: idx = draftState.list.findIndex(i => i.id === payload); if (idx !== -1) { draftState.list.splice(idx, 1); } break; case COMPLETE_ACTION: idx = draftState.list.findIndex(i => i.id === payload); if (idx !== -1) { draftState.list[idx].completed = true; } break; default: } }); }, initialStore()), // 支持多播 shareReplay(), ); // 持久化 todos$.pipe(debounceTime(1000)).subscribe(store => { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); }); 複製代碼

更多例子: 徐飛在"RxJS 入門指引和初步應用>"提到了一個"幸福人生"的例子, 挺有意思, 讀者能夠嘗試對其進行建模




通過上述過程, 能夠深入體會到函數響應式編程優點:

  • 數據流抽象了不少現實問題. 也就說數據流對業務邏輯的表達能力流程圖基本一致. 能夠說彈珠圖是流程圖的直觀翻譯, 而 Rx 代碼則是彈珠圖的直觀翻譯. 使用 Rx 以聲明式形式編寫代碼, 可讓代碼更容易理解, 由於它們接近業務流程.
  • 把複雜的問題分解成簡單的問題的組合. Rx 編程本質上就是數據流的分治和合並

相關資料

相關文章
相關標籤/搜索