设计state

Redux 应用执行过程中的任何一个时刻本质上都是该时刻的应用 state 的反映。可以说,state 驱动了 Redux 逻辑的运转。对于 Redux 项目来说,设计良好的 state 结构至关重要。下面先来看看设计 state 时容易犯的两个错误。

错误1:以API作为设计state的依据

以 API 作为设计 state 的依据往往是一个 API 对应全局 state 中的一部分结构,且这部分结构同 API 返回的数据结构保持一致(或接近一致)。例如,在 BBS 项目中,获取帖子列表 API 返回的数据结构如下:

image 2024 04 24 17 21 03 484

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

image 2024 04 24 17 21 55 445

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

image 2024 04 24 17 23 46 664

这个 state 中,posts 和 currentPost 存在很多重复的信息,而且 posts、currentComments 是数组类型的结构,不便于查找,每次查找某条记录时,都需要遍历整个数组。这些问题本质上是因为 API 是基于服务端逻辑设计的,而不是基于应用的状态设计的。比如,虽然获取帖子列表时已经获取到帖子的标题、作者等基本信息,但对于获取帖子详情的 API 来说,根据 API 的设计原则,这个 API 依然应该包含这些基本信息,而不能只是返回帖子的正文内容。再比如,posts、currentComments 之所以返回数组结构,是考虑到数据的有序性、分页等因素。

错误2:以页面UI为设计state的依据

既然不能依据 API 设计 state,很多人又会走另一条路,基于页面 UI 设计 state。页面 UI 需要什么样的数据和数据结构,state 就设计成什么样。以 todos 应用为例,页面会有三种状态:显示所有的事项、显然所有的已办事项和显示所有的待办事项。以页面 UI 为设计 state 的依据,state 将是这样的:

image 2024 04 24 17 25 24 011

这个 state 对于展示 UI 的组件来说使用起来非常方便,当前应用处于哪种状态,就用对应状态的数组类型的数据渲染 UI,不用做任何中间数据转换。但这种 state 存在的问题也很容易被发现,一是这种 state 依然存在数据重复的问题;二是当新增或修改一条记录时,需要修改不止一个地方。例如,当新增一条记录时,all 和 uncompleted 这两个数组都要添加这条新增记录。这样设计的 state 既会造成存储的浪费,又会存在数据不一致的风险。

这两种设计 state 的方式实际上是两种极端,在实际项目中,完全按照这两种方式设计 state 的开发者并不多,但绝大部分人都会受到这两种设计方式的影响。

合理设计state

看过了 state 的错误设计方式,下面来看一下应该如何合理地设计 state。设计 state 时,最重要的是记住一句话:像设计数据库一样设计 state。把 state 看作一个数据库,state 中的每一部分状态看作数据库中的一张表,状态中的每一个字段对应表的一个字段。设计一个数据库应该遵循以下三个原则:

  1. 数据按照领域(Domain)分类存储在不同的表中,不同的表中存储的列数据不能重复。

  2. 表中每一列的数据都依赖于这张表的主键。

  3. 表中除了主键以外,其他列互相之间不能有直接依赖关系。

根据这三个原则可以翻译出设计 state 时的原则:

  1. 把整个应用的状态按照领域分成若干子状态,子状态之间不能保存重复的数据。

  2. state 以键值对的结构存储数据,以记录的 key 或 ID 作为记录的索引,记录中的其他字段都依赖于索引。

  3. state 中不能保存可以通过 state 中的已有字段计算而来的数据,即 state 中的字段不互相依赖。

按照这三个原则重新设计 BBS 的 state。按领域划分,state 可以拆分为三个子 state:posts、comments 和 users,posts 中的记录以帖子的 id 为 key 值,结构如下:

image 2024 04 24 17 28 16 450

这个结构相比前面的按 API 划分 state 结构变化之处主要有两点:第一点是,posts 中的数据类型由数组类型改为以帖子 id 为 key 的 JSON 对象类型;第二点是,author 字段不再存储完整的作者信息,只存储作者的 id。第一个变化可以方便在使用 posts 时快速根据 id 获取对应帖子数据;第二个变化把原本嵌套的数据结构扁平化,避免了查询和修改嵌套数据时需要向下访问多个层级的烦琐,同时扁平化的数据结构更利于扩展。

但这个 state 还有不满足应用需求的地方:键值对的存储方式无法保证数据的有序性,但对于帖子列表,有序性显然是需要的。解决这个问题可以通过定义一个数组类型的属性 allIds 存储帖子的 id,同时将之前的键值对类型的数据存储在 byId 属性下:

image 2024 04 24 17 29 31 857

这样一来,allIds 负责维护数据的有序性,byId 负责根据 id 快速查询对应数据。这种设计 state 的方式是很常用的一种方式,请读者注意。

posts 不再保存完整的作者信息,那么作者信息的查询就有赖于领域 users 对应的子 state。应用中不关注作者的顺序,因此我们只需要使用以作者 id 为 key 的键值对存储数据即可:

image 2024 04 24 17 30 17 830

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

test

byPost 保存帖子 id 到评论 id 的映射关系,byId 保存评论 id 到评论数据的映射关系。

由 posts、comments 和 users 三个领域组成的 state 结构如下:

image 2024 04 24 17 35 48 287

到目前为止,我们的 state 都是根据领域数据进行设计的,但实际上,应用的 state 不仅包含领域数据,还包含应用状态数据和 UI 状态数据。应用状态数据指反映应用行为的数据,例如,当前登录的状态、是否有 API 请求在进行等。UI 状态数据是代表 UI 当前如何显示的数据,例如对话框当前是否处于打开状态等。

有些开发者习惯把 UI 状态数据仍然保存在组件的 state 中,由组件自己管理,而不是交给 Redux 管理。这也是一种可选的做法,但将 UI 状态数据也交给 Redux 统一管理有利于应用 UI 状态的追溯。

在 BBS 项目中,我们将应用状态分为两部分,一部分专门记录登录认证相关的状态,保存到子 state auth 中,其余应用状态保存到子 state app 中。这两部分 state 结构如下:

image 2024 04 24 17 38 38 131

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

image 2024 04 24 17 39 24 386

这里涉及的 UI 状态数据比较少,只保存了新增帖子对话框和编辑帖子对话框的状态。

至此,由领域数据、应用状态数据、UI 状态数据组成的完整 state 结构如下:

image 2024 04 24 17 40 01 003