[譯] Redux 非同步四兄弟

檻外畸人發表於2017-03-14

在十分鐘內實踐 ThunkSagaObservable 以及 Redux Promise Middleware

[譯] Redux 非同步四兄弟

在上一次的 React Native online meetup 活動中,筆者就 Thunk 、 Saga 以及 Redux Observable 之間的不同之處做了報告 (點選此處獲取幻燈片)

上述函式庫都提供了一些方法用以處理 Redux 應用中帶有副作用的或者是非同步的 action。更多關於為什麼要用到這些庫的介紹,請 點選此處

相較於僅僅是建立一個倉庫,然後檢視和測試這些庫的實現方法,筆者希望更進一步,即一步步地弄清這些庫是如何解決非同步在 Redux 中產生的副作用,並額外增加一種方案 —— Redux Promise Middleware

筆者第一次接觸 Redux 的時候,就被這些非同步的、帶有副作用的函式庫搞得“頭昏腦脹”。 雖然相關文件還算齊全,但還是希望能夠結合實際專案去深入理解這些函式庫是如何解決 Redux 中的非同步問題。從而快速上手,避免浪費過多時間。

在本教程中,筆者將應用上述函式庫,一步步地實現一個拉取資料並將資料儲存在 reducer 中的簡單例子。

[譯] Redux 非同步四兄弟

如圖所示,上述函式庫最通用的模式之一就是發起一個 API 請求,顯示載入圖示,資料返回後展示結果(如果出現錯誤則展示錯誤資訊)。筆者將依次使用上述 4 個函式庫實現該功能。

[譯] Redux 非同步四兄弟

開始

在本例中筆者將使用 React Native,當然使用 React 也是完全一樣的 —— 只需要把 View 替換為 div, 把 Text 替換為 p 即可。 在本節中,筆者將僅僅實現一個簡單的 Redux 示例應用,以展示上述 4 個函式庫的用法。

首先執行 react-native init 命令建立一個空專案:

react-native init redux4ways複製程式碼

當然也可以使用 create-react-app:

create-react-app redux4ways複製程式碼

然後進入專案目錄:

cd redux4ways複製程式碼

安裝所需依賴:

yarn add redux react-redux redux-thunk redux-observable redux-saga rxjs redux-promise-middleware複製程式碼

建立將要用到的相關目錄和檔案:

mkdir reducers複製程式碼
touch reducers/index.js reducers/dataReducer.js複製程式碼
touch app.js api.js configureStore.js constants.js actions.js複製程式碼

至此,所有依賴都已安裝完畢,相關檔案業已新建妥當,可以著手編碼開發了。

首先將 index.ios (ios) 或 index.android.js (android) 中的程式碼更新如下:

import React from 'react'
import {
  AppRegistry
} from 'react-native'

import { Provider } from 'react-redux'
import configureStore from './configureStore'
import App from './app'

const store = configureStore()

const ReduxApp = () => (
  <Provider store={store}>
    <App />
  </Provider>
)複製程式碼
  1. react-redux 中引入 Provider
  2. 引入 configureStore,隨後將建立該檔案。
  3. 引入 App 作為本例應用中的入口元件。
  4. 呼叫 configureStore() 方法建立 store。
  5. App 包裹在 Provider 中並傳入上述 store。

接著建立 actions 和 reducer 所涉及的相關常量,constants.js 檔案內容如下:

export const FETCHING_DATA = 'FETCHING_DATA'
export const FETCHING_DATA_SUCCESS = 'FETCHING_DATA_SUCCESS'
export const FETCHING_DATA_FAILURE = 'FETCHING_DATA_FAILURE'複製程式碼

再接著建立 dataReducerdataReducer.js 檔案內容如下:

import { FETCHING_DATA, FETCHING_DATA_SUCCESS, FETCHING_DATA_FAILURE } from '../constants'
const initialState = {
  data: [],
  dataFetched: false,
  isFetching: false,
  error: false
}

