[譯] redux-recompose 介紹:優雅的編寫 Redux 中的 action 和 reducer

言歸發表於2019-03-01

redux-recompose 介紹:優雅的編寫 Redux 中的 action 和 reducer

[譯] redux-recompose 介紹:優雅的編寫 Redux 中的 action 和 reducer

去年一年做了不少 React 和 React Native 專案的開發,而且這些專案都使用了 Redux 來管理元件狀態 。碰巧,這些專案裡有很多具有代表性的開發模式,所以趁著我還在 Wolox,在分析、總結了這些模式之後,開發出了 redux-recompose,算是對這些模式的抽象和提升。


痛點所在

在 Wolox 培訓的那段時間,為了學 redux 看了 Dan Abramov’s 在 Egghead 上釋出的 Redux 教程,發現他大量使用了 switch 語句:我聞到了點 壞程式碼的味道

在我接手的第一個 React Native 專案中,開始的時候我還是按照教程上講的,使用 switch 編寫 reducer。但不久後就發現,這種寫法實在難以維護:

import { actions } from './actions';

const initialState = {
  matches: [],
  matchesLoading: false,
  matchesError: null,
  pitches: [],
  pitchesLoading: false,
  pitchesError: null
};

/* eslint-disable complexity */
function reducer(state = initialState, action) {
  switch (action.type) {
    case actions.GET_MATCHES: {
      return { ...state, matchesLoading: true };
    }
    case actions.GET_MATCHES_SUCCESS: {
      return {
        ...state,
        matchesLoading: false,
        matchesError: null,
        matches: action.payload
      };
    }
    case actions.GET_MATCHES_FAILURE: {
      return {
        ...state,
        matchesLoading: false,
        matchesError: action.payload
      };
    }
    case actions.GET_PITCHES: {
      return { ...state, pitchesLoading: true };
    }
    case actions.GET_PITCHES_SUCCESS: {
      return {
        ...state,
        pitches: action.payload,
        pitchesLoading: false,
        pitchesError: null
      };
    }
    case actions.GET_PITCHES_FAILURE: {
      return {
        ...state,
        pitchesLoading: false,
        pitchesError: null
      };
    }
  }
}
/* eslint-enable complexity */

export default reducer;
複製程式碼

到後面 reducer 裡的條件實在是太多了,索性就把 eslint 的複雜度檢測關掉了。

另一個問題集中在非同步呼叫上,action 的定義中大量充斥著 SUCCESSFAILURE 這樣的程式碼,雖然這可能也不是什麼問題,但是還是引入了太多重複程式碼。

import SoccerService from '../services/SoccerService';

export const actions = createTypes([
  'GET_MATCHES',
  'GET_MATCHES_SUCCESS',
  'GET_MATCHES_FAILURE',
  'GET_PITCHES',
  'GET_PITCHES_SUCCESS',
  'GET_PITCHES_FAILURE'
], '@SOCCER');

const privateActionCreators = {
  getMatchesSuccess: matches => ({
    type: actions.GET_MATCHES_SUCCESS,
    payload: matches
  }),
  getMatchesError: error => ({
    type: actions.GET_MATCHES_ERROR,
    payload: error
  }),
  getPitchesSuccess: pitches => ({
    type: actions.GET_PITCHES_SUCCESS,
    payload: pitches
  }),
  getPitchesFailure: error => ({
    type: actions.GET_PITCHES_FAILURE,
    payload: error
  })
};

const actionCreators = {
  getMatches: () => async dispatch => {
    // 將 loading 狀態置為 true
    dispatch({ type: actions.GET_MATCHES });
    // -> api.get('/matches');
    const response = await SoccerService.getMatches();
    if (response.ok) {
      // 儲存 matches 陣列資料,將 loading 狀態置為 false
      dispatch(privateActionCreators.getMatchesSuccess(response.data));
    } else {
      // 儲存錯誤資訊,將 loading 狀態置為 false
      dispatch(privateActionCreators.getMatchesFailure(response.problem));
    }
  },
  getPitches: clubId => async dispatch => {
    dispatch({ type: actions.GET_PITCHES });
    const response = await SoccerService.getPitches({ club_id: clubId });
    if (response.ok) {
      dispatch(privateActionCreators.getPitchesSuccess(response.data));
    } else {
      dispatch(privateActionCreators.getPitchesFailure(response.problem));
    }
  }
};

export default actionCreators;
複製程式碼

物件即過程

某天,我的同事建議:

’要不試試把 switch 語句改成訪問物件屬性的形式?這樣之前 switch 的條件就都能抽離成單個的函式了,也方便測試。‘

再者,Dan Abramov 也說過Reducer 就是一個很普通的函式,你可以抽出一些程式碼獨立成函式,也可以在裡面呼叫其他的函式,具體實現可以自由發揮。

有了這句話我們也就放心開幹了,於是開始探索有沒有更加優雅的方式編寫 reducer 的程式碼。最終,我們得出了這麼一種寫法:

