[譯] 在大型應用中使用 Redux 的五個技巧

loveky發表於2017-08-01

在大型應用中使用 Redux 的五個技巧

Redux 是一個很棒的用於管理應用程式“狀態(state)”的工具。單向資料流以及對不可變資料的關注使得推斷狀態的變化變得很簡單。每次狀態變化都由一個 action 觸發,這會導致 reducer 函式返回一個變更後的新狀態。由於客戶要在我們的平臺上管理或釋出廣告資源,在 AppNexus 使用 Redux 建立的很多使用者介面都需要處理大量資料以及非常複雜的互動。在開發這些介面的過程中,我們發現了一些有用的規則和技巧以維持 Redux 易於管理。以下的幾點討論應該可以幫助到任何在大型、資料密集型應用中使用 Redux 的開發者:

  • 第一點: 在儲存和訪問狀態時使用索引和選擇器
  • 第二點: 把資料物件,對資料物件的修改以及其它 UI 狀態區分開
  • 第三點: 在單頁應用的不同頁面間共享資料,以及何時不該這麼做
  • 第四點: 在狀態中的不同節點複用通用的 reducer 函式
  • 第五點: 連線 React 元件與 Redux 狀態的最佳實踐

1. 使用索引(index)儲存資料,使用選擇器(selector)讀取資料

選擇正確的資料結構可以對程式的結構和效能產生很大影響。在儲存來自 API 的可序列化資料時可以極大的受益於索引的使用。索引是指一個 JavaScript 物件,其鍵是我們要儲存的資料物件的 id,其值則是這些資料物件自身。這種模式和使用 hashmap 儲存資料非常類似,在查詢效率方面也有相同的優勢。這一點對於精通 Redux 的人來說不足為奇。實際上,Redux 的作者 Dan Abramov 在他的 Redux 教程中就推薦了這種資料結構。

設想你有一組從 REST API 獲取的資料物件,例如來自 /users 服務的資料。假設我們決定直接將這個普通陣列原封不動地儲存在狀態中,就像它在響應中那樣。當我們需要獲取一個特定使用者物件時會怎樣呢?我們需要遍歷狀態中的所有使用者。如果使用者很多,這可能會是一個代價高昂的操作。如果我們想跟蹤使用者的一小部分,例如選中和未選中的使用者呢?我們要麼需要把資料儲存在兩個陣列中,要麼就要記錄這些選中和未選中使用者在主陣列中的索引(譯者注:此處指的是普通意義上的陣列索引)。

然而,我們決定重構程式碼改用索引的方式儲存資料。我們可以在 reducer 中以如下的方式儲存資料:

{
 "usersById": {
    123: {
      id: 123,
      name: "Jane Doe",
      email: "jdoe@example.com",
      phone: "555-555-5555",
      ...
    },
    ...
  }
}複製程式碼

那麼這種資料結構到底是如何幫助我們解決以上問題的呢?如果要查詢一個特定使用者,你可以直接用 const user = state.usersById[userId] 讀取狀態。這種方式不需要我們遍歷整個列表,節省時間的同時簡化了程式碼。

此時你可能會好奇我們如何通過這種資料結構來展示一個簡單的使用者列表呢。為此,我們需要使用一個選擇器,它是一個接收狀態並返回所需資料的函式。一個簡單的例子是一個返回狀態中所有使用者的函式:

const getUsers = ({ usersById }) => {
  return Object.keys(usersById).map((id) => usersById[id]);
}複製程式碼

在我們的檢視程式碼中,我們呼叫該方法以獲取使用者列表。然後就可以遍歷這些使用者生成檢視了。我們可以建立另一個函式用於從狀態中獲取指定使用者:

const getSelectedUsers = ({ selectedUserIds, usersById }) => {
  return selectedUserIds.map((id) => usersById[id]);
}複製程式碼

選擇器模式還同時增加了程式碼的可維護性。設想以後我們想要改變狀態的結構。在不使用選擇器的情況下,我們不得不更新所有的檢視程式碼以適應新的狀態結構。隨著檢視元件的增多,修改狀態結構的負擔會急劇增加。為了避免這種情況,我們在檢視中通過選擇器讀取狀態。即使底層的狀態結構發生了改變,我們也只需要更新選擇器。所有依賴狀態的元件仍將可以獲取它們的資料,我們也不必更新它們。出於所有這些原因,大型 Redux 應用將受益於索引與選擇器資料儲存模式。

