如何實現一個 redux-observable

吳曉軍發表於2018-08-26

本文是 《使用 RxJS + Redux 管理應用狀態》系列第二篇文章,將會介紹 redux-observable 的設計哲學和實現思路。返回第一篇:使用 redux-observable 實現元件自治

本系列的文章地址彙總:

Redux

Redux 脫胎於 Elm 架構,其狀態管理視角和流程非常清晰和明確:

如何實現一個 redux-observable

  1. dispatch 了一個 action
  2. reducer 俘獲 action,並根據 action 型別進行不同的狀態更新邏輯
  3. 周而復始地進行這個過程

這個過程是同步的,Redux 為了保護 reducer 的純度是不推薦在 reducer 中處理副作用的(如 HTTP 請求)。因此,就出現了 redux-thunk、redux-saga 這樣的 Redux 中介軟體去處理副作用。

這些中介軟體本質都是俘獲 dispatch 的內容,並在這個過程中進行副作用處理,最終 dispatch 一個新的 action 給 reducer,讓 reducer 專心做一個純的狀態機。

用 observable 管理副作用

假定我們在 UI 層能派發出一個資料拉取的 FETCH action,拉取資料後,將派發拉取成功的 FETCH_SUCCESS action 或者是資料拉取失敗的 FETCH_ERROR action 到 reducer。

           FETCH
             |
       fetching data...
             |
            / \
           /   \
 FETCH_SUCCESS FETCH_ERROR
複製程式碼

如果我們用 FRP 模式來思考這個過程,FETCH 就不是一個獨立的個體,而是存在於一條會派發 FETCH action 的流上(observable):

---- FETCH ---- FETCH ---- 

---- FETCH_SUCCESS ---- FETCH_SUCCESS ----

---- FETCH_ERROR ---- FETCH_ERROR ----
複製程式碼

若我們將 FETCH 流定義為 fetch$,則 FETCH_SUCCESS 和 FETCH_ERROR 都將來自於 fetch$

