【譯文】構建大型 Redux 應用的五個建議

本篇譯文的原文:Five Tips for Working with Redux in Large Applicationsjavascript

譯者序

爲何翻譯這篇文章,是由於本文中給出的建議和我在實際項目中的實踐不謀而合,更完全也更優秀。因此特別想分享給你們。前端

當項目規模逐漸增大以後,入門文檔和教程級別的項目代碼的侷限性會逐漸顯現出來,而且你會遇到在小型應用中不會遇到的問題。更致命的地方在於,若是想要解決這些問題,須要對整個應用的代碼作出調整。因此最好是在創建項目之處就有意識的融入最佳實踐,有助於預防未來問題的發生。java

這篇文章並不適合 redux 的初學者,但願你已經開發了少量完整應用,或者至少正在開發你的第一個應用的時候來閱讀這篇文章,這樣你才更有體會。react

本文給出的建議在 Redux 的官方文檔或者 React 的官方文檔裏或多或少確定都有說起。可是文檔太龐大,入口太深以致於把這些內容給淹沒了。若是你尚未接觸過它們,至少這篇文章不會再讓你錯過它們。git

有些不便的翻譯,或者翻譯後很彆扭,或者你們公認的技術詞彙的地方仍然保持原文。下面正式開始github

正文

Redux 是一個用於管理應用狀態的出色工具。它的單向數據流和 immmutable state 特點讓咱們更容易追蹤狀態的變動。每個狀態的變動都是由被調度的 action 引發 reducer 函數返回新的狀態而產生的。咱們站點上許多使用 Redux 構建的用戶界面都須要處理大量的數據和複雜的交互,由於用戶須要經過這些界面管理他們的廣告或者在平臺上更新庫存信息。在開發這些界面的過程當中,咱們掌握了一些規則和竅門有助於讓 Redux 更易於維護。接下來要討論的幾個要點相信對那些使用 Redux 開發大型數據集成類型的應用的同窗們會有所幫助數據庫

  • 使用索引和選擇器用於排序和訪問數據
  • 把數據對象與編輯狀態和其餘的UI狀態隔離開
  • 如何在多個視圖間共享狀態
  • 在狀態間重用 reducer
  • 將組件鏈接至 Redux 狀態的最佳實踐

1. 使用索引(index)存儲數據,使用選擇器(selector)訪問數據

選擇正確的數據結構對應用的組織和性能相當重要。使用索引存儲來自接口的可序列化數據會帶來不少好處。索引指的是咱們須要進行存儲的對象裏的對象id,而值則是對象自己。這個模式相似於使用哈希map來存儲數據,能夠節省查找的時間。對於精通 Redux 的人來講這可能不足爲奇。事實上 Redux 的做者,Dan Abramob 在他的 Redux 教程裏也推薦這種數據結構redux

想象你從 REST 接口裏請求到了一個列表數據,好比來自/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]);
}
複製代碼

選擇器模式一樣提升了代碼的可維護性。想象或許一段時間後咱們須要改變狀態的結構(shape)。若是沒有選擇器的話,須要更新全部的視圖代碼來響應狀態結構的修改。隨着視圖組件的增長,更改狀態結構的負擔也會劇烈增加。爲了不這個問題,在視圖中咱們使用選擇器來訪問狀態,若是底層的狀態結構發生了改變,咱們只須要更新選擇器來保證訪問狀態方式的正確性。全部消費方的組件依然會獲得它們須要的數據而不用進行更改。基於全部這些緣由,大型的 Redux 應用會從索引和選擇器的存儲模式中受益

2. 將標準狀態與視圖和編輯狀態區分開

真實的 Redux 應用一般須要從另外一個服務請求一些數據,好比 REST 接口。當獲取到數據時,會發起一個 action, 而且附帶上剛剛取得的數據。咱們傾向於把來自服務的返回數據稱之爲「標準狀態」(canonical state)。也就是狀態中那些來自數據庫中的數據。狀態也包括其餘類型的數據,好比組件的狀態,或者應用總體的狀態。當首次從API中取得標準數據時,會嘗試把它和頁面的其餘狀態都存儲在同一個 reducer 中。這個方法雖然會很方便,可是當你須要從不一樣的源請求更多類型的數據時,擴展起來會很是困難

另闢蹊徑的,咱們把標準狀態隔離到它獨立的 reducer 文件中去。這種方法鼓勵用更好的方式組織和模塊化代碼。縱向的拓展 reducer 文件(增長單個文件行數)的可維護性比橫向拓展 reducer (增長更多的reducer文件供 combineReducers調用)的可維護性差。將 reducers 拆分爲獨立的文件會在複用它們方面也會顯得更加容易。此外,它不鼓勵開發者向數據對象 reducer 添加非標準狀態