const reducerDescription = {
  [actions.GET_MATCHES]: (state, action) => ({ ...state, matchesLoading: true }),
  [actions.GET_MATCHES_SUCCESS]: (state, action) => ({
    ...state,
    matchesLoading: false,
    matchesError: null,
    matches: action.payload
  }),
  [actions.GET_MATCHES_FAILURE]: (state, action) => ({
    ...state,
    matchesLoading: false,
    matchesError: action.payload
  }),
  [actions.GET_PITCHES]: (state, action) => ({ ...state, pitchesLoading: true }),
  [actions.GET_PITCHES_SUCCESS]: (state, action) => ({
    ...state,
    pitchesLoading: false,
    pitchesError: null,
    pitches: action.payload
  }),
  [actions.GET_PITCHES_FAILURE]: (state, action) => ({
    ...state,
    pitchesLoading: false,
    pitchesError: action.payload
  })
};
複製程式碼
function createReducer(initialState, reducerObject) {
  return (state = initialState, action) => {
    (reducerObject[action.type] && reducerObject[action.type](state, action)) || state;
  };
}

export default createReducer(initialState, reducerDescription);
複製程式碼

SUCCESSFAILURE 的 action 和之前看來沒啥區別,只是 action 的用法變了 —— 這裡將 action 和操作它對應的 state 裡的那部分資料的函式進行了一一對應。例如,我們分發了一個 action.aList 來修改一個列表的內容,那麼‘aList’就是找到對應的 reducer 函式的關鍵詞。

靶向化 action

有了上面的嘗試,我們不妨更進一步思考:何不站在 action 的角度來定義 state 的哪些部分會被這個 action 影響?

Dan 這麼說過:

我們可以把 action 想象成一個“差使”,action 不關心 state 的變化 —— 那是 reducer 的事

那麼,為什麼就不能反其道而行之呢,如果 action 就是要去管 state 的變化呢?有了這種想法,我們就能引申出 靶向化 action 的概念了。何謂靶向化 action?就像這樣:

const privateActionCreators = {
  getMatchesSuccess: matchList => ({
    type: actions.GET_MATCHES_SUCCESS,
    payload: matchList,
    target: 'matches'
  }),
  getMatchesError: error => ({
    type: actions.GET_MATCHES_ERROR,
    payload: error,
    target: 'matches'
  }),
  getPitchesSuccess: pitchList => ({
    type: actions.GET_PITCHES_SUCCESS,
    payload: pitchList,
    target: 'pitches'
  }),
  getPitchesFailure: error => ({
    type: actions.GET_PITCHES_FAILURE,
    payload: error,
    target: 'pitches'
  })
};
複製程式碼

effects 的概念

如果你以前用過 redux saga 的話,應該對 effects 有點印象,但這裡要講的還不是這個 effects 的意思。

這裡講的是將 reducer 和 reducer 對 state 的操作進行解耦合,而這些抽離出來的操作(即函式)就稱為 effects —— 這些函式具有冪等性質,而且對 state 的變化一無所知:

export function onLoading(selector = (action, state) => true) {
  return (state, action) => ({ ...state, [`${action.target}Loading`]: selector(action, state) });
}

export function onSuccess(selector = (action, state) => action.payload) {
  return (state, action) => ({
    ...state,
    [`${action.target}Loading`]: false,
    [action.target]: selector(action, state),
    [`${action.target}Error`]: null
  });
}

export function onFailure(selector = (action, state) => action.payload) {
  return (state, action) => ({
    ...state,
    [`${action.target}Loading`]: false,
    [`${action.target}Error`]: selector(action, state)
  });
}
複製程式碼

注意上面的程式碼是如何使用這些 effects 的。你會發現裡面有很多 selector 函式,它主要用來從封裝物件中取出你需要的資料域:

// 假設 action.payload 的結構是這個樣子: { matches: [] }; 
const reducerDescription = {
  // 這裡只引用了 matches 陣列,不用處理整個 payload 物件
  [actions.GET_MATCHES_SUCCESS]: onSuccess(action => action.payload.matches)
};
複製程式碼

有了以上思想,最終處理函式的程式碼變成這樣:

const reducerDescription = {
  [actions.MATCHES]: onLoading(),
  [actions.MATCHES_SUCCESS]: onSuccess(),
  [actions.MATCHES_FAILURE]: onFailure(),
  [actions.PITCHES]: onLoading(),
  [actions.PITCHES_SUCCESS]: onSuccess(),
  [actions.PITCHES_FAILURE]: onFailure()
};

export default createReducer(initialState, reducerDescription);
複製程式碼

當然,我並不是這種寫法的第一人:

[譯] redux-recompose 介紹:優雅的編寫 Redux 中的 action 和 reducer

到這一步你會發現程式碼還是有重複的。針對每個基礎 action(有配對的 SUCCESS 和 FAILURE),我們還是得寫相應的 SUCCESS 和 FAILURE 的 effects。 那麼,能否再做進一步改進呢?

你需要 Completer

Completer 可以用來抽取程式碼中重複的邏輯。所以,用它來抽取 SUCCESSFAILURE 的處理程式碼的話,程式碼會從:

const reducerDescription: {
  [actions.GET_MATCHES]: onLoading(),
  [actions.GET_MATCHES_SUCCESS]: onSuccess(),
  [actions.GET_MATCHES_FAILURE]: onFailure(),
  [actions.GET_PITCHES]: onLoading(),
  [actions.GET_PITCHES_SUCCESS]: onSuccess(),
  [actions.GET_PITCHES_FAILURE]: onFailure(),
  [actions.INCREMENT_COUNTER]: onAdd()
};

export default createReducer(initialState, reducerDescription);
複製程式碼

變成以下更簡潔的寫法:

const reducerDescription: {
  primaryActions: [actions.GET_MATCHES, actions.GET_PITCHES],
  override: {
    [actions.INCREMENT_COUNTER]: onAdd()
  }
}

export default createReducer(initialState, completeReducer(reducerDescription))
複製程式碼

completeReducer 接受一個 reducer description 物件,它可以幫基礎 action 擴充套件出相應的 SUCCESS 和 FAILURE 處理函式。同時,它也提供了過載機制,用於配製非基礎 action 。

根據 SUCCESS 和 FAILURE 這兩種情況定義狀態欄位也比較麻煩,對此,可以使用 completeState 自動為我們新增 loading 和 error 這兩個欄位:

const stateDescription = {
  matches: [],
  pitches: [],
  counter: 0
};

const initialState = completeState(stateDescription, ['counter']);
複製程式碼

還可以自動為 action 新增配對的 SUCCESSFAILURE

export const actions = createTypes(
  completeTypes(['GET_MATCHES', 'GET_PITCHES'], ['INCREMENT_COUNTER']),
  '@@SOCCER'
);
複製程式碼

這些 completer 都有第二個引數位 —— 用於配製例外的情況。

鑑於 SUCCESS-FAILURE 這種模式比較常見,目前的實現只會自動加 SUCCESS 和 FAILURE。不過,後期我們會支援使用者自定義規則的,敬請期待!

使用注入器(Injections)處理非同步操作

那麼,非同步 action 的支援如何呢?

當然也是支援的,多數情況下,我們寫的非同步 action 無非是從後端獲取資料,然後整合到 store 的狀態樹中。

寫法如下:

import SoccerService from '../services/SoccerService';

export const actions = createTypes(completeTypes['GET_MATCHES','GET_PITCHES'], '@SOCCER');

const actionCreators = {
  getMatches: () =>
    createThunkAction(actions.GET_MATCHES, 'matches', SoccerService.getMatches),
  getPitches: clubId =>
    createThunkAction(actions.GET_PITCHES, 'pitches', SoccerService.getPitches, () => clubId)
};

export default actionCreators;
複製程式碼

思路和剛開始是一樣的:載入資料時先將 loading 標誌置為 true ,然後根據後端的響應結果,選擇分發 SUCCESS 還是 FAILURE 的 action。使用這種方法,我們抽取出了大量的重複邏輯,也不用再建立 privateActionsCreators 物件了。

但是,如果我們想要在呼叫和分發過程中間執行一些自定義程式碼呢?

我們可以使用 注入器(injections) 來實現,在下面的例子中我們就用這個函式為 baseThunkAction 新增了一些自定義行為。

這兩個例子要傳達的思想是一樣的:

const actionCreators = {
  fetchSomething: () => async dispatch => {
    dispatch({ type: actions.FETCH });
    const response = Service.fetch();
    if (response.ok) {
      dispatch({ type: actions.FETCH_SUCCESS, payload: response.data });
      dispatch(navigationActions.push('/successRoute');
    } else {
      dispatch({ type: actions.FETCH_ERROR, payload: response.error });
      if (response.status === 404) {
        dispatch(navigationActions.push('/failureRoute');
      }
    }
  }
}
複製程式碼
const actionCreators = {
  fetchSomething: () => composeInjections(
    baseThunkAction(actions.FETCH, 'fetchTarget', Service.fetch),
    withPostSuccess(dispatch => dispatch(navigationActions.push('/successRoute'))),
    withStatusHandling({ 404: dispatch => dispatch(navigationActions.push('/failureRoute')) })
  )
}
複製程式碼

以上是對這個庫的一些簡介,詳情請參考 github.com/Wolox/redux…。 安裝姿勢:

npm install --save redux-recompose
複製程式碼

感謝 Andrew Clark,他建立的 recompose 給了我很多靈感。同時也感謝 redux 的創始人 Dan Abramov,他的話給了我很多啟發。

當然,也不能忘了同在 Wolox 裡的戰友們,是大家一起合力才完成了這個專案。

歡迎各位積極提出意見,如果在使用中發現任何 bug,一定要記得在 GitHub 上給我們反饋,或者提交你的修復補丁,總之,我希望大家都能積極參與到這個專案中來!

在以後的文章中,我們將會討論更多有關 effects、注入器(injectors)和 completers 的話題,同時還會教你如何將其整合到 apisauceseamless-immutable 中使用。

希望你能繼續關注!


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章