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

李熠發表於2019-03-02

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

譯者序

為什麼翻譯這篇文章,是因為本文中給出的建議和我在實際專案中的實踐不謀而合,更徹底也更優秀。所以特別想分享給大家。

當專案規模逐漸增大之後,入門文件和教程級別的專案程式碼的侷限性會逐漸顯現出來,並且你會遇到在小型應用中不會遇到的問題。更致命的地方在於,如果想要解決這些問題,需要對整個應用的程式碼做出調整。所以最好是在建立專案之處就有意識的融入最佳實踐,有助於預防將來問題的發生。

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

本文給出的建議在 Redux 的官方文件或者 React 的官方文件裡或多或少肯定都有提及。但是文件太龐大,入口太深以至於把這些內容給淹沒了。如果你還沒有接觸過它們,至少這篇文章不會再讓你錯過它們。

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

正文

Redux 是一個用於管理應用狀態的出色工具。它的單向資料流和 immmutable state 特色讓我們更容易追蹤狀態的變更。每一個狀態的變更都是由被排程的 action 引起 reducer 函式返回新的狀態而產生的。我們站點上許多使用 Redux 構建的使用者介面都需要處理大量的資料和複雜的互動,因為使用者需要通過這些介面管理他們的廣告或者在平臺上更新庫存資訊。在開發這些介面的過程中,我們掌握了一些規則和竅門有助於讓 Redux 更易於維護。接下來要討論的幾個要點相信對那些使用 Redux 開發大型資料整合型別的應用的同學們會有所幫助

  • 使用索引和選擇器用於排序和訪問資料
  • 把資料物件與編輯狀態和其他的UI狀態隔離開
  • 如何在多個檢視間共享狀態
  • 在狀態間重用 reducer
  • 將元件連線至 Redux 狀態的最佳實踐

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

選擇正確的資料結構對應用的組織和效能至關重要。使用索引儲存來自介面的可序列化資料會帶來很多好處。索引指的是我們需要進行儲存的物件裡的物件id,而值則是物件本身。這個模式類似於使用雜湊map來儲存資料,可以節省查詢的時間。對於精通 Redux 的人來說這可能不足為奇。事實上 Redux 的作者,Dan Abramob 在他的 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

本文同時也釋出在我的知乎前端專欄,歡迎大家關注

相關文章