为什么使用 redux
使用 react 构建大型应用,势必会面临状态管理的问题,redux 是常用的一种状态管理库,我们会因为各种原因而需要使用它。
- 不同的组件可能会使用相同的数据,使用 redux 能更好的复用数据和保持数据的同步
- react 中子组件访问父组件的数据只能通过 props 层层传递,使用 redux 可以轻松的访问到想要的数据
- 全局的 state 可以很容易的进行数据持久化,方便下次启动 app 时获得初始 state
- dev tools 提供状态快照回溯的功能,方便问题的排查
但并不是所有的 state 都要交给 redux 管理,当某个状态数据只被一个组件依赖或影响,且在切换路由再次返回到当前页面不需要保留操作状态时,我们是没有必要使用 redux 的,用组件内部 state 足以。例如下拉框的显示与关闭。
常见的状态类型
react 应用中我们会定义很多 state,state 最终也都是为页面展示服务的,根据数据的来源、影响的范围大致可以将前端 state 归为以下三类:
Domain data: 一般可以理解为从服务器端获取的数据,比如帖子列表数据、评论数据等。它们可能被应用的多个地方用到,前端需要关注的是与后端的数据同步、提交等等。
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 时,思考如下几个问题:
- 有多少页面会使用到该数据
- 每个页面是否需要单独的数据副本
- 改动数据的频率怎么样
参考文章
https://www.zhihu.com/questio…
https://segmentfault.com/a/11…
https://hackernoon.com/shape-…
https://medium.com/@dan_abram…
https://medium.com/@fastphras…
https://juejin.im/post/59a16e…
http://cn.redux.js.org/docs/r…
https://redux.js.org/recipes/…