设计state
Redux 应用执行过程中的任何一个时刻本质上都是该时刻的应用 state 的反映。可以说,state 驱动了 Redux 逻辑的运转。对于 Redux 项目来说,设计良好的 state 结构至关重要。下面先来看看设计 state 时容易犯的两个错误。
错误1:以API作为设计state的依据
以 API 作为设计 state 的依据往往是一个 API 对应全局 state 中的一部分结构,且这部分结构同 API 返回的数据结构保持一致(或接近一致)。例如,在 BBS 项目中,获取帖子列表 API 返回的数据结构如下:

当查看帖子详情时,需要调用获取帖子详情 API 和获取帖子评论数据 API,两个接口返回的数据结构分别如下:

上面三个接口的数据分别作为 state 的一部分,组合在一起构成应用全局的 state:

这个 state 中,posts 和 currentPost 存在很多重复的信息,而且 posts、currentComments 是数组类型的结构,不便于查找,每次查找某条记录时,都需要遍历整个数组。这些问题本质上是因为 API 是基于服务端逻辑设计的,而不是基于应用的状态设计的。比如,虽然获取帖子列表时已经获取到帖子的标题、作者等基本信息,但对于获取帖子详情的 API 来说,根据 API 的设计原则,这个 API 依然应该包含这些基本信息,而不能只是返回帖子的正文内容。再比如,posts、currentComments 之所以返回数组结构,是考虑到数据的有序性、分页等因素。
错误2:以页面UI为设计state的依据
既然不能依据 API 设计 state,很多人又会走另一条路,基于页面 UI 设计 state。页面 UI 需要什么样的数据和数据结构,state 就设计成什么样。以 todos 应用为例,页面会有三种状态:显示所有的事项、显然所有的已办事项和显示所有的待办事项。以页面 UI 为设计 state 的依据,state 将是这样的:

这个 state 对于展示 UI 的组件来说使用起来非常方便,当前应用处于哪种状态,就用对应状态的数组类型的数据渲染 UI,不用做任何中间数据转换。但这种 state 存在的问题也很容易被发现,一是这种 state 依然存在数据重复的问题;二是当新增或修改一条记录时,需要修改不止一个地方。例如,当新增一条记录时,all 和 uncompleted 这两个数组都要添加这条新增记录。这样设计的 state 既会造成存储的浪费,又会存在数据不一致的风险。
这两种设计 state 的方式实际上是两种极端,在实际项目中,完全按照这两种方式设计 state 的开发者并不多,但绝大部分人都会受到这两种设计方式的影响。
合理设计state
看过了 state 的错误设计方式,下面来看一下应该如何合理地设计 state。设计 state 时,最重要的是记住一句话:像设计数据库一样设计 state。把 state 看作一个数据库,state 中的每一部分状态看作数据库中的一张表,状态中的每一个字段对应表的一个字段。设计一个数据库应该遵循以下三个原则:
-
数据按照领域(Domain)分类存储在不同的表中,不同的表中存储的列数据不能重复。
-
表中每一列的数据都依赖于这张表的主键。
-
表中除了主键以外,其他列互相之间不能有直接依赖关系。
根据这三个原则可以翻译出设计 state 时的原则:
-
把整个应用的状态按照领域分成若干子状态,子状态之间不能保存重复的数据。
-
state 以键值对的结构存储数据,以记录的 key 或 ID 作为记录的索引,记录中的其他字段都依赖于索引。
-
state 中不能保存可以通过 state 中的已有字段计算而来的数据,即 state 中的字段不互相依赖。
按照这三个原则重新设计 BBS 的 state。按领域划分,state 可以拆分为三个子 state:posts、comments 和 users,posts 中的记录以帖子的 id 为 key 值,结构如下:

这个结构相比前面的按 API 划分 state 结构变化之处主要有两点:第一点是,posts 中的数据类型由数组类型改为以帖子 id 为 key 的 JSON 对象类型;第二点是,author 字段不再存储完整的作者信息,只存储作者的 id。第一个变化可以方便在使用 posts 时快速根据 id 获取对应帖子数据;第二个变化把原本嵌套的数据结构扁平化,避免了查询和修改嵌套数据时需要向下访问多个层级的烦琐,同时扁平化的数据结构更利于扩展。
但这个 state 还有不满足应用需求的地方:键值对的存储方式无法保证数据的有序性,但对于帖子列表,有序性显然是需要的。解决这个问题可以通过定义一个数组类型的属性 allIds 存储帖子的 id,同时将之前的键值对类型的数据存储在 byId 属性下:

这样一来,allIds 负责维护数据的有序性,byId 负责根据 id 快速查询对应数据。这种设计 state 的方式是很常用的一种方式,请读者注意。
posts 不再保存完整的作者信息,那么作者信息的查询就有赖于领域 users 对应的子 state。应用中不关注作者的顺序,因此我们只需要使用以作者 id 为 key 的键值对存储数据即可:

评论数据是通过单独的 API 获取的,但评论数据是从属于某个帖子的,这个关系应该如何在 state 中体现呢?有两种方法:第一种是在 posts 对应的 state 中增加一个 comments 属性,存储该帖子对应的评论数据的 id;第二种是在 comments 对应的 state 中增加一个 byPost 属性,存储以帖子 id 作为 key,以这个帖子下的所有评论 id 作为值的对象。使用第二种方法,当调用 API 请求评论数据时,只需要修改 comments 对应的 state 即可,使用第一种方法还需要修改 posts 对应的 state,因此这里使用第二种方法:

byPost 保存帖子 id 到评论 id 的映射关系,byId 保存评论 id 到评论数据的映射关系。
由 posts、comments 和 users 三个领域组成的 state 结构如下:

到目前为止,我们的 state 都是根据领域数据进行设计的,但实际上,应用的 state 不仅包含领域数据,还包含应用状态数据和 UI 状态数据。应用状态数据指反映应用行为的数据,例如,当前登录的状态、是否有 API 请求在进行等。UI 状态数据是代表 UI 当前如何显示的数据,例如对话框当前是否处于打开状态等。
有些开发者习惯把 UI 状态数据仍然保存在组件的 state 中,由组件自己管理,而不是交给 Redux 管理。这也是一种可选的做法,但将 UI 状态数据也交给 Redux 统一管理有利于应用 UI 状态的追溯。 |
在 BBS 项目中,我们将应用状态分为两部分,一部分专门记录登录认证相关的状态,保存到子 state auth 中,其余应用状态保存到子 state app 中。这两部分 state 结构如下:

app 中保存了当前进行中的 API 请求数量和应用的错误信息,auth 中保存了当前登录的用户 ID 和用户名。当需要管理的应用状态数据增多时,可以进一步将 app 拆分成多个子 state。类似地,我们将 UI 状态数据保存到子 state ui 中:

这里涉及的 UI 状态数据比较少,只保存了新增帖子对话框和编辑帖子对话框的状态。
至此,由领域数据、应用状态数据、UI 状态数据组成的完整 state 结构如下:
