在大型應用中使用 Redux 的五個技巧
Redux 是一個很棒的用於管理應用程序“狀態”的工具。單向數據流以及對不可變數據的關註使得推斷狀態的變化變得很簡單。每次狀態變化都由一個 action 觸發,這會導致 reducer 函數返回一個變更後的新狀態。由於客戶要在我們的平臺上管理或發布廣告資源,在 AppNexus 使用 Redux 創建的很多用戶界面都需要處理大量數據以及非常復雜的交互。在開發這些界面的過程中,我們發現了一些有用的規則和技巧以維持 Redux 易於管理。以下的幾點討論應該可以幫助到任何在大型,數據密集型應用中使用 Redux 的開發者:
- 第一點: 在存儲和訪問狀態時使用索引和選擇器
- 第二點: 把數據對象,對數據對象的修改以及其它 UI 狀態區分開
- 第三點: 在單頁應用的不同頁面間共享數據,以及何時不該這麽做
- 第四點: 在狀態中的不同節點復用通用的 reducer 函數
- 第五點: 連接 React 組件與 Redux 狀態的最佳實踐
1. 使用索引保存數據,使用選擇器讀取數據
選擇正確的數據結構可以對程序的結構和性能產生很大影響。存儲來自 API 的可序列化數據可以極大的受益於索引的使用。索引是指一個 JavaScript 對象,其鍵是我們要存儲的數據對象的 id,其值則是這些數據對象自身。這種模式和使用 hashmap 存儲數據非常類似,在查詢效率方面也有相同的優勢。這一點對於精通 Redux 的人來說不足為奇。實際上,Redux 的作者 Dan Abramov 在它的 Redux 教程中 就推薦了這種數據結構。
設想你有一組從 REST API 獲取的數據對象,例如來自 /users
服務的數據。假設我們決定直接將這個普通數組存儲在狀態中,就像在響應中那樣。當我們需要獲取一個特定用戶對象時會怎樣呢?我們需要遍歷狀態中的所有用戶。如果用戶很多,這可能會是一個代價高昂的操作。如果我們想跟蹤用戶的一小部分,例如選中和未選中的用戶呢?我們要麽需要把數據保存在兩個數組中,要麽就要跟蹤這些選中和未選中用戶在主數組中的索引(譯者註:此處指的是普通意義上的數組索引)。
然而,我們決定重構代碼改用索引的方式存儲數據。我們可以在 reducer 中以如下的方式存儲數據:
{ "usersById": { 123: { id:123, name: "Jane Doe", email: "[email protected]", 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: "[email protected]", phone: "555-555-5555", ... }, ... } }
現在假設我們的界面允許編輯用戶信息。當點擊某個用戶的編輯圖標時,我們需要更新狀態,以便視圖呈現出該用戶的編輯控件。我們決定在 users/by-id
索引中存儲的數據對象上新增一個字段,而不是分開存儲視圖狀態和標準狀態。現在我們的狀態看起來是這個樣子:
{ "usersById": { 123: { id: 123, name: "Jane Doe", email: "[email protected]", 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: "[email protected]", phone: "555-555-5555", ... }, ... }, "editingUsersById": { 123: { id: 123, name: "Jane Smith", email: "[email protected]", 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。
我們推薦兩種不同的方式來解決此問題,利用特殊作用域或是類型前綴。第一種方式涉及到在 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 的五個技巧