2. 將標準狀態與檢視狀態、編輯狀態分隔開

現實中的 Redux 應用通常需要從一些服務(例如一個 REST API)讀取資料。在收到資料以後,我們傳送一個包含了收到的資料的 action。我們把這些從服務返回的資料稱為“標準狀態” —— 即當前在我們資料庫中儲存的資料的正確狀態。我們的狀態還包含其他型別的資料,例如使用者介面元件的狀態或是整個應用程式的狀態。當首次從 API 讀取到標準狀態時,我們可能會想將其與頁面的其他狀態儲存在同一個 reducer 檔案中。這種方式可能很省事,但當你需要從不同資料來源獲取多種資料時,它就會變得難以擴充套件。

相反,我們會把標準狀態儲存在它單獨的 reducer 檔案中。這會促使你編寫組織更加良好、更加模組化的程式碼。垂直擴充套件 reducer(增加程式碼行數)比水平擴充套件 reducer(在 combineReducers 呼叫中引入更多的 reducer)的可維護性要差。將 reducers 拆分到各自的檔案中有利於複用這些 reducer(在第三點中會詳細討論)。此外,這還可以阻止開發者將非標準狀態新增到資料物件 reducer 中。

為什麼不把其他型別的狀態和標準狀態儲存在一起呢?假設我們像第一部分一樣從 REST API 獲得一組使用者資料。利用索引儲存模式,我們會像下面這樣將其儲存在 reducer 中:

{
 "usersById": {
    123: {
      id: 123,
      name: "Jane Doe",
      email: "jdoe@example.com",
      phone: "555-555-5555",
      ...
    },
    ...
  }
}複製程式碼

現在假設我們的介面允許編輯使用者資訊。當點選某個使用者的編輯圖示時,我們需要更新狀態,以便檢視呈現出該使用者的編輯控制元件。我們決定在 users/by-id 索引中儲存的資料物件上新增一個欄位,而不是分開儲存檢視狀態和標準狀態。現在我們的狀態看起來是這個樣子:

{
 "usersById": {
    123: {
      id: 123,
      name: "Jane Doe",
      email: "jdoe@example.com",
      phone: "555-555-5555",
      ...
      isEditing: true,
    },
    ...
  }
}複製程式碼

我們進行了一些修改,點選提交按鈕,改動以 PUT 形式提交回 REST 服務。服務返回了該使用者最新的狀態。可是我們該如何將最新的標準狀態合併到 store 呢?如果我們直接把新物件儲存到 users/by-id 索引中對應的 id 下,那麼 isEditing 標記就會丟失。我們不得不手動指定來自 API 的資料中哪些欄位需要儲存到 store 中。這使得更新邏輯變得複雜。你可能要追加多個布林、字串、陣列或其他型別的新欄位到標準狀態中以維護檢視狀態。這種情況下,當新增一個 action 修改標準狀態時很容易由於忘記重置這些 UI 欄位而導致無效的狀態。相反,我們在 reducer 中應該將標準狀態儲存在其獨立的資料儲存中,並保持我們的 action 更簡單,更容易理解。

將編輯狀態分開儲存的另一個好處是如果使用者取消編輯我們可以很方便的重置回標準狀態。假設我們點選了某個使用者的編輯圖示,並修改了該使用者的姓名和電子郵件地址。現在假設我們不想儲存這些修改,於是我們點選取消按鈕。這應該導致我們在檢視中做的修改恢復到之前的狀態。然而,由於我們用編輯狀態覆蓋了標準狀態,我們已經沒有舊狀態的資料了。我們不得不再次請求 REST API 以獲取標準狀態。相反,讓我們把編輯狀態分開儲存。現在我們的狀態看起來是這個樣子:

{
 "usersById": {
    123: {
      id: 123,
      name: "Jane Doe",
      email: "jdoe@example.com",
      phone: "555-555-5555",
      ...
    },
    ...
  },
  "editingUsersById": {
    123: {
      id: 123,
      name: "Jane Smith",
      email: "jsmith@example.com",
      phone: "555-555-5555",
    }
  }
}複製程式碼

由於我們同時擁有該物件在編輯狀態和標準狀態下的兩個副本,在點選取消後重置狀態變得很簡單。我們只需在檢視中展示標準狀態而不是編輯狀態即可,不必再次呼叫 REST API。作為獎勵,我們仍然在 store 中跟蹤著資料的編輯狀態。如果我們決定確實需要保留這些更改,我們可以再次點選編輯按鈕,此時之前的修改狀態就又可以展示出來了。總之,把編輯狀態和檢視狀態與標準狀態區分開儲存既在程式碼組織和可維護性方面提供了更好的開發體驗,又在表單操作方面提供了更好的使用者體驗。

