Redux是一个非常流行的状态管理解决方案,Redux应用执行过程中的任何一个时刻,都是一个状态的反映。可以说,State 驱动了Redux逻辑的运转。设计一个好的State并非易事,本文先从设计State时最容易犯的两个错误开始介绍,然后引出如何合理地设计State。
错误1:以API为设计State的依据
以API为设计State的依据,往往是一个API对应一个子State,State的结构同API返回的数据结构保持一致(或接近一致)。例如,一个博客应用,/posts
接口返回博客列表,返回的数据结构如下:
[
{
"id": 1,
"title": "Blog Title",
"create_time": "2017-01-10T23:07:43.248Z",
"author": {
"id": 81,
"name": "Mr Shelby"
}
}
...
]
我们还需要查看一篇博客的详情,假设通过接口/posts/{id}
获取博客详情,通过接口/posts/{id}/comments
获取博客的评论,返回的数据结构如下:
{
"id": 1,
"title": "Blog Title",
"create_time": "2017-01-10T23:07:43.248Z",
"author": {
"id": 81,
"name": "Mr Shelby"
},
"content": "Some really short blog content. "
}
[
{
"id": 41,
"author": "Jack",
"create_time": "2017-01-11T23:07:43.248Z",
"content": "Good article!"
}
...
]
上面三个接口的数据分别作为3个子State,构成应用全局的State:
{
"posts": [
{
"id": 1,
"title": "Blog Title",
"create_time": "2017-01-10T23:07:43.248Z",
"author": {
"id": 81,
"name": "Mr Shelby"
}
},
...
],
"currentPost": {
"id": 1,
"title": "Blog Title",
"create_time": "2017-01-10T23:07:43.248Z",
"author": {
"id": 81,
"name": "Mr Shelby"
},
"content": "Some really short blog content. "
},
"currentComments": [
{
"id": 1,
"author": "Jack",
"create_time": "2017-01-11T23:07:43.248Z",
"content": "Good article!"
},
...
]
}
这个State中,posts和currentPost存在很多重复的信息,而且posts、currentComments是数组类型的结构,不便于查找,每次查找某条记录时,都需要遍历整个数组。这些问题本质上是因为API是基于服务端逻辑设计的,而不是基于应用的状态设计的。比如,虽然获取博客列表时,已经获取了每篇博客的标题、作者等基本信息,但对于获取博客详情的API来说,根据API的设计原则,这个API依然应该包含博客的这些基本信息,而不能只是返回博客的内容。再比如,posts、currentComments之所以返回数组结构,是考虑到数据的顺序、分页等因素。
错误2:以页面UI为设计State的依据
既然不能依据API设计State,很多人又会走到另外一个反面,基于页面UI设计State。页面UI需要什么样的数据和数据格式,State就设计成什么样。我们以todo应用为例,页面会有三种状态:显示所有的事项,显然所有的已办事项和显示所有的待办事项。以页面UI为设计State的依据,那么State将是这样的:
{
"all": [
{
"id": 1,
"text": "todo 1",
"completed": false
},
{
"id": 2,
"text": "todo 2",
"completed": true
}
],
"uncompleted": [
{
"id": 1,
"text": "todo 1",
"completed": false
}
],
"completed": [
{
"id": 2,
"text": "todo 2",
"completed": false
}
]
}
这个State对于展示UI的组件来说,使用起来非常方便,当前应用处于哪种状态,就用对应状态的数组类型的数据渲染UI,不用做任何的中间数据转换。但这种State存在的问题也很容易被发现,一是这种State依然存在数据重复的问题;二是当新增或修改一条记录时,需要修改不止一个地方。例如,当新增一条记录时,all和uncompleted这两个数组都要添加这条新增记录。这种类型的State,既会造成存储的浪费,又会存在数据不一致的风险。
这两种设计State的方式实际上是两种极端的设计方式,实际项目中,完全按照这两种方式设计State的开发者并不多,但绝大部分人都会受到这两种设计方式的影响。请回忆一下,你是否有过把某个API返回的数据原封不动的作为State的一部分?又是否有过,为了组件渲染方便,专门为某个组件的UI定义一个State?
合理设计State
下面我们来看一下应该如何合理地设计State。最重要最核心的原则是像设计数据库一样设计State。把State看做一个数据库,State中的每一部分状态看做数据库中的一张表,状态中的每一个字段对应表的一个字段。设计一个数据库,应该遵循以下三个原则:
- 数据按照领域(Domain)分类,存储在不同的表中,不同的表中存储的列数据不能重复。
- 表中每一列的数据都依赖于这张表的主键。
- 表中除了主键以外的其他列,互相之间不能有直接依赖关系。
这三个原则,可以翻译出设计State时的原则:
- 把整个应用的状态按照领域(Domain)分成若干子State,子State之间不能保存重复的数据。
- State以键值对的结构存储数据,以记录的key/ID作为记录的索引,记录中的其他字段都依赖于索引。
- State中不能保存可以通过已有数据计算而来的数据,即State中的字段不互相依赖。
按照这三个原则,我们重新设计博客应用的State。按领域划分,State可以拆分为三个子State: posts、comments、authors,posts中的记录以博客的id为key值,包含title、create_time、author、comments,同样的方式可以设计出comments、authors的结构,最终State的结构如下:
{
"posts": {
"1": {
"id": 1,
"title": "Blog Title",
"content": "Some really short blog content.",
"created_at": "2016-01-11T23:07:43.248Z",
"author": 81,
"comments": [
352
]
},
...
},
"comments": {
"352": {
"id": 352,
"content": "Good article!",
"author": 41
},
...
},
"authors": {
"41": {
"id": 41,
"name": "Jack"
},
"81": {
"id": 81,
"name": "Mr Shelby"
},
...
}
}
现在这个State看起来是不是很像有三张表的数据库呢?但这个State还有不满足应用需求的地方:键值对的存储方式无法保证博客列表数据的顺序,但对于博客列表,有序性显然是需要的。解决这个问题,我们可以通过定义另外一个状态postIds,以数组格式存储博客的id:
{
"posts": {
"1": {
"id": 1,
"title": "Blog Title",
"content": "Some really short blog content.",
"created_at": "2016-01-11T23:07:43.248Z",
"author": 81,
"comments": [
352
]
},
...
},
"postIds": [1, ...],
"comments": {
"352": {
"id": 352,
"content": "Good article!",
"author": 41
},
...
},
"authors": {
"41": {
"id": 41,
"name": "Jack"
},
"81": {
"id": 81,
"name": "Mr Shelby"
},
...
}
}
这样,当显示博客列表时,根据postIds获取列表顺序,然后根据博客id从posts中获取博客的信息。这个地方有些同学可能有疑惑,认为posts和postIds都保存了id数据,违反了不同State间不能有重复数据的原则。但其实这并不是重复数据,postIds保存的数据是博客列表的顺序,只不过“顺序”这个数据是通过博客id来体现的。这和一张表的主键同时可以用作另外一张表的外键,是同样的道理。同样需要注意的是,当新增加一条博客时,posts和postId这两个状态都要进行修改。这看似变得麻烦,不如直接使用一个数组类型的状态操作简单,但是当需要修改某一篇博客的数据时,这种结构就有了明显的优势,而且直接使用数组保存状态,会存在对象嵌套层级过深的问题,想象下访问评论的内容,需要通过类似posts[0].comments[0].content
三层结构才能获取到,当业务越复杂,这个问题越突出。扁平化的State,才具有更好的灵活性和扩展性。
截至目前为止,我们的State都是根据后台API返回的领域数据进行设计的,但实际上,应用的State,不仅包含领域数据,还需要包含应用的UI逻辑数据,例如根据当前是否正在与服务器通信,处理页面的加载效果;当应用运行出错时,需要显示错误信息等。这时,State的结构如下:
{
"isFetching": false,
"error": "",
"posts": {
...
},
"postIds": [1, ...],
"comments": {
...
},
"authors": {
...
}
}
随着应用业务逻辑的增加,State的第一层级的节点也会变得越来越多。这时候我们往往会考虑合并关联性较强的节点数据,然后通过拆分reducer的方式,让每一个子reducer处理一个节点的状态逻辑。这个例子中,我们可以把posts、postIds进行合并,同时状态名做了调整,把isFetching、error作为全局的UI逻辑状态合并:
{
"app":{
"isFetching": false,
"error": "",
},
"posts":{
"byId": {
"1": {
...
},
...
},
"allIds": [1, ...],
}
"comments": {
...
},
"authors": {
...
}
}
这样,我们就可以定义appReducer、postsReducer、commentsReducer、authorsReducer四个reducer分别处理4个子状态。至此,State的结构设计完成。
总结一下,设计Redux State的关键在于,像设计数据库一样设计State。把State看作应用在内存中的一个数据库,action、reducer等看作操作这个数据库的SQL语句。
欢迎关注我的公众号:老干部的大前端,领取21本大前端精选书籍!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。