export default function dataReducer (state = initialState, action) {
  switch (action.type) {
    case FETCHING_DATA:
      return {
        ...state,
        data: [],
        isFetching: true
      }
    case FETCHING_DATA_SUCCESS:
      return {
        ...state,
        isFetching: false,
        data: action.data
      }
    case FETCHING_DATA_FAILURE:
      return {
        ...state,
        isFetching: false,
        error: true
      }
    default:
      return state
  }
}複製程式碼
  1. 引入相關常量。
  2. 該 reducer 的初始狀態 initialState 是一個物件,該物件由 1 個陣列 data 和 3 個布林型別的變數:dataFetchedisFetching 以及 error 構成。
  3. 該 reducer 負責處理 3 種型別的 actions 並相應地更新狀態。例如,如果 action 的型別是 FETCHING_DATA_SUCCESS, 則將新資料新增到狀態物件中並將 isFetching 設為 false

接下來需要建立 reducer 的入口檔案,在該檔案中會對所有的 reducers 呼叫 combineReducers 方法(在本例中只有一個 reducer,即 dataReducer.js )。

reducers/index.js 檔案內容如下:

import { combineReducers } from 'redux'
import appData from './dataReducer'

const rootReducer = combineReducers({
    appData
})

export default rootReducer複製程式碼

之後則需要建立相應的 actions,actions.js 檔案內容如下:

import { FETCHING_DATA, FETCHING_DATA_SUCCESS, FETCHING_DATA_FAILURE } from './constants'

export function getData() {
  return {
    type: FETCHING_DATA
  }
}

export function getDataSuccess(data) {
  return {
    type: FETCHING_DATA_SUCCESS,
    data,
  }
}

export function getDataFailure() {
  return {
    type: FETCHING_DATA_FAILURE
  }
}

export function fetchData() {}複製程式碼
  1. 引入相關常量。
  2. 定義 4 個函式,其中 3 個 (getDatagetDataSuccessgetDataFailure)會直接返回 action,第 4 個(fetchData)則會更新一個 thunk (具體實現見下文)。

接著定義 configureStore:

import { createStore } from 'redux'
import app from './reducers'

export default function configureStore() {
  let store = createStore(app)
  return store
}複製程式碼
  1. ./reducers 中引入 root reducer。
  2. 暴露用以建立 store 的函式介面。

最後, 對接頁面 UI 並繫結相應 props:

import React from 'react'
import { TouchableHighlight, View, Text, StyleSheet } from 'react-native'

import { connect } from 'react-redux'
import { fetchData } from './actions'

let styles

const App = (props) => {
  const {
    container,
    text,
    button,
    buttonText
  } = styles

  return (
    <View style={container}>
      <Text style={text}>Redux Examples</Text>
      <TouchableHighlight style={button}>
        <Text style={buttonText}>Load Data</Text>
      </TouchableHighlight>
    </View>
  )
}

styles = StyleSheet.create({
  container: {
    marginTop: 100
  },
  text: {
    textAlign: 'center'
  },
  button: {
    height: 60,
    margin: 10,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#0b7eff'
  },
  buttonText: {
    color: 'white'
  }
})

function mapStateToProps (state) {
  return {
    appData: state.appData
  }
}

function mapDispatchToProps (dispatch) {
  return {
    fetchData: () => dispatch(fetchData())
  }
}

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(App)複製程式碼

此處程式碼不言自明 —— connect 方法用於將當前 Redux store 的狀態和引入的 actions 作為 props 傳入目標展示性元件中,即此例中的 App

最後需要一個模擬的資料介面,該介面返回一個 promise,該 promise 會在 3 秒鐘後 reslove,並返回相應資料。對應檔案 api.js 內容如下:

const people = [
  { name: 'Nader', age: 36 },
  { name: 'Amanda', age: 24 },
  { name: 'Jason', age: 44 }
]

export default () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      return resolve(people)
    }, 3000)
  })
}複製程式碼

在該檔案中,首先建立一個含有人員資訊的陣列,然後暴露一個實現了上述模擬介面功能的方法。

Redux Thunk