const fetch$: Observable<FetchAction> = //....
fetch$.pipe(
  switchMap(() => from(api.fetch).pipe(
    // 拉取資料成功
    switchMap(resp => ({
      type: FETCH_SUCCESS,
      payload: {
        // ...
      }
    }),
    // 拉取資料失敗
    catchError(error => of({
      type: FETCH_ERROR,
      payload: {
        // ....
      }
    }))
  ))
)
複製程式碼

除此之外,我們可以用一個流來承載頁面所有的 action:

const action$: Observable<Action>
複製程式碼

那麼, fetch$ 亦可以由 action$ 流轉得到:

const fetch$ = action$.pipe(
  filter(({type}) => type === FETCH)
)
複製程式碼

這樣,我們就形成了使用 observable 流轉 action 的模式:

如何實現一個 redux-observable

接下來,我們嘗試講這個模式整合到 Redux 中,讓 observable 來負責應用的 action 流轉和副作用處理。

構建中介軟體

Redux 提供的中介軟體機制能讓我們干預每個到來的 action, 藉此處理一些業務邏輯,然後再返還一個 action 給 reducer:

如何實現一個 redux-observable

中介軟體的函式構成如下:

const middleware: Middleware = store => {
  // 初始化中介軟體
  return next => action => { 
  	// do something
  }
}

const store = createStore(
  rootReducer,
  applyMiddleware(middleware)
)
複製程式碼

現在,當中介軟體初始化時,我們進行 action$ 。當新的 action 到來時:

  1. 將 action 交給 reducer 處理
  2. action$ 中放入 action
  3. action$ 可以轉化另一個的 action 流

因此,action$ 既是觀察者又是可觀察物件,是一個 Subject 物件:

const createMiddleware = (): Middleware => {
  const action$ = new Subject()
  const middleware: Middleware = store => next => action => {
    // 將 action 交給 reducer 處理
    const result = next(action)
    // 將 action 放到 action$ 中進行流轉
    action$.next(action)
    return result
  }
  return middleware
}
複製程式碼

流的轉換器

現在,在中介軟體中,我們初始化了 action$,但是如何得到 fetch$ 這些由 action$ 派生的流呢?因此,我們還需要告知中介軟體如果通過 action$ 生成更多的流,不妨定義一個轉換器,由它負責 action$ 的流轉,並在當中處理副作用:

如何實現一個 redux-observable

interface Transformer {
  (action$: Observable<Action>): Observable<Action>
}

const fetchTransformer: Transformer = (action$) => {
  action$.pipe(
    filter(({type}) => type === FETCH),
    switchMap(() => from(api.fetch).pipe(
      switchMap(resp => ({
        type: FETCH_SUCCESS,
        payload: {
          // ...
        }
      }),
      catchError(error => of({
        type: FETCH_ERROR,
        payload: {
          // ....
        }
      }))
    ))
  )
}
複製程式碼

應用中,我們可能定義不同的轉換器,從而得到派發不同 action 的流:

如何實現一個 redux-observable

const newActionsStreams: Observable<Action>[] = transformers.map(transformer => transformer(action$))
複製程式碼

由於這些 action 還具有一致的資料結構,因此我們可以將這些流進行合併,由合併後的流負責派發 action 到 reducer:

如何實現一個 redux-observable

const newAction$ = merge(newActionStreams)
複製程式碼

那麼,修改我們的中介軟體實現:

const createMiddleware = (...transformers): Middleware => {
  const action$ = new Subject()
  // 執行各個 transformer,並將轉換的流進行合併
  const newAction$ = merge(tramsformer.map(transformer => transformer(action$)))
  const middleware: Middleware = store => {
    // 訂閱 newAction$
    newAction$.subscribe(action => store.dispatch(action))
    return next => action => {
      // 將 action 交給 reducer 處理
      const result = next(action)
      // 將 action 放到 action$ 中進行流轉
      action$.next(action)
      return result
    }
  }
  return middleware
}
複製程式碼

優化:ofType operator

由於我們總是需要 filter(action => action.type === SOME_TYPE) 來過濾 action,因此可以封裝一個 operator 來優化這個過程:

const ofType: OperatorFunction<Observable<Action>, Observable<Action>> = (type: String) => pipe(
  filter(action => action.type === type)
)
複製程式碼
const fetchTransformer: Transformer = (action$) {
  return action$.pipe(
    filter(({type}) => type === FETCH),
    switchMap(() => from(api.fetch)),
    // ...
  )
}
複製程式碼

再考慮到我們可能不只過濾一個 action type,因此可以優化我們的 ofType operator 為:

const ofType: OperatorFunction<Observable<Action>, Observable<Action>> = 
  (...types: String[]) => pipe(
    filter((action: Action) => types.indexOf(action.type) > -1)
  )
複製程式碼
const counterTransformer: Transformer = (action$) {
  return action$.pipe(
    ofType(INCREMENT, DECREMENT),
    // ...
  )
}
複製程式碼

下面這個測試用例將用來測試我們的中介軟體是否能夠工作了:

it('should transform action', () => {
   const reducer: Reducer = (state = 0, action) => {
    switch(action.type) {
      case 'PONG':
        return state + 1
      default:
        return state
    }
  }

  const transformer: Transformer = (action$) => {
    return action$.pipe(
        ofType('PING'),
        mapTo({type: 'PONG'})
      )
    )
  }

  const middleware = createMiddleware(transformer)
  const store = createStore(reducer, applyMiddleware(middleware))
  store.dispatch({type: 'PING'})
  expect(store.getState()).to.be.equal(1)
})
複製程式碼

優化:獲得 state

在 action 的流轉過程可能還需要獲得應用狀態,例如,fetch$ 中獲取資料前,需要封裝請求引數,部分引數可能來自於應用狀態。因此,我們可以考慮為每個 transformer 再傳遞當前的 store 物件,使它能拿到當前的應用狀態:

interface Transformer {
  (action$: Observable<Action>, store: Store): Observable<Action>
}

// ...

const createMiddleware = (...transformers): Middleware => {
  const action$ = new Subject()
  const middleware: Middleware = store => {
    // 將 store 也傳遞給 transformer
    const newAction$ = merge(tramsformer.map(transformer => transformer(action$, store)))
    newAction$.subscribe(action => store.dispatch(action))
    return next => action => {
      const result = next(action)
      action$.next(action)
      return result
    }
  }
  return middleware
}
複製程式碼

現在,當需要取用狀態的時候,就通過 store.getState() 拿取:

const fetchTransformer: Transformer = (action$, store) {
  return action$.pipe(
    filter(({type}) => type === FETCH),
    switchMap(() => {
      const { query, page, pageSize } = store.getState()
      const params = { query, page, pageSize }
      return from(api.fetch, params)
    }),
    // ...
  )
}
複製程式碼

優化:觀察狀態

在響應式程式設計體系下,一切資料來源都應當是可被觀察的,而上面我們對狀態的取值確是主動的(proactive)的,正確的方式是應當觀察狀態的變化,並在變化時作出決策:

如何實現一個 redux-observable

為此,類似 action$,我們也將 state 流化,使得應用狀態成為一個可觀察物件,並將 state$ 傳遞給 transformer:

interface Transformer {
  (action$: Observable<Action>, state$: Observable<State>): Observable<Action>
}

// ...

const createMiddleware = (...transformers): Middleware => {
  const action$ = new Subject()
  const state$ = new Subject()
  const middleware: Middleware = store => {
    // 由各個 transformer 獲得應用的 action$
    const newAction$ = merge(tramsformer.map(transformer => transformer(action$, state$)))
    // 新的 action 到來時,將其又 dispatch 到 Redux 生態
    newAction$.subscribe(action => store.dispatch(action))
    return next => action => {
      // 將 action 交給 reducer
      const result = next(action)
      // 獲得 reducer 處理後的新狀態
      state$.next(state)
		  // 將 action 放入 action$
      action$.next(action)
      return result
    }
  }
  return middleware
}
複製程式碼

當業務流程需要狀態時,就可以自由組合 state$ 得到:

const fetchTransformer: Transformer = (action$, state$) {
  return action$.pipe(
    filter(({type}) => type === FETCH),
    withLatestFrom(state$),
    switchMap(([action, state]) => {
      const { query, page, pageSize } = state
      const params = { query, page, pageSize }
      return from(api.fetch, params)
    }),
    // ...
  )
}
複製程式碼

乍看之下,似乎不如 store.getState() 來的方便,為了獲得當前狀態,我們還額外引入了一個 operator withLatestFrom。但是,要注意到,我們引入 state$ 不只為了獲得狀態和統一模式,更重要是為了觀察狀態。

舉個例子,我們有一個備忘錄元件,每次內容變動時,我們就儲存一下草稿。如果我們能觀察狀態變動,通過響應式程式設計模式,當狀態變動時,自動形成草稿儲存的業務:

const saveDraft$: Observable<Action> = state$.pipe(
  // 選出當前
	pluck('content'),
  // 只有當內容變動時才考慮儲存草稿
  distinctUntilChanged(),
  // 只在 1 s 內儲存一次
  throttleTime(1000),
  // 呼叫服務儲存草稿
  switchMap(content => from(api.saveDraft(content)))
  // ....
)
複製程式碼

大家也可以在回顧系列第一篇所介紹的內容,正是由於 redux-observable 在 1.0 版本引入了 state$,我們才得以解耦元件的業務關係,實現單個元件的自治。

優化:響應初始狀態

現在,我們可以測試一下現在的中介軟體,看能否觀察應用狀態了:

it('should observe state', () => {
   const reducer: Reducer = (state = {step: 10, counter: 0}, action) => {
    switch(action.type) {
      case 'PONG':
        return {
          ...state,
          counter: action.counter
        }
      default:
        return state
    }
  }

  const transformer: Transformer = (action$, state$) => {
    return action$.pipe(
        ofType('PING'),
      	withLatestFrom(state$, (action, state) => state.step + state.counter),
        map(counter => ({type: 'PONG', counter}))
      )
    )
  }

  const middleware = createMiddleware(transformer)
  const store = createStore(reducer, applyMiddleware(middleware))
  store.dispatch({type: 'PING'})
  expect(store.getState().counter).to.be.equal(10)
})
複製程式碼