3. 合理地在檢視之間共享狀態

許多應用起初都只有一個 store 和一個使用者介面。隨著我們為了擴充套件功能而不斷擴充套件應用,我們將要管理多個不同檢視和 store 之間的狀態。為每個頁面建立一個頂層 reducer 可能有助於擴充套件我們的 Redux 應用。每個頁面和頂層 reducer 對應我們應用中的一個檢視。例如,使用者頁面會從 API 獲取使用者資訊並儲存在 users reducer 中,而另一個為當前使用者展示域名資訊的頁面會從域名 API 存取資料。此時的狀態看起來會是如下結構:

{
  "usersPage": {
    "usersById": {...},
    ...
  },
  "domainsPage": {
    "domainsById": {...},
    ...
  }
}複製程式碼

像這樣組織頁面有助於保持這些頁面背後的資料之間的解耦與獨立。每個頁面跟蹤各自的狀態,我們的 reducer 檔案甚至可以和檢視檔案儲存在相同位置。隨著我們不斷擴充套件應用程式,我們可能會發現需要在兩個檢視之間共享一些狀態。在考慮共享狀態時,請思考以下幾個問題:

  • 有多少檢視或者其他 reducer 依賴此部分資料?
  • 每個頁面是否都需要這些資料的副本?
  • 這些資料的改動有多頻繁?

例如,我們的應用在每個頁面都要展示一些當前登入使用者的資訊。我們需要從 API 獲取使用者資訊並儲存在 reducer 中。我們知道每個頁面都會依賴於這部分資料,所以它似乎並不符合我們每個頁面對應一個 reducer 的策略。我們清楚沒必要為每個頁面準備一份這部分資料的副本,因為絕大多數頁面都不會獲取其他使用者或編輯當前使用者。此外,當前登入使用者的資訊也不太會改變,除非客戶在使用者頁面編輯自己的資訊。

在頁面之間共享當前使用者資訊似乎是個好辦法,於是我們把這部分資料提升到專屬於它的、單獨儲存的頂層 reducer 中。現在,使用者首次訪問的頁面會檢查當前使用者資訊是否載入,如果未載入則呼叫 API 獲取資訊。任何連線到 Redux 的檢視都可以訪問到當前登入使用者的資訊。

不適合共享狀態的情況又如何呢?讓我們考慮另一種情況。設想使用者名稱下的每一個域名還包含一系列子域名。我們增加了一個子域名頁面用以展示某個使用者名稱下的全部子域名。域名頁面也有一個選項用以展示該域名下的子域名。現在我們有兩個頁面同時依賴於子域名資料。我們還知道域名資訊可能會頻繁改動 —— 使用者可能會在任何時間增加、刪除或是編輯域名與子域名。每個頁面也可能需要它自己的資料副本。子域名頁面允許通過子域名 API 讀取和寫入資料,可能還會需要對資料進行分頁。而域名頁面每次只需要獲取子域名的一個子集(某個特定域名的子域名)。很明顯,在這些檢視間共享子域名資料並不妥當。每個頁面應該單獨儲存其子域名資料。

4. 在狀態之間複用 reducer 函式

在編寫了一些 reducer 函式之後,我們可能想要在狀態中的不同節點間複用 reducer 邏輯。例如,我們可能會建立一個用於從 API 讀取使用者資訊的 reducer。該 API 每次返回 100 個使用者,然而我們的系統中可能有成千上萬的使用者。要解決該問題,我們的 reducer 還需要記錄當前正在展示哪一頁。我們的讀取邏輯需要訪問 reducer 以確定下一次 API 請求的分頁引數(例如 page_number)。之後當我們需要讀取域名列表時,我們最終會寫出幾乎完全相同的邏輯來讀取和儲存域名資訊,只不過 API 和資料結構不同罷了。

在 Redux 中複用 reducer 邏輯可能會有點棘手。預設情況下,當觸發一個 action 時所有的 reducer 都會被執行。如果我們在多個 reducer 函式中共享一個 reducer 函式,那麼當觸發一個 action 時所有這些 reducer 都會被呼叫。然而這並不是我們想要的結果。當我們讀取使用者得到總數是 500 時,我們不想域名的 count 也變成 500。

