使用 redux-observable 實現元件自治

吳曉軍發表於2018-08-19

本文是 《使用 RxJS + Redux 管理應用狀態》系列第一篇文章,旨在介紹 redux-obervable v1 版本為 React + Redux 帶來的元件自治能力。

本系列的文章地址彙總:

redux-observable 簡介

redux-observable 是 redux 一箇中介軟體,使用了 RxJs 來驅動 action 副作用。與其目的類似的有大家比較熟悉的 redux-thunkredux-saga。通過整合 redux-observable,我們可以在 Redux 中使用到 RxJS 所提供的函式響應式程式設計(FRP)的能力,從而更輕鬆的管理我們的非同步副作用(前提是你熟悉了 RxJS)。

Epic 是 redux-observable 的核心概念和基礎型別,幾乎承載了 redux-observable 的所有。從形式上看,Epic 是一個函式,其接收一個 action stream,輸出一個新的 action stream:

function (action$: Observable<Action>, state$: StateObservable<State>): Observable<Action>
複製程式碼

可以看到,Epic 扮演了 stream 轉換器的能力。

在 redux-observable 的視角下,Redux 作為中央狀態收集器,當一個 action 被 dispatch,歷經某個同步或者非同步任務,將 dispatch 一個新的 action,攜帶著它的負載(payload)到 reducer,如此反覆。這麼看的話,Epic 定義了 action 因果關係。

同時,FRP 模式的 RxJS 還帶來了如下能力:

  • 競態處理能力
  • 宣告式地任務處理
  • 測試友好
  • 元件自治(redux-observable 1. 0 開始支援)

實踐

本系列是假定讀者有了 FRP 和 RxJS 的基礎,因此,關於 RxJS 和 redux-observable 不再贅述。

現在,我們實踐一個常見的業務需求 —— 列表頁。通過這個例子,將展示 redux-observable 1.0 新的特性,並展示在 1.0 下實現的元件自治。

元件自治:元件只用關注如何治理自己。

先看到列表頁的訴求:

  • 間隔一段時間輪詢資料列表
  • 支援搜尋,觸發搜尋時,重新輪詢
  • 支援欄位排序,排序狀況變動,重新輪詢
  • 支援分頁,頁面容量修改,分頁狀況變動,重新輪詢
  • 元件解除安裝時,結束輪詢

在前端元件化開發的思路下,我們可能會設計如下容器元件(Container),其中基礎元件基於 ant design

  • 資料表格(含分頁):基於 Table 元件

  • 搜尋框::基於 Input 元件

  • **排序選擇框:**基於 **Select ** 元件

在 React + Redux 的架構下,容器元件通過 connect 方法從狀態樹上採摘自己所需要的狀態,因此,先要認識到,這些容器元件必定存在一個耦合—— Redux:

列表頁

接下來將會討論兩種不同的模式下,列表應用的狀態管理和副作用處理,它們分別是基於 redux-thunk 或者 redux-saga 的傳統模式,以及基於 redux-observable 的 FRP 模式。大家可以看到不同模式下,除了基礎的對於 Redux 的耦合,元件及其資料生態(狀態與副作用)上耦合狀況的差異。

當然,為了讓大家更好的理解文章,我也撰寫了一個 demo,大家可以 clone & run。接下來的程式碼也都來源於這個 demo。demo 一個 github 小應用,其中你看到使用者列表背後是基於 FRP 模式的,Repo 列表則是基於傳統模式的:

使用 redux-observable 實現元件自治

傳統模式下元件的耦合

在傳統的模式下,我們需要面對一個現實,對於狀態的獲取是**主動式(proactive)**的:

const state = store.getState()
複製程式碼

亦即我們需要主動取用狀態,而無法監聽狀態變化。因此,在這種模式下,我們元件化開發的思路會是:

  • 元件掛載,開啟輪詢
    • 搜尋時,結束上次輪詢,構建新的請求引數,開始新的輪詢
    • 排序變動時,結束上次輪詢,構建新的請求引數,開始新的輪詢
    • 分頁變動時,結束上次輪詢,構建新的請求引數,開始新的輪詢
  • 元件解除安裝,結束輪詢

在這種思路下,我們撰寫搜尋,排序,分頁等容器時,當容器涉及的取值變動時,不僅需要在狀態樹上更新這些值,還需要去重啟一下輪詢。

元件耦合

假定我們使用 redux-thunk 來處理副作用,程式碼大致如下:

let pollingTimer: number = null

function fetchUsers(): ThunkResult {
  return (dispatch, getState) => {
    const delay = pollingTimer === null ? 0 : 15 * 1000
    pollingTimer = setTimeout(() => {
      dispatch({
        type: FETCH_START,
        payload: {}
      })
      const { repo }: { repo: IState } = getState()
      const { pagination, sort, query } = repo
      // 封裝引數
      const param: ISearchParam = {
        // ...
      }
      // 進行請求
      // fetch(param)...
  }, delay)
}}

export function polling(): ThunkResult {
  return (dispatch) => {
    dispatch(stopPolling())
    dispatch({
      type: POLLING_START,
      payload: {}
    })
    dispatch(fetchUsers())
  }
}

