×

Redux进阶系列2:如何合理地设计State

96
苍山沭河
2017.08.26 20:44* 字数 2189

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中的每一部分状态看做数据库中的一张表,状态中的每一个字段对应表的一个字段。设计一个数据库,应该遵循以下三个原则:

  1. 数据按照领域(Domain)分类,存储在不同的表中,不同的表中存储的列数据不能重复。
  2. 表中每一列的数据都依赖于这张表的主键。
  3. 表中除了主键以外的其他列,互相之间不能有直接依赖关系。

这三个原则,可以翻译出设计State时的原则:

  1. 把整个应用的状态按照领域(Domain)分成若干子State,子State之间不能保存重复的数据。
  2. State以键值对的结构存储数据,以记录的key/ID作为记录的索引,记录中的其他字段都依赖于索引。
  3. 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语句。

全栈之路
Web note ad 1