使用 redux-observable 實現組件自治

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

本系列的文章地址彙總:html

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:java

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

能夠看到,Epic 扮演了 stream 轉換器的能力。react

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

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

  • 競態處理能力
  • 聲明式地任務處理
  • 測試友好
  • 組件自治(redux-observable 1. 0 開始支持)

實踐

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

如今,咱們實踐一個常見的業務需求 —— 列表頁。經過這個例子,將展現 redux-observable 1.0 新的特性,並展現在 1.0 下實現的組件自治。編程

組件自治:組件只用關注如何治理本身。redux

先看到列表頁的訴求:

  • 間隔一段時間輪詢數據列表
  • 支持搜索,觸發搜索時,從新輪詢
  • 支持字段排序,排序情況變更,從新輪詢
  • 支持分頁,頁面容量修改,分頁情況變更,從新輪詢
  • 組件卸載時,結束輪詢

在前端組件化開發的思路下,咱們可能會設計以下容器組件(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 列表則是基於傳統模式的:

傳統模式下組件的耦合

在傳統的模式下,咱們須要面對一個現實,對於狀態的獲取是**主動式(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,聲明瞭輪詢的誘因,讓輪詢收歸到了數據表格組件中, 解除了輪詢和數據表格與分頁,搜索,排序等組件的耦合。實現了數據表格的組件自治

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

  • 聲明式地描述異步任務,代碼簡潔
  • 使用 switchMap operator 處理競態任務
  • 儘量減小組件耦合,來達到組件自治。利於多人協做的大型工程。

其帶來的利好算是拳拳打到了傳統模式的痛處。下圖是一個更直觀的對比,一樣的業務邏輯,靠上的是 redux-saga 實現,考下則是 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 的開源。

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

相關文章
相關標籤/搜索