export function stopPolling(): IAction {
  clearTimeout(pollingTimer)
  pollingTimer = null
  return {
    type: POLLING_STOP,
    payload: {}
  }
}

export function changePagination(pagination: IPagination): ThunkResult {
  return (dispatch) => {
    dispatch({
      type: CHANGE_PAGINATION,
      payload: {
        pagination
      }
    })
    dispatch(polling())
  }
}

export function changeQuery(query: string): ThunkResult {
  return (dispatch) => {
    dispatch({
      type: CHANGE_QUERY,
      payload: {
        query
      }
    })
    dispatch(polling())
  }
}

export function changeSort(sort: string): ThunkResult {
  return (dispatch) => {
    dispatch({
      type: CHANGE_SORT,
      payload: {
        sort
      }
    })
    dispatch(polling())
  }
}
複製程式碼

可以看到,涉及到請求引數的幾個元件,如篩選專案,分頁,搜尋等,當它們 dispatch 了一個 action 修改對應的業務狀態後,還需要手動 dispatch 一個重啟輪詢的 action 結束上一次輪詢,開啟下一次輪詢

或許這個場景的複雜程度你覺得也還能接受,但是假想我們有一個更大的專案,或者現在的專案未來會擴充套件得很大,那麼元件勢必會越來越多,參與協作的開發者也會越來越多。協作的開發者就需要時刻關注到自己撰寫的元件是否會是其他開發者撰寫的元件的影響因子,如果是的話,影響有多大,又該怎麼處理?

這裡提到的元件不單純指 UI Component,還包括了元件涉及的資料生態。因為絕大部分前端開發者撰寫業務元件時,除了 UI,還要實現 UI 涉及的業務邏輯。

我們歸納下使用傳統模式梳理資料流以及副作用面臨的問題:

  1. 程式式程式設計,程式碼囉嗦
  2. 競態處理需要人為地通過標誌量等進行控制
  3. 元件間耦合大,彼此牽連。

FRP 模式與元件自治

在 FRP 模式下,遵循 passive 模式,state 應當被觀察和響應,而不是主動獲取。因此,redux-observable 從 1.0 開始,不再推薦使用 store.getState() 進行狀態獲取,Epic 有了新的函式簽名, 第二個引數為 state$

function (action$: Observable<Action>, state$: StateObservable<State>): Observable<Action>
複製程式碼

state$ 的引入,讓 redux-observable 達到了它的里程碑,現在,我們能在 Redux 中更進一步地實踐 FRP。比如下面這個例子(來源自 redux-observable 官方),當 googleDocument 狀態變動時,我們就自動儲存 google 文件:

const autoSaveEpic = (action$, state$) =>
  action$.pipe(
    ofType(AUTO_SAVE_ENABLE),
    exhaustMap(() =>
      state$.pipe(
        pluck('googleDocument'),
        distinctUntilChanged(),
        throttleTime(500, { leading: false, trailing: true }),
        concatMap(googleDocument =>
          saveGoogleDoc(googleDocument).pipe(
            map(() => saveGoogleDocFulfilled()),
            catchError(e => of(saveGoogleDocRejected(e)))
          )
        ),
        takeUntil(action$.pipe(
          ofType(AUTO_SAVE_DISABLE)
        ))
      )
    )
  );
複製程式碼

回過頭來,我們還可以將列表頁的需求概括為:

  • 間隔一段時間輪詢資料列表
  • 引數(排序,分頁等)變動時,重新發起輪詢
  • 主動進行搜尋時,重新發起輪詢
  • 元件解除安裝時結束輪詢

在 FRP 模式下,我們定義一個輪詢 epic:

const pollingEpic: Epic = (action$, state$) => {
  const stopPolling$ = action$.ofType(POLLING_STOP)
  const params$: Observable<ISearchParam> = state$.pipe(
    map(({user}: {user: IState}) => {
      const { pagination, sort, query } = user
      return {
        q: `${query ? query + ' ' : ''}language:javascript`,
        language: 'javascript',
        page: pagination.page,
        per_page: pagination.pageSize,
        sort,
        order: EOrder.Desc
      }
    }),
    distinctUntilChanged(isEqual)
  )

  return action$.pipe(
    ofType(LISTEN_POLLING_START, SEARCH),
    combineLatest(params$, (action, params) => params),
    switchMap((params: ISearchParam) => {
      const polling$ = merge(
        interval(15 * 1000).pipe(
          takeUntil(stopPolling$),
          startWith(null),
          switchMap(() => from(fetch(params)).pipe(
            map(({data}: ISearchResp) => ({
              type: FETCH_SUCCESS,
              payload: {
                total: data.total_count,
                list: data.items
              }
            })),
            startWith({
              type: FETCH_START,
              payload: {}
            }),
            catchError((error: AxiosError) => of({
              type: FETCH_ERROR,
              payload: {
                error: error.response.statusText
              }
            }))
          )),
          startWith({
            type: POLLING_START,
            payload: {}
          })
      ))
      return polling$
    })
  )
}
複製程式碼