爲何不把其餘狀態和標準狀態存儲在一塊兒?想象一下咱們有一樣一份請求自 REST 接口的用戶列表數據。使用上一小節的索引模式進行存儲,能夠像這樣把數據存儲在 reducer 中:

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

如今想象 UI 容許用戶編輯視圖。當編輯圖標被用戶點擊時,咱們須要更新視圖狀態使得視圖爲用戶渲染出編輯控件。咱們決定將視圖狀態與標準狀態合併,在每個索引對象中添加一個新字段 isEditing,像這樣:

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

編輯以後,點擊提交按鈕,而後變動便經過 PUT 方法傳遞迴 REST 服務。服務返回對象的新狀態。可是如何將新的標準狀態合併到 store 中?若是隻是根據索引賦值新的對象的話,isEditing標誌便不復存在了。因此如今須要手動指定接口的返回中哪些字段須要合併到 store 中。這讓更新邏輯變得複雜了。你或許有多個布爾值、字符串、數組、或者其餘 UI 所需的新字段插入到了標準狀態中。在這個場景下,添加用於更新標準狀態的 action 或許很簡單,可是容易忘記重置對象裏的 UI 字段而形成無效的狀態。因此咱們應該保證標準狀態在 store 的獨立的 reducer 中,而且保證 action 簡單而且易於追蹤

另外一個把編輯狀態獨立出來的好處是,若是用戶取消了編輯,咱們能很容易的回滾到標準狀態。想象用戶點擊了編輯圖標,而且已經編輯了名稱和郵箱字段,如今他不想保留這些更改了,因此他點擊了取消按鈕。這個操做會引發視圖的狀態恢復到前一個狀態。可是若是已經把標準狀態和編輯狀態合二爲一,咱們便再也不擁有舊的數據,而不得不被迫從新從 REST 接口中再一次請求數據以得到標準狀態。因此如今把編輯狀態存儲到其餘的地方。如今總體狀態看上去:

