乐趣区

如何设计redux-state结构

为什么使用 redux

使用 react 构建大型应用,势必会面临状态管理的问题,redux 是常用的一种状态管理库,我们会因为各种原因而需要使用它。

  1. 不同的组件可能会使用相同的数据,使用 redux 能更好的复用数据和保持数据的同步
  2. react 中子组件访问父组件的数据只能通过 props 层层传递,使用 redux 可以轻松的访问到想要的数据
  3. 全局的 state 可以很容易的进行数据持久化,方便下次启动 app 时获得初始 state
  4. 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 即可。
但是这种方式有一些问题:

  1. 存在很多重复数据,当某个群成员信息更新的时候,想要在不同的群之间进行同步比较麻烦。
  2. 嵌套过深,导致 reducer 逻辑复杂,修改深层的属性会导致代码臃肿,空指针的问题
  3. 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: []}
}

与原来的数据相比有如下改进:

  1. 因为数据是扁平的,且只被定义在一个地方,更方便数据更新
  2. 检索或者更新给定数据项的逻辑变得简单与一致。给定一个数据项的 type 和 ID,不必嵌套引用其他对象而是通过几个简单的步骤就能查找到它。
  3. 每个数据类型都是唯一的,像用户信息这样的更新仅仅需要状态树中“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 时,思考如下几个问题:

  1. 有多少页面会使用到该数据
  2. 每个页面是否需要单独的数据副本
  3. 改动数据的频率怎么样

参考文章

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/…

退出移动版