遺憾的是,這個測試用例將不會通過,通過除錯發現,當我們 dispatch 了 PING action 後,withLatestFrom 沒有拿到最近一次的 state。這是為什麼呢?原來是因為 Redux 的 init action 並沒有暴露給中介軟體進行攔截,因此,應用的初始狀態沒能被送入 state$ 中,觀察者無法觀察到初始狀態。

為了解決這個問題,在建立了 store 後,我們可以嘗試 dispatch 一個無意義的 action 給中介軟體,強制將初始狀態先送入 state$ 中:

const middleware = createMiddleware(transformer)
const store = createStore(reducer, applyMiddleware(middleware))
// 派發一個 action 去獲得初始狀態
store.dispatch({type: '@@INIT_STATE'})
複製程式碼

這個方式雖然能讓測試通過,但缺不是很優雅,我們讓使用者手動去派發一個無意義的 action,這會讓使用者感覺很困惑。因此,我們考慮為中介軟體單獨設定一個 API,用以在 store 建立後,完成一些任務:

// 設定一個 store 副本
let cachedStore: Store
const createMiddleware = (...transformers): Middleware => {
  const action$ = new Subject()
  const state$ = new Subject()
  const newAction$ = merge(transformers.map(transformer => transformer(action$, state$)))
  
  const middleware: Middleware = store => {
    cachedStore = store
    
    return next => action => {
      // 將 action 交給 reducer
      const result = next(action)
      // 獲得 reducer 處理後的新狀態
      state$.next(state)
		  // 將 action 放入 action$
      action$.next(action)
      return result
    }
  }
  
  middleware.run = function() {
    // 1. 開始對 action 的訂閱
    newAction$.subscribe(cachedStore.dispatch)
    // 2. 將初始狀態傳遞給 state$
    state$.next(cachedStore.getState())
  }
  return middleware
}
複製程式碼