{
 "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 接口。額外的,咱們仍然能在 store 中追蹤編輯狀態。若是決定繼續使用上次的編輯,那麼只須要再一次點擊編輯按鈕,舊的更改隨着編輯狀態又會呈現出來。總的來講,保證視圖和編輯狀態與標準狀態的分離,在代碼的組織和可維護性上帶來更好的開發體驗,同時也給使用表單的用戶帶來了更好的交互體驗。

3. 明智的在視圖間共享狀態

許多應用在發起時只有一個用戶界面和單個 store。隨着功能的增加應用的也會變得龐大,咱們須要管理不一樣視圖和 store 之間的狀態。爲了擴展 Redux 應用,爲每個頁面建立一個頂級 reducer 或許是一件有益的事情。每一個頁面和頂級 reducer 對應於應用中的一個視圖。舉個例子,用戶列表視圖會從接口請求全部的用戶數據,而後存儲在 users reducer 中,另外一個負責追蹤當前用戶擁有域名的頁面會從域名接口請求數據而後儲存下來,狀態看起來相似於:

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

像這樣組織頁面可以使視圖和數據解耦且獨立(self-contained)。每個頁面追蹤它本身的狀態,reducer 文件甚至也能和視圖文件遙相呼應(co-located)。當繼續擴展應用時,咱們也許會發現須要在不一樣的視圖間共享它們共同依賴的狀態。當考慮共享狀態時請思考如下幾點:

  • 有多少視圖或者 reducer 依賴這一份數據?
  • 每一個頁面都須要這份數據的副本嗎?
  • 數據更新的頻率時多少?

舉個例子,應用須要在每一個頁面展現當前登錄用戶的信息。咱們須要從接口中獲取用戶信息而後存儲在 store 中。咱們知道每一個頁面都依賴這份數據,因此這份數據並不適用於「每一個頁面都有獨立的 reducer」這個策略。咱們也知道每一個頁面不須要依賴這份數據的副本,由於大多數頁面不會請求其餘的用戶也不會修改當前的用戶。並且,這份關於當前登錄用戶的數據不太可能發生更改,除非他們在用戶頁面修改他們本身。

那麼在頁面間分享當前用戶的狀態彷佛是一個好主意,因此咱們把這份數據抽取出來放在處於頂級的它本身的 reducer 中。如今用戶第一次訪問的頁面會檢查當前用戶的 reducer 是否已經被加載,若是沒有的話從接口進行請求。任何鏈接到 Redux store 的視圖都能瀏覽這份關於當前登錄用戶的信息。

對於那些共享狀態沒有意義的場景怎麼辦?讓咱們來考慮另外一個例子。想象每個屬於用戶的域名下一樣也擁有必定數量的子域名。咱們將新增一個子域名列表頁面用於展現用戶的全部子域名列表,域名列表頁面一樣提供展現已選域名的子域名。如今就有兩個頁面同時來展現子域名數據。咱們也知道域名經常被修改,用戶可能會在任什麼時候候增長、刪除或者編輯域名或者子域名。每一個頁面可能須要獨一無二的數據副本。子域名頁面容許經過子域名接口讀或者寫,並且有可能須要對數據進行翻頁操做。相反域名視圖只須要一次獲取子域名的一部分子集(被選擇的域名的子域名)。這樣看來結果很是明確了,在這個場景中在不一樣頁面共享子域名狀態彷佛不是一個好的選擇。每一個頁面應該存儲它本身子域名數據的副本

4. 跨狀態的重用公共 reducer 函數

在編寫了好幾個 reducer 函數以後,咱們決定嘗試在狀態中的不一樣地方複用 reducer 邏輯。舉個例子,咱們建立了一個 reducer 從接口請求用戶信息。接口每次只返回 100 條用戶信息,可是在系統中有成千上萬個。爲了解決這個問題,reducer 須要記錄當前展現的是數據的哪一頁。請求邏輯會從 reducer 中讀取該信息而後決定下一個請求的翻頁參數是什麼(好比叫page_number)。以後在請求域名列表時,最終也編寫了相同的邏輯用於請求和存儲域名信息,只是接口和對象的結構(schema)不一樣而已,翻頁的行爲仍然保持一致。聰明的開發者會意識到或許可以把 reducer 模塊化而且在任何須要翻頁的 reducer 中共享這段邏輯。

在 Redux 中共享 reducer 邏輯須要一些小技巧。默認狀況下,當一個新的 action 發起時全部的 reducer 函數都會被調用。若是在多個 reducer 函數中共享同一個 reducer 函數,那麼當 action 被髮起時它會引發全部的 reducer 被觸發。這不是重用 reducer 指望的行爲。也就是說當請求用戶列表而且取得了500條數據時,咱們不但願域名列表的個數也變成500

咱們推薦是兩種方式來實現共享,二者都使用做用域(scope)或者前綴(prefix)對動做類型(types)進行特殊處理。第一種方式須要在 action 攜帶的信息種傳遞一個做用域。action 使用動做類型來推斷狀態中的哪一個字段須要發生更改。爲了便於說明,假設有一個擁有多個不一樣區域(section)的頁面,全部區域都從接口處異步進行加載。追蹤加載狀況的狀態像這個樣子:

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

有了這個狀態,接下來須要藉助 reducer 和 action 來設置每一個區域視圖加載狀態。咱們能夠寫擁有不一樣 action 的四個 reducer,每個都有獨立的動做類型。但那是一大堆的重複代碼。取而代之的是,讓咱們嘗試使用具備做用域的 reducer 和 action,只建立一個動做類型SET_LOADING, 和一個像這樣的 reducer 函數:

const loadingReducer = (state = initialLoadingState, action) => {
  const { type, payload } = action;
  if (type === SET_LOADING) {
    return Object.assign({}, state, {
      // sets the loading boolean at this scope
      [`${payload.scope}Loading`]: payload.loading,
    });
  } else {
    return state;
  }
}
複製代碼

同時也須要提供一個帶有做用域的 action creator 函數來調用做用域 reducer。action 看起來像:

const setLoading = (scope, loading) => {
  return {
    type: SET_LOADING,
    payload: {
      scope,
      loading,
    },
  };
}
// example dispatch call
store.dispatch(setLoading('users', true));
複製代碼

經過使用一個像這樣帶有做用域的 reducer,解決須要在不一樣 reducer 函數和 action 中重複相同邏輯的問題。這顯著下降了重複代碼的數量以及幫助咱們編寫更小的 action 和 reducer 文件。若是須要在頁面中添加另外一個區域視圖,只須要簡單的在初始狀態中添加一個新索引,而後使用不一樣的做用域調用setLoading。這個解決辦法在擁有幾個須要以一樣方式更新的類似字段時很是有效

一樣想要在狀態的不一樣處共享 reducer 邏輯,不一樣於使用同一個 reducer 和 action 更新狀態中的多個字段,咱們但願在調用combineReducers時插拔式的重用 reducer 函數。那麼須要經過調用 reducer 工廠函數返回一個帶有類型前綴的 reducer 函數。

一個複用 reducer 邏輯很好的例子是處理翻頁信息時。回到請求用戶信息的例子中,接口或許包含上千個用戶,接口也將提供將用戶分頁以後的翻頁信息。假設接收到的接口返回長這個樣子:

{
  "users": ...,
  "count": 2500, // the total count of users in the API
  "pageSize": 100, // the number of users returned in one page of data
  "startElement": 0, // the index of the first user in this response
  ]
}
複製代碼

