如何設計redux state結構
為什麼使用redux
使用react構建大型應用,勢必會面臨狀態管理的問題,redux是常用的一種狀態管理庫,我們會因為各種原因而需要使用它。
- 不同的元件可能會使用相同的資料,使用redux能更好的複用資料和保持資料的同步
- react中子元件訪問父元件的資料只能通過props層層傳遞,使用redux可以輕鬆的訪問到想要的資料
- 全域性的state可以很容易的進行資料持久化,方便下次啟動app時獲得初始state
- dev tools提供狀態快照回溯的功能,方便問題的排查
但並不是所有的state都要交給redux管理,當某個狀態資料只被一個元件依賴或影響,且在切換路由再次返回到當前頁面不需要保留操作狀態時,我們是沒有必要使用redux的,用元件內部state足以。例如下拉框的顯示與關閉。
常見的狀態型別
react應用中我們會定義很多state,state最終也都是為頁面展示服務的,根據資料的來源、影響的範圍大致可以將前端state歸為以下三類:
UI state: 決定當前UI如何展示的狀態,比如一個彈窗的開閉,下拉選單是否開啟,往往聚焦於某個元件內部,狀態之間可以相互獨立,也可能多個狀態共同決定一個UI展示,這也是UI state管理的難點。
App state: App級的狀態,例如當前是否有請求正在loading、某個聯絡人被選中、當前的路由資訊等可能被多個元件共同使用到狀態。
如何設計state結構
在使用redux的過程中,我們都會使用modules的方式,將我們的reducers拆分到不同的檔案當中,通常會遵循高內聚、方便使用的原則,按某個功能模組、頁面來劃分。那對於某個reducer檔案,如何設計state結構能更方便我們管理資料呢,下面列出幾種常見的方式:
1.將api返回的資料直接放入state
這種方式大多會出現在列表的展示上,如帖子列表頁,因為後臺介面返回的資料通常與列表的展示結構基本一致,可以直接使用。
2.以頁面UI來設計state結構
如下面的頁面,分為三個section,對應開戶中、即將流失、已提交稽核三種不同的資料型別。
因為頁面是展示性的沒有太多的互動,所以我們完全可以根據頁面UI來設計如下的結構:
tabData: { opening: [{ userId: "6332", mobile: "1858849****", name: "test1", ... }, ...], missing: [], commit: [{ userId: "6333", mobile: "1858849****", name: "test2", ... }, ... ] }
這樣設計比較方便我們將state對映到頁面,拉取更多資料,也只簡單contact進對應的陣列即可。對於簡單頁面,這樣是可行的。
3.State正規化化(normailize)
很多情況下,處理的資料都是巢狀或互相關聯的。例如,一個群列表,由很多群組成,每個群又包含很多個使用者,一個使用者可以加入多個不同的群。這種型別的資料,我們可以方便用如下結構表示:
const Groups = [ { id: 'group1', groupName: '連線電商', groupMembers: [ { id: 'user1', name: '張三', dept: '電商部' }, { id: 'user2', name: '李四', dept: '電商部' }, ] }, { id: 'group2', groupName: '連線資管', groupMembers: [ { id: 'user1', name: '張三', dept: '電商部' }, { id: 'user3', name: '王五', dept: '電商部' }, ] } ]
這種方式,對介面展示很友好,展示群列表,我們只需遍歷Groups陣列,展示某個群成員列表,只需遍歷相應索引的資料Groups[index],展示某個群成員的資料,繼續索引到對應的成員資料GroupsgroupIndex即可。
但是這種方式有一些問題:
- 存在很多重複資料,當某個群成員資訊更新的時候,想要在不同的群之間進行同步比較麻煩。
- 巢狀過深,導致reducer邏輯複雜,修改深層的屬性會導致程式碼臃腫,空指標的問題
- redux中需要遵循 不可變更新模式 ,更新屬性往往需要更新元件樹的祖先,產生新的引用,這會導致跟修改資料無關的元件也要重新render。
為了避免上面的問題,我們可以借鑑資料庫儲存資料的方式,設計出類似的正規化化的state,正規化化的資料遵循下面幾個原則:
- 不同型別的資料,都以“資料表”的形式儲存在state中
- “資料表” 中的每一項條目都以物件的形式儲存,物件以唯一性的ID作為key,條目本身作為value。
- 任何對單個條目的引用都應該根據儲存條目的 ID 來索引完成。
- 資料的順序通過ID陣列表示。
上面的示例正規化化之後如下:
{ groups: { byIds: { group1: { id: 'group1', groupName: '連線電商', groupMembers: ['user1', 'user2'] }, group2: { id: 'group2', groupName: '連線資管', groupMembers: ['user1', 'user3'] } }, allIds: ['group1', 'group2'] }, members: { byIds: { user1: { id: 'user1', name: '張三', dept: '電商部' }, user2: { id: 'user2', name: '李四', dept: '電商部' }, user3: { id: 'user3', name: '王五', dept: '電商部' } }, allIds: [] } }
與原來的資料相比有如下改進:
- 因為資料是扁平的,且只被定義在一個地方,更方便資料更新
- 檢索或者更新給定資料項的邏輯變得簡單與一致。給定一個數據項的 type 和 ID,不必巢狀引用其他物件而是通過幾個簡單的步驟就能查詢到它。
- 每個資料型別都是唯一的,像使用者資訊這樣的更新僅僅需要狀態樹中 “members > byId > user” 這部分的複製。這也就意味著在 UI 中只有資料發生變化的一部分才會發生更新。與之前的不同的是,之前巢狀形式的結構需要更新整個 groupMembers陣列,以及整個 groups陣列。這樣就會讓不必要的元件也再次重新渲染。
通常我們介面返回的資料都是巢狀形式的,要將資料正規化化,我們可以使用 Normalizr 這個庫來輔助。
當然這樣做之前我們最好問自己,我是否需要頻繁的遍歷資料,是否需要快速的訪問某一項資料,是否需要頻繁更新同步資料。
更進一步
對於這些關係資料,我們可以統一放到entities中進行管理,這樣root state,看起來像這樣:
{ simpleDomainData1: {....}, simpleDomainData2: {....} entities : { entityType1 : {byId: {}, allIds}, entityType2 : {....} } ui : { uiSection1 : {....}, uiSection2 : {....} } }
其實上面的entities並不夠純粹,因為其中包含了關聯關係(group裡面包含了groupMembers的資訊),也包含了列表的順序資訊(如每個實體的allIds屬性)。更進一步,我們可以將這些資訊剝離出來,讓我們的entities更加簡單,扁平。
{ entities: { groups: { group1: { id: 'group1', groupName: '連線電商', }, group2: { id: 'group2', groupName: '連線資管', } }, members: { user1: { id: 'user1', name: '張三', dept: '電商部' }, user2: { id: 'user2', name: '李四', dept: '電商部' }, user3: { id: 'user3', name: '王五', dept: '電商部' } } }, groups: { gourpIds: ['group1', 'group2'], groupMembers: { group1: ['user1', 'user2'], group2: ['user2', 'user3'] } } }
這樣我們在更新entity資訊的時候,只需操作對應entity就可以了,新增新的entity時則需要在對應的物件如entities[group]中新增group物件,在groups[groupIds]中新增對應的關聯關係。
enetities.js
const ADD_GROUP = 'entities/addGroup'; const UPDATE_GROUP = 'entities/updateGroup'; const ADD_MEMBER = 'entites/addMember'; const UPDATE_MEMBER = 'entites/updateMember'; export const addGroup = entity => ({ type: ADD_GROUP, payload: {[entity.id]: entity} }) export const updateGroup = entity => ({ type: UPDATE_GROUP, payload: {[entity.id]: entity} }) export const addMember = member => ({ type: ADD_MEMBER, payload: {[member.id]: member} }) export const updateMember = member => ({ type: UPDATE_MEMBER, payload: {[member.id]: member} }) _addGroup(state, action) { return state.set('groups', state.groups.merge(action.payload)); } _addMember(state, action) { return state.set('members', state.members.merge(action.payload)); } _updateGroup(state, action) { return state.set('groups', state.groups.merge(action.payload, {deep: true})); } _updateMember(state, action) { return state.set('members', state.members.merge(action.payload, {deep: true})) } const initialState = Immutable({ groups: {}, members: {} }) export default function entities(state = initialState, action) { let type = action.type; switch (type) { case ADD_GROUP: return _addGroup(state, action); case UPDATE_GROUP: return _updateGroup(state, action); case ADD_MEMBER: return _addMember(state, action); case UPDATE_MEMBER: return _updateMember(state, action); default: return state; } }
可以看到,因為entity的結構大致相同,所以更新起來很多邏輯是差不多的,所以這裡可以進一步提取公用函式,在payload裡面加入要更新的key值。
export const addGroup = entity => ({ type: ADD_GROUP, payload: {data: {[entity.id]: entity}, key: 'groups'} }) export const updateGroup = entity => ({ type: UPDATE_GROUP, payload: {data: {[entity.id]: entity}, key: 'groups'} }) export const addMember = member => ({ type: ADD_MEMBER, payload: {data: {[member.id]: member}, key: 'members'} }) export const updateMember = member => ({ type: UPDATE_MEMBER, payload: {data: {[member.id]: member}, key: 'members'} }) function normalAddReducer(state, action) { let payload = action.payload; if (payload && payload.key) { let {key, data} = payload; return state.set(key, state[key].merge(data)); } return state; } function normalUpdateReducer(state, action) { if (payload && payload.key) { let {key, data} = payload; return state.set(key, state[key].merge(data, {deep: true})); } } export default function entities(state = initialState, action) { let type = action.type; switch (type) { case ADD_GROUP: case ADD_MEMBER: return normalAddReducer(state, action); case UPDATE_GROUP: case UPDATE_MEMBER: return normalUpdateReducer(state, action); default: return state; } }
將loading狀態抽離到根reducer中,統一管理
在請求介面時,通常會dispatch loading狀態,通常我們會在某個介面請求的reducer裡面來處理響應的loading狀態,這會使loading邏輯到處都是。其實我們可以將loading狀態作為根reducer的一部分,單獨管理,這樣就可以複用響應的邏輯。
const SET_LOADING = 'SET_LOADING'; export const LOADINGMAP = { groupsLoading: 'groupsLoading', memberLoading: 'memberLoading' } const initialLoadingState = Immutable({ [LOADINGMAP.groupsLoading]: false, [LOADINGMAP.memberLoading]: false, }); const loadingReducer = (state = initialLoadingState, action) => { const { type, payload } = action; if (type === SET_LOADING) { return state.set(key, payload.loading); } else { return state; } } const setLoading = (scope, loading) => { return { type: SET_LOADING, payload: { key: scope, loading, }, }; } // 使用的時候 store.dispatch(setLoading(LOADINGMAP.groupsLoading, true));
這樣當需要新增新的loading狀態的時候,只需要在LOADINGMAP和initialLoadingState新增相應的loading type即可。
也可以參考 dva 的實現方式,它也是將loading儲存在根reducer,並且是根據model的namespace作為區分,它方便的地方在於將更新loading狀態的邏輯自動化,使用者不需要手動更新loading,只需要在用到時候使用state即可,更高階。
其他
對於web端應用,我們無法控制使用者的操作路徑,很可能使用者在直接訪問某個頁面的時候,我們store中並沒有準備好資料,這可能會導致一些問題,所以有人建議以page為單位劃分store,捨棄掉部分多頁面共享state的好處,具體可以參考這篇文章,其中提到在檢視之間共享state要謹慎,其實這也反映出我們在思考是否要共享某個state時,思考如下幾個問題:
- 有多少頁面會使用到該資料
- 每個頁面是否需要單獨的資料副本
- 改動資料的頻率怎麼樣