我們推薦兩種不同的方式來解決此問題,利用特殊作用域(scope)或是型別字首(prefix)。第一種方式涉及到在 action 傳遞的資料中增加一個型別資訊。這個 action 會利用該型別來決定該更新狀態中的哪個資料。為了演示該方法,假設我們有一個包含多個模組的頁面,每個模組都是從不同 API 非同步載入的。我們跟蹤載入過程的狀態可能會像下面這樣:

const initialLoadingState = {
  usersLoading: false,
  domainsLoading: false,
  subDomainsLoading: false,
  settingsLoading: false,
};複製程式碼

有了這樣的狀態,我們就需要設定各模組載入狀態的 reducer 和 action。我們可能會用 4 種 action 型別寫出 4 個不同的 reducer 函式 —— 每個 action 都有它自己的 action 型別。這就造成了很多重複程式碼!相反,讓我們嘗試使用一個帶作用域的 reducer 和 action。我們只建立一種 action 型別 SET_LOADING 以及一個 reducer 函式:

const loadingReducer = (state = initialLoadingState, action) => {
  const { type, payload } = action;
  if (type === SET_LOADING) {
    return Object.assign({}, state, {
      // 在此作用域內設定載入狀態
      [`${payload.scope}Loading`]: payload.loading,
    });
  } else {
    return state;
  }
}複製程式碼

我們還需要一個支援作用域的 action 生成器來呼叫我們帶作用域的 reducer。這個 action 生成器看起來是這個樣子:

const setLoading = (scope, loading) => {
  return {
    type: SET_LOADING,
    payload: {
      scope,
      loading,
    },
  };
}
// 呼叫示例
store.dispatch(setLoading('users', true));複製程式碼

通過像這樣使用一個帶作用域的 reducer,我們消除了在多個 action 和 reducer 函式間重複 reducer 邏輯的必要。這極大的減少了程式碼重複度同時有助於我們編寫更小的 action 和 reducer 檔案。如果我們需要在檢視中新增一個模組,我們只需在初始狀態中新增一個欄位並在呼叫 setLoading 時傳入一個新的作用域型別即可。當我們有幾個相似的欄位以相同的方式更新時,此方案非常有效。

有時我們還需要在 state 中的多個節點間共享 reducer 邏輯。我們需要一個可以通過 combineReducers 在狀態中不同節點多次使用的 reducer 函式,而不是在狀態中的某一個節點利用一個 reducer 與 action 來維護多個欄位。這個 reducer 會通過呼叫一個 reducer 工廠函式生成,該工廠函式會返回一個新增了型別字首的 reducer 函式。

複用 reducer 邏輯的一個絕佳例子就是分頁資訊。回到之前讀取使用者資訊的例子,我們的 API 可能包含成千上萬的使用者資訊。我們的 API 很可能會提供一些資訊用於在多頁使用者之間進行分頁。我們收到的 API 響應也許是這樣的:

{
  "users": ...,
  "count": 2500, // API 中包含的使用者總量
  "pageSize": 100, // 介面每一頁返回的使用者數量
  "startElement": 0, // 此次響應中第一個使用者的索引
  ]
}複製程式碼

如果我們想要讀取下一頁資料,我們會傳送一個帶有 startElement=100 查詢引數的 GET 請求。我們可以為每一個 API 都編寫一個 reducer 函式,但這樣會在程式碼中產生大量的重複邏輯。相反,我們要建立一個獨立的分頁 reducer。這個 reducer 會由一個接收字首型別為引數並返回一個新 reducer 的 reducer 工廠生成:

const initialPaginationState = {
  startElement: 0,
  pageSize: 100,
  count: 0,
};
const paginationReducerFor = (prefix) => {
  const paginationReducer = (state = initialPaginationState, action) => {
    const { type, payload } = action;
    switch (type) {
      case prefix + types.SET_PAGINATION:
        const {
          startElement,
          pageSize,
          count,
        } = payload;
        return Object.assign({}, state, {
          startElement,
          pageSize,
          count,
        });
      default:
        return state;
    }
  };
  return paginationReducer;
};
// 使用示例
const usersReducer = combineReducers({
  usersData: usersDataReducer,
  paginationData: paginationReducerFor('USERS_'),
});
const domainsReducer = combineReducers({
  domainsData: domainsDataReducer,
  paginationData: paginationReducerFor('DOMAINS_'),
});複製程式碼