若是想要下一頁的數據,須要發起一個帶着startElement=100參數的 GET 請求。咱們恰好爲每個打交道的服務構建了一個 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;
};
// example usages
const usersReducer = combineReducers({
  usersData: usersDataReducer,
  paginationData: paginationReducerFor('USERS_'),
});
const domainsReducer = combineReducers({
  domainsData: domainsDataReducer,
  paginationData: paginationReducerFor('DOMAINS_'),
});
複製代碼

reducer 工廠函數paginationReducerFor接收類型前綴參數,該參數將會被添加在該 reducer 函數內全部的類型前。工廠返回一個全部類型都已添加前綴的新的 reducer。如今當發起一個相似於 USERS_SET_PAGINATION 的 action 時,它只會引發用戶信息的翻頁 reducer 的更新。域名的翻頁 reducer 仍然保持不變。這有效的在 store 的多處重用 reducer 函數。爲了代碼的完整性,這還有一個帶有前綴的 action creator 工廠函數:

const setPaginationFor = (prefix) => { 
  const setPagination = (response) => {
    const {
      startElement,
      pageSize,
      count,
    } = response;
    return {
      type: prefix + types.SET_PAGINATION,
      payload: {
        startElement,
        pageSize,
        count,
      },
    };
  };
  return setPagination;
};
// example usages
const setUsersPagination = setPaginationFor('USERS_');
const setDomainsPagination = setPaginationFor('DOMAINS_');
複製代碼

5. 整合 React

有一些 Redux 應用永遠也不須要給用戶渲染視圖(像接口同樣),可是大部分狀況下你須要視圖將數據渲染出來。目前最受歡迎的與 Redux 配合的渲染 UI 類庫是 React,這也是接下來用於展現如何與 Redux 整合的 UI 類庫。咱們可使用上面幾個小節中學習到的策略來讓視圖代碼更友好。爲了實現整合,咱們將使用 react-redux 類庫

UI 整合的一個有用模式是在視圖中使用訪問器訪問狀態中的數據, 在react-redux中便於放置訪問器的地方是mapStateToProps函數中。這個函數在connect函數(用於將 React 組件鏈接至 Redux store 的函數)被調用時傳遞進去。在這裏你能將狀態中的數據映射爲組件接收到的屬性。這是一個完美的使用選擇器從狀態獲取數據,而後以屬性的形式傳遞給組件的地方。整合的例子以下:

const ConnectedComponent = connect(
  (state) => {
    return {
      users: selectors.getCurrentUsers(state),
      editingUser: selectors.getEditingUser(state),
      ... // other props from state go here
    };
  }),
  mapDispatchToProps // another `connect` function
)(UsersComponent);
複製代碼

這種在 React 和 Redux 之間的整合也爲咱們提供了使用做用域和類型封裝 action 的場所。咱們須要組件的處理函數有能力喚起 store 並調用 action creator。爲了完成這項任務,在react-redux中調用connect時,咱們傳入mapDispatchToProps函數。函數mapDispatchToProps是調用 Redux 的 bindActionCreators 函數用於將 action 和 dispatch 方法綁定起來的地方。在其中咱們能夠像上一節展現的那樣給 action 綁定做用域。舉個例子,若是打算在用戶列表頁面中使用帶有做用域模式的 reduer 實現翻頁功能,代碼以下所示:

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

如今從UsersPage組件的角度來講,它接收到了用戶列表和其餘的狀態碎片,以及被綁定的 action creator 做爲屬性傳遞給它。組件不須要關心它須要帶有什麼做用域的 action 又或者如何訪問狀態;在整合的層面咱們已經對這些問題進行了處理。這種機制讓咱們可以建立不須要依賴狀態內部工做機制的很是鬆耦合的組件。但願藉助在這裏討論的各種模式,咱們都能以可伸縮,可維護,以及合理的方式建立 Redux 應用。

更多參考

  • 剛剛討論的狀態管理類庫 Redux
  • 用於建立選擇器的 Reselect 類庫
  • Normalizr 是一個用於「標準化」(normalizing)JSON 數據的類庫。對使用索引存儲數據很是有幫助
  • 用於在 Redux 中使用異步 action 的中間件類庫 Redux-Thunk
  • 使用 ES2016 generator 實現的異步 action 的另外一箇中間件類庫 Redux-Saga

本文同時也發佈在個人知乎前端專欄,歡迎你們關注

相關文章
相關標籤/搜索