下面是對這個 Epic 的一些解釋。

  • 首先我們宣告輪詢結束流,當輪詢結束流有值產生時,輪詢會被終止:
const stopPolling$ = action$.ofType(POLLING_STOP)
複製程式碼
  • 引數來源於狀態,由於現在狀態可觀測,我們可以從狀態流 state$ 派發一個下游 —— 引數流
const params$: Observable<ISearchParam> = state$.pipe(
  map(({user}: {user: IState}) => {
    const { pagination, sort, query } = user
    return {
      // 構造引數
    }
  }),
  distinctUntilChanged(isEqual)
)
複製程式碼

我們預期引數流都是最新的引數,因此使用了 dinstinctUntilChanged(isEqual) 來判斷兩次引數的異同

  • 主動進行搜尋,或者引數變動時,將建立輪詢流(藉助到了 combineLatest operator),最終,新的 action 仰仗於資料拉取結果:
return action$.pipe(
  ofType(LISTEN_POLLING_START, SEARCH),
  combineLatest(params$, (action, params) => params),
  switchMap((params: ISearchParam) => {
    const polling$ = merge(
      interval(15 * 1000).pipe(
        takeUntil(stopPolling$),
        // 自動開始輪詢
        startWith(null),
        switchMap(() => from(fetch(params)).pipe(
          map(({data}: ISearchResp) => {
            // ... 處理響應
          }),
          startWith({
            type: FETCH_START,
            payload: {}
          }),
          catchError((error: AxiosError) => {
            // ...
          })
        )),
        startWith({
          type: POLLING_START,
          payload: {}
        })
      ))
    return polling$
  })
)
複製程式碼

OK,我們現在只需要在資料表格這個容器元件掛載時 dispatch 一個 LISTEN_POLLING_START 事件,即可開始我們的輪詢,在其對應的 Epic 中,它完全知道什麼時候去結束輪詢,什麼時候去重啟輪詢。我們的分頁元件,排序選擇元件都不再需要關心重啟輪詢這個需求。例如分頁元件的狀態變動的 action 就只需要修改狀態即可,而不用再去關注輪詢:

export function changePagination(pagination: IPagination): IAction {
  return {
    type: CHANGE_PAGINATION,
    payload: {
      pagination
    }
  }
}
複製程式碼

在 FRP 模式下,passive 模型讓我們觀測了 state,宣告瞭輪詢的誘因,讓輪詢收歸到了資料表格元件中, 解除了輪詢和資料表格與分頁,搜尋,排序等元件的耦合。實現了資料表格的元件自治

使用 redux-observable 實現元件自治

總結,利用 FRP 進行副作用處理帶來了:

  • 宣告式地描述非同步任務,程式碼簡潔
  • 使用 switchMap operator 處理競態任務
  • 儘可能減少元件耦合,來達到元件自治。利於多人協作的大型工程。

其帶來的利好算是拳拳打到了傳統模式的痛處。下圖是一個更直觀的對比,同樣的業務邏輯,靠上的是 redux-saga 實現,考下則是 redux-observable 實現。你一眼就能感受到誰更簡潔明瞭:

使用 redux-observable 實現元件自治

使用 redux-observable 實現元件自治

接入 redux-observable

redux-observable 只是 redux 一箇中介軟體,因此它可以和你現在的 redux-thunk,redux-saga 等共存,redux-observable 的作者你可以漸進地接入 redux-observable 去處理一些複雜的業務邏輯,當你基本熟悉了 RxJS 和 FRP 模式,你會發現它可以做一切。

後續,考慮到整個工程的風格控制,還是建議只選擇一套模型,FRP 在複雜場景下表現力卓著,在簡單場景下,也不會大炮打蚊子。

總結

本文敘述瞭如何 redux-observable 1.0 提供的 state$,解耦元件之間的業務關聯,實現單個元件的業務自治。

接下來,將通過一步步實現一個類 redux-observable 中介軟體,向大家闡述 redux-observable 設計理念和實現原理。

參考資料


關於本系列

  • 本系列將從介紹 redux-observable 1.0 開始,闡述自己在結合 RxJS 到 Redux 中的心得體會。涉及內容會有 redux-observable 實踐介紹,redux-observable 實現原理探究,最後會介紹下自己當前基於 redux-observble + dva architecture 的一個 state 管理框架 reobservable。

  • 本系列不是 RxJS 或者 Redux 入門,不再講述他們的基礎概念,宣揚他們的核心優勢。如果你搜尋 RxJS 不小心進到了這個系列,對 RxJS 和 FRP 程式設計產生了興趣,那麼入門我會推薦:

  • 本系列更不是教程,只是介紹自己在 Redux 中應用 RxJS 的一些思路,希望更多人能指出當中存在的誤區,或者交流更優雅的實踐。

  • 由衷的感謝實踐路上一些師兄的幫助,尤其感謝騰訊雲的 questguo 學長在模式上的指導。reobservable 脫胎於騰訊雲 questguo 主導的 React 框架 —— TCFF,期待未來 TCFF 的開源。

  • 感謝小雨同學的設計支援。

相關文章