現在,我們為中介軟體提供了一個 run 方法,來讓中介軟體在 store 建立以後完成一些工作。當我們建立好 store 後,執行 run 方法來執行中介軟體:

const middleware = createMiddleware(transformer)
const store = createStore(reducer, applyMiddleware(middleware))
// 執行我們的中介軟體
middleware.run()
複製程式碼

優化:相互關聯的 transformer

再考慮一個更加場景,各個 transformer 之間可能存在關聯,各個 trasformer 也可能直接發出 action,而不需要依賴於 action$

it('should queue synchronous actions', () => {
    const reducer = (state = [], action) => state.concat(action)
    const transformer1 = (action$, state$) => action$.pipe(
      ofType('FIRST'),
      mergeMap(() => of({ type: 'SECOND' }, { type: 'THIRD'} ))
    )
    const transformer2 = (action$, state$) => action$.pipe(
        ofType('SECOND'),
        mapTo({type: 'FORTH'})
    )
    
    const middleware = createMiddleware(transformer1, transformer2)
    const store = createStore(reducer, applyMiddleware(middleware))
    middleware.run()
    
    const actions = store.getState()
    actions.shift() // remove redux init action
    expect(actions).to.deep.equal([
      { type: 'FIRST' },
      { type: 'SECOND' },
      { type: 'THIRD' },
      { type: 'FORTH' }
    ])
})
複製程式碼

在這個測試用例中,我們看到的 action 序列是:

FIRST
SECOND
THIRD
FORTH
複製程式碼

但是,在當前的實現中,你將得到:

FIRST
SECOND
FORTH
THIRD
複製程式碼

這並不符合預期。但是,問題又出在哪裡呢?我們分析下程式執行過程:

  1. 發出 first action
  2. 排程 first action,派生出 second action 及 third action 的 observable
  3. 排程 second action,派生出 forth action 的 observable
  4. 排程 forth action
  5. 排程 third action

問題顯然就出在第 2、3 步,如果第 2 步中,我們控制 observable 吐出值的速度,將同時到來的 second 和 third action 快取到佇列,並依次執行,就能得到我們期望的輸出。

幸運的是,RxJS 中提供了 observeOn 這個 operator 來控制資料來源發出值的節奏。其第一個引數接收一個排程器,用於告知資料來源以怎樣的速錄排程任務,這裡我們將使用 Queue Scheduler 將各個 action 快取到佇列,當此時再無 action 時,各個 action 出隊並被排程:

export const createEpicMiddleware = (...epics) => {
  const action$ = new Subject().pipe(observeOn(queueScheduler)) as Subject<Action>
  
  // ...
  
  return middleware
}
複製程式碼

現在,再次執行測試用例,你講看到符合期望的 action 序列:

FIRST
SECOND
THIRD
FORTH
複製程式碼

這是因為:

  1. 發出 first action
  2. 排程 first action,入隊
  3. 此時沒有 action,first action 出隊,store.dispatch(first),派生出 second action 及 third action 的 observable
  4. second action 入隊,third action 入隊
  5. 此時沒有等待的 action,則 second action 出隊,store.dispatch(second),派生出 forth action 的 observable
  6. forth action 入隊
  7. 此時沒有等待的 action,隊首元素 third action 出隊,store.dispatch(third)
  8. forth action 出隊,store.dispatch(forth)

總結

截止目前,我們的中介軟體已經允許我們通過 FRP 模式梳理應用狀態了,這個中介軟體的實現已經非常類似於 redux-observable 的實現了。當然,大家生產環境還是用更流行,更穩定的 redux-observable,本文旨在幫助大家更好的理解如何在 Redux 中整合 RxJS 更好的管理狀態,通過一步一步對中介軟體的優化,也讓大家理解了了 redux-observable 的設計哲學和實現原理。本文實現的 mini redux-observable 我也放到了我的 github 上,包含了一些測試用例和一個小的 demo。

接下來,我們將探索將 redux-observable 以及 FRP 這套模式整合到 dva 架構的前端框架中,dva 架構幫助砍掉 Redux 冗長的樣板程式碼,而 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 的開源。
  • 感謝小雨的設計支援。

相關文章