至此,Redux 已經和 React 連線了起來,接下來引入第一個非同步函式庫 —— Redux Thunk。(branch

首先需要建立一個 thunk

Redux Thunk middleware 允許 action 建立函式返回一個函式而不是 action。 該中介軟體可以用於延遲 action 的 dispatch 過程, 或僅當滿足特定條件時才 dispatch action;其內部函式接受兩個引數:dispatchgetState。 ” —— Redux Thunk 文件

actions.js 檔案中,更新函式 fetchData 並引入 api:

import { FETCHING_DATA, FETCHING_DATA_SUCCESS, FETCHING_DATA_FAILURE } from './constants'
import getPeople from './api'

export function getData() {
  return {
    type: FETCHING_DATA
  }
}

export function getDataSuccess(data) {
  return {
    type: FETCHING_DATA_SUCCESS,
    data,
  }
}

export function getDataFailure() {
  return {
    type: FETCHING_DATA_FAILURE
  }
}

export function fetchData() {
  return (dispatch) => {
    dispatch(getData())
    getPeople()
      .then((data) => {
        dispatch(getDataSuccess(data))
      })
      .catch((err) => console.log('err:', err))
  }
}
view raw複製程式碼

此處 fetchData 函式是一個 thunk。當被呼叫時,fetchData 會返回一個函式;該函式首先會 dispatch getData action,然後呼叫 getPeople,在 getPeople 返回的 promise reslove 之後,會 dispatch getDataSuccess action。

接下來,需要更新 configureStore 函式以引入 thunk 中介軟體:

import { createStore, applyMiddleware } from 'redux'
import app from './reducers'
import thunk from 'redux-thunk'

export default function configureStore() {
  let store = createStore(app, applyMiddleware(thunk))
  return store
}複製程式碼
  1. redux 引入 applyMiddleware。
  2. redux-thunk 引入 thunk
  3. applyMiddleware 作為第二個引數傳遞給函式 createStore

最後,更新 app.js 檔案來使用上述 thunk:


import React from 'react'
import { TouchableHighlight, View, Text, StyleSheet } from 'react-native'

import { connect } from 'react-redux'
import { fetchData } from './actions'

let styles

const App = (props) => {
  const {
    container,
    text,
    button,
    buttonText,
    mainContent
  } = styles

  return (
    <View style={container}>
      <Text style={text}>Redux Examples</Text>
      <TouchableHighlight style={button} onPress={() => props.fetchData()}>
        <Text style={buttonText}>Load Data</Text>
      </TouchableHighlight>
      <View style={mainContent}>
      {
        props.appData.isFetching && <Text>Loading</Text>
      }
      {
        props.appData.data.length ? (
          props.appData.data.map((person, i) => {
            return <View key={i} >
              <Text>Name: {person.name}</Text>
              <Text>Age: {person.age}</Text>
            </View>
          })
        ) : null
      }
      </View>
    </View>
  )
}

styles = StyleSheet.create({
  container: {
    marginTop: 100
  },
  text: {
    textAlign: 'center'
  },
  button: {
    height: 60,
    margin: 10,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#0b7eff'
  },
  buttonText: {
    color: 'white'
  },
  mainContent: {
    margin: 10,
  }
})

function mapStateToProps (state) {
  return {
    appData: state.appData
  }
}

function mapDispatchToProps (dispatch) {
  return {
    fetchData: () => dispatch(fetchData())
  }
}

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(App)複製程式碼

此處程式碼主要有以下幾個要點:

  1. 為 TouchableHighlight 元件繫結 onPress 函式,當按壓事件觸發後呼叫 props.fetchData()
  2. 檢查 props.appData.isFetching 的值是否為 true, 如果是則返回正在載入的文字提示。
  3. 檢查 props.appData.data.length,如果該值存在且不為 0,則遍歷該陣列,展示人員姓名和年齡資訊。

至此,當按下按鈕 Load Data 後,首先會看到正在載入的提示文字,3 秒後會看到人員資訊。

Redux Saga

Redux Saga 組合使用 async await 和 Generators,使其函式介面簡單易用。(branch

“通過使用 ES6 的新特性 Generators,涉及非同步流程的程式碼變得易於閱讀、編寫和測試。(如果你對此特性還不熟悉的話,點選此處獲取入門介紹)。基於此,Javascript 的非同步程式碼看起來就和標準的同步程式碼一樣(有點類似於 async/await,但 Generators 另外還有一些我們所需要的極佳特性)。—— Redux Saga 文件

為了實現 Saga,首先需要更新 actions —— 刪除 actions.js 檔案中除了如下程式碼外的其它所有程式碼:

import { FETCHING_DATA } from './constants'

export function fetchData() {
  return {
    type: FETCHING_DATA
  }
}複製程式碼

該 action 會觸發我們即將建立的 saga。新建 saga.js 檔案,寫入如下程式碼:

import { FETCHING_DATA, FETCHING_DATA_SUCCESS, FETCHING_DATA_FAILURE } from './constants'
import { put, takeEvery } from 'redux-saga/effects'
import getPeople from './api'

function* fetchData (action) {
  try {
    const data = yield getPeople()
    yield put({ type: FETCHING_DATA_SUCCESS, data })
  } catch (e) {
    yield put({ type: FETCHING_DATA_FAILURE })
  }
}

function* dataSaga () {
  yield takeEvery(FETCHING_DATA, fetchData)
}

export default dataSaga複製程式碼
  1. 引入所需常量。
  2. redux-saga/effects 中引入 puttakeEvery。當呼叫 put 函式時,Reduc Sage 會指示中介軟體 dipatch 一個 action。takeEvery 函式則會監聽被 dispatch 了的 action(本例中即為 FETCHING_DATA),然後呼叫回撥函式(本例中即為 fetchData)。
  3. fetchData 被呼叫後,程式碼會等待函式 getPeople 的返回,如果返回成功則 dispatch FETCHING_DATA_SUCCCESS action。

最後更新 configureStore.js 檔案,用 saga 替換 thunk。

import { createStore, applyMiddleware } from 'redux'
import app from './reducers'

import createSagaMiddleware from 'redux-saga'
import dataSaga from './saga'

const sagaMiddleware = createSagaMiddleware()

export default function configureStore() {
  const store = createStore(app, applyMiddleware(sagaMiddleware))
  sagaMiddleware.run(dataSaga)
  return store
}複製程式碼

在該檔案中既引入了上述 saga,又從 redux-saga 中引入了 createSagaMiddleware。在建立 store 時,傳入 sagaMiddleware,然後在返回 store 之前呼叫 sagaMiddleWare.run

至此,可以再次執行該程式並看到和使用 Redux Thunk 是同樣的效果!

注意:從 thunk 遷移到 saga 只改變了 3 個檔案: saga.jsconfigureStore.js 以及 actions.js

Redux Observable

Redux Observable 使用 RxJS 和 observables 來為 Redux 應用建立非同步 action 和非同步資料流。(branch

“基於 RxJS 5Redux 中介軟體。組合撤銷非同步 actions 以產生副作用等。” —— Redux Observable 文件

首先還是需要更新 actions.js 檔案:

import { FETCHING_DATA, FETCHING_DATA_SUCCESS, FETCHING_DATA_FAILURE } from './constants'

export function fetchData () {
  return {
    type: FETCHING_DATA
  }
}

export function getDataSuccess (data) {
  return {
    type: FETCHING_DATA_SUCCESS,
    data
  }
}

export function getDataFailure (error) {
  return {
    type: FETCHING_DATA_FAILURE,
    errorMessage: error
  }
}複製程式碼

如上所示,將之前的 actions 更新為最早的 3 個 actions。

接著建立所謂的 epic —— 輸入 action stream 並輸出 action stream 的函式。

新建 epic.js 檔案並加入如下程式碼:

import { FETCHING_DATA } from './constants'
import { getDataSuccess, getDataFailure } from './actions'
import getPeople from './api'

import 'rxjs'
import { Observable } from 'rxjs/Observable'

const fetchUserEpic = action$ =>
  action$.ofType(FETCHING_DATA)
    .mergeMap(action =>
      Observable.fromPromise(getPeople())
        .map(response => getDataSuccess(response))
        .catch(error => Observable.of(getDataFailure(error)))
      )

export default fetchUserEpic複製程式碼

一般在 RxJS 中,變數名中的 $ 符號用以表示該變數是某 stream 的引用。

  1. 引入常量 FETCHING_DATA。
  2. 引入 getDataSuccessgetDataFailure 函式。
  3. 從 rxjs 中引入 rxjsObservable
  4. 定義函式 fetchUserEpic
  5. 等到 FETCHING_DATA action 通過該 stream 之後,呼叫 mergeMap 函式, 從 getPeople 中返回 Observable.fromPromise 並將返回值對映到 getDataSuccess 函式中。

最後,更新 configureStore,應用新中介軟體 —— epic。

configureStore.js 檔案內容如下:

import { createStore, applyMiddleware } from 'redux'
import app from './reducers'

import { createEpicMiddleware } from 'redux-observable'
import fetchUserEpic from './epic'

const epicMiddleware = createEpicMiddleware(fetchUserEpic)

export default function configureStore () {
  const store = createStore(app, applyMiddleware(epicMiddleware))
  return store
}
view raw複製程式碼

至此,可以再次執行該程式並看到後之前一樣的效果!

Redux Promise Middleware

Redux Promise Middleware 是一個用於 reslove 和 reject promise 的輕量級函式庫。 (branch)

“Redux Promise Middleware 使得 Redux 中的非同步程式碼更為健壯,並使 optimistic updates 、dispatches pending 、fulfilled 和 rejected actions 成為可能。 它也可以和 redux-thunk 結合使用鏈式化非同步 action” —— Redux Promise Middleware 文件

正如你將要看到的一樣,相比於上述幾個函式庫而言,Redux Promise Middleware 極大地減少了程式碼量。

它也可以和 Thunk 結合使用 以實現非同步 action 的鏈式化。

相較於上述幾個函式庫,Redux Promise Middleware 有所不同 —— 它會接管你的 action 並基於 promise 狀態的不同在 action 型別名稱後新增 _PENDING_FULFILLED_REJECTED

例如,如果呼叫如下函式:

function fetchData() {
  return {
    type: FETCH_DATA,
    payload: getPeople()
  }
}複製程式碼

那麼就會自動地 dispatch FETCH_DATA_PENDING action。

一旦 getPeople promise resolved,基於返回結果的不同,會 dispatch FETCH_DATA_FULFILLEDFETCH_DATA_REJECTED action。

讓我們通過現有的例子來理解該特性:

首先需要更新 constants.js,以使其匹配我們將要用到的常量:

export const FETCH_DATA = 'FETCH_DATA'
export const FETCH_DATA_PENDING = 'FETCH_DATA_PENDING'
export const FETCH_DATA_FULFILLED = 'FETCH_DATA_FULFILLED'
export const FETCH_DATA_REJECTED = 'FETCH_DATA_REJECTED'複製程式碼

接著將 actions.js 檔案更新為只有一個 FETCH_DATA 這一個 action。

import { FETCH_DATA } from './constants'
import getPeople from './api'

export function fetchData() {
  return {
    type: FETCH_DATA,
    payload: getPeople()
  }
}複製程式碼

接著基於上面新定義的常量更新 dataReducer.js 檔案:

import { FETCH_DATA_PENDING, FETCH_DATA_FULFILLED, FETCH_DATA_REJECTED } from '../constants'
const initialState = {
  data: [],
  dataFetched: false,
  isFetching: false,
  error: false
}

export default function dataReducer (state = initialState, action) {
  switch (action.type) {
    case FETCH_DATA_PENDING:
      return {
        ...state,
        data: [],
        isFetching: true
      }
    case FETCH_DATA_FULFILLED:
      return {
        ...state,
        isFetching: false,
        data: action.payload
      }
    case FETCH_DATA_REJECTED:
      return {
        ...state,
        isFetching: false,
        error: true
      }
    default:
      return state
   }
}複製程式碼

最後更新 configureStore,應用 Redux Promise Middleware:

import { createStore, applyMiddleware } from 'redux'
import app from './reducers'
import promiseMiddleware from 'redux-promise-middleware';

export default function configureStore() {
  let store = createStore(app, applyMiddleware(promiseMiddleware()))
  return store
}複製程式碼

至此,可以再次執行該程式並看到後之前一樣的效果!

總結

總的來說,筆者認為 Saga 更適用於較為複雜的應用,除此之外的其他所有情況 Redux Promise Middleware 都是十分合適的。筆者十分喜歡 Saga 中的 Generators 和 async-await,這些特性很有趣; 同時筆者也喜歡 Redux Promise Middleware,因為它極大地減少了程式碼量。

如果對 RxJS 更為熟悉的話,筆者也許會偏向 Redux Observable;但還是有很多筆者理解不透徹的地方,因此無法自信地將其應用於生產環境中。

筆者 Nader Dabit,是一名專注於 React 和 React Native 開發和培訓的軟體開發者。

如果你也喜歡 React Native,歡迎檢視我和 Gant Laborde Kevin Old Ali NajafizadehPeter PiekarczykDevchat.tv 的 podcast — React Native Radio

同時,也歡迎檢視筆者所著的 React Native in Action,該書目前可以在 Manning Publications 購買。

如果你喜歡這篇文章,歡迎推薦和分享!謝謝!

相關文章