reducer 工廠函式 paginationReducerFor 接收一個字首型別作為引數,此引數將作為該 reducer 匹配的所有 action 型別的字首使用。這個工廠函式會返回一個新的、已經新增了型別字首的 reducer。現在,當我們傳送一個 USERS_SET_PAGINATION 型別的 action 時,它只會觸發維護使用者分頁資訊的 reducer 更新。域名分頁資訊的 reducer 則不受影響。這允許我們有效地在 store 中複用通用 reducer 函式。為了完整起見,以下是一個配合我們的 reducer 工廠使用的 action 生成器工廠,同樣使用了字首:

const setPaginationFor = (prefix) => {
  const setPagination = (response) => {
    const {
      startElement,
      pageSize,
      count,
    } = response;
    return {
      type: prefix + types.SET_PAGINATION,
      payload: {
        startElement,
        pageSize,
        count,
      },
    };
  };
  return setPagination;
};
// 使用示例
const setUsersPagination = setPaginationFor('USERS_');
const setDomainsPagination = setPaginationFor('DOMAINS_');複製程式碼

5. React 整合與包裝

有些 Redux 應用可能永遠都不需要向使用者呈現一個檢視(如 API),但大多數時間你都會想把資料渲染到某種形式的檢視中。配合 Redux 渲染頁面最流行的庫是 React,我們也將使用它演示如何與 Redux 整合。我們可以利用在前幾點中學到的策略簡化我們建立檢視程式碼的過程。為了實現整合,我們要用到 react-redux 。這裡就是將狀態中的資料對映到你元件的 props 的地方。

在 UI 整合方面一個有用的模式是在檢視元件中使用選擇器訪問狀態中的資料。在 react-redux 中的 mapStateToProps 函式中使用選擇器很方便。該函式會在呼叫 connect 方法(該方法用於將你的 React 元件連線到 Redux store)時作為引數傳入。這裡是使用選擇器從狀態中獲取資料並通過 props 傳遞給元件的絕佳位置。以下是一個整合的例子:

const ConnectedComponent = connect(
  (state) => {
    return {
      users: selectors.getCurrentUsers(state),
      editingUser: selectors.getEditingUser(state),
      ... // 其它來自狀態的 props
    };
  }),
  mapDispatchToProps // 另一個 connect 函式
)(UsersComponent);複製程式碼

React 與 Redux 之間的整合也提供了一個方便的位置來封裝我們按作用域或型別建立的 action。我們必須連線我們元件的事件處理函式,以便在呼叫 store 的 dispatch 方法時使用我們的 action 生成器。要在 react-redux 中實現這一點,我們要使用 mapDispatchToProps 函式,它也會在呼叫 connect 方法時作為引數傳入。這個 mapDispatchToProps 方法就是通常我們呼叫 Redux 的 bindActionCreators 方法將每個 action 和 store 的 dispatch 方法繫結的地方。在我們這樣做的時候,我們也可以像在第四點中那樣把作用域繫結到 action 上。例如,如果我們想在使用者頁面使用帶作用域的 reducer 模式的分頁功能,我們可以這樣寫:

const ConnectedComponent = connect(
  mapStateToProps,
  (dispatch) => {
    const actions = {
      ...actionCreators, // other normal actions
      setPagination: actionCreatorFactories.setPaginationFor('USERS_'),
    };
    return bindActionCreators(actions, dispatch);
  }
)(UsersComponent);複製程式碼

現在,從我們 UsersPage 元件的角度看來,它只接收一個使用者列表、狀態的一部分以及繫結過的 action 生成器作為props。元件不需要知道它需要使用哪個作用域的 action 也不需要知道如何訪問狀態;我們已經在整合層面處理了這些問題。這使得我們可以建立一些非常獨立的元件,它們並不依賴於狀態內部的細節。希望通過遵循本文討論的模式,我們都可以以一種可擴充套件的、可維護的、合理的方式開發 Redux 應用。

延伸閱讀:

  • Redux 本文討論的狀態管理庫
  • Reselect 一個用於建立選擇器的庫
  • Normalizr 一個用於根據模式規範 JSON 資料的庫,有助於在索引中儲存資料
  • Redux-Thunk 一個用於處理 Redux 中非同步 action 的中介軟體
  • Redux-Saga 另一個利用 ES2016 生成器處理非同步 action 的中介軟體

掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章