数组对象状态 vs 以id为键的对象状态

110
设计状态形状的章节中,文档建议将您的状态存储在以ID为键的对象中:

将每个实体保存在以ID为键的对象中,并使用ID从其他实体或列表引用它。

他们继续说道:

把应用程序的状态想象成一个数据库。

我正在为一系列过滤器的状态形状工作,其中一些过滤器将是打开的(它们显示在弹出窗口中),或者具有选定的选项。当我读到“把应用程序的状态想象成一个数据库”时,我考虑将它们视为从API返回的JSON响应(本身由数据库支持)。

所以我想到了这样:

[{
    id: '1',
    name: 'View',
    open: false,
    options: ['10', '11', '12', '13'],
    selectedOption: ['10'],
    parent: null,
  },
  {
    id: '10',
    name: 'Time & Fees',
    open: false,
    options: ['20', '21', '22', '23', '24'],
    selectedOption: null,
    parent: '1',
  }]

然而,文档建议使用更像以下格式

{
   1: { 
    name: 'View',
    open: false,
    options: ['10', '11', '12', '13'],
    selectedOption: ['10'],
    parent: null,
  },
  10: {
    name: 'Time & Fees',
    open: false,
    options: ['20', '21', '22', '23', '24'],
    selectedOption: null,
    parent: '1',
  }
}

在理论上,只要数据可序列化(在“状态”标题下),那么它就不应该有影响。
因此,我很高兴地采用了对象数组的方法,直到我写reducer时。
使用以id为键的对象方法(并大量使用展开语法),reducer的OPEN_FILTER部分变成:
switch (action.type) {
  case OPEN_FILTER: {
    return { ...state, { ...state[action.id], open: true } }
  }

与对象数组的方法相比,这种方法更冗长(且依赖于辅助函数)。

switch (action.type) {
   case OPEN_FILTER: {
      // relies on getFilterById helper function
      const filter = getFilterById(state, action.id);
      const index = state.indexOf(filter);
      return state
        .slice(0, index)
        .concat([{ ...filter, open: true }])
        .concat(state.slice(index + 1));
    }
    ...

我的问题有三个:

1)reducer的简单性是采用基于ID的对象键方法的动机吗?这种状态形式还有其他优点吗?

2)基于ID的对象键方法似乎使处理标准JSON的API更加困难。(这就是我首先选择对象数组的原因。)那么,如果你采用这种方法,你只是使用一个函数在JSON格式和状态形式格式之间进行转换吗?那看起来很笨重。(尽管如果你支持这种方法,你的理由部分是认为这比上面的对象数组reducer不那么笨重?)

3)我知道Dan Abramov设计redux的理论目标是使其与状态数据结构无关(如"按照惯例,顶层状态是一个对象或其他像Map这样的键值集合,但从技术上讲它可以是任何类型",强调是我的)。但鉴于上述情况,将其保持为以ID为键的对象只是“推荐”,还是我会遇到其他未预见的痛点,使我应该放弃这个计划,尝试坚持以ID为键的对象?


3
这是一个有趣的问题,我也曾经遇到过。仅提供一些见解,尽管我倾向于在 Redux 中使用归一化而不是数组(仅仅因为查找更容易),但我发现如果采用归一化方法,排序就成了一个问题,因为你无法得到与数组相同的结构,所以必须自己进行排序。 - Robert Saunders
我发现“按对象ID键入”的方法存在问题,尽管这种情况并不经常发生,但在编写任何UI应用程序时都必须考虑到这种情况。那么,如果我想使用拖放元素更改实体的顺序,该怎么办?通常,“按对象ID键入”的方法在这里失败,我肯定会采用对象数组方法来避免这些慷慨的问题。可能还有其他问题,但想在这里分享这个问题。 - Kunal Navhate
如何对由对象组成的对象进行排序?这似乎是不可能的。 - David Vielhuber
@DavidVielhuber 你是说除了使用像 lodash 的 sort_by 这样的东西之外?const sorted = _.sortBy(collection, 'attribute'); - nickcoxdotme
是的。目前我们在Vue的计算属性中将这些对象转换为数组。 - David Vielhuber
3个回答

49

Q1: reducer 的简单性是由于不需要查找数组中的正确条目。不需要在数组中查找是优势。选择器和其他数据访问器可能会并且经常通过 id 访问这些项。每次访问都要搜索数组会导致性能问题。当数组变得更大时,性能问题会急剧恶化。另外,随着您的应用程序变得更加复杂,在更多地方显示和过滤数据,问题也会恶化。组合可能是有害的。通过按 id 访问项,访问时间从 O(n) 改变为 O(1),这对于大的 n(这里指数组项)来说差别很大。

Q2: 您可以使用normalizr 来帮助您进行从 API 到存储的转换。截至 normalizr V3.1.0,您可以使用 denormalize 反向操作。话虽如此,应用程序通常更多地消费数据而不是生成数据,因此将数据转换为存储通常更频繁。

Q3: 使用数组会遇到的问题不是存储约定和/或不兼容性问题,而是性能问题。


规范化器是在我们更改后端中的定义时肯定会带来痛苦的东西。因此,每次都必须保持最新状态。 - Kunal Navhate
你可以使用二分查找来找到该项,其时间复杂度为O(log n),而非O(n)。 - Volper
只有在按id排序的列表中,即使如此,它仍然比属性访问慢。 - DDS

13
将应用程序的状态视为一个数据库是关键思想。具有唯一ID的对象允许您始终在引用对象时使用该ID,因此您必须在操作和减速器之间传递最少量的数据。这比使用array.find(...)更有效率。如果您使用数组方法,则必须传递整个对象,这可能很快变得混乱不堪,您可能会在不同的减速器、操作或甚至容器中重新创建对象 (您不希望那样)。即使其相关的reducer只包含ID,视图也总是能够获取完整的对象,因为在映射状态时,您将在某处获取集合(视图获取整个状态以将其映射到属性)。因为我所说的所有内容,操作最终具有最小数量的参数,而减速器具有最小数量的信息,请尝试一下,尝试两种方法,您会发现使用ID时架构更加可扩展和清晰,如果集合确实具有ID。
与API的连接不应影响您的存储和reducer的体系结构,这就是为什么要有操作,以保持关注点的分离。只需将转换逻辑放入可重用模块中,并从使用API的操作中导入该模块,就可以了。
对于具有ID的结构,我使用了数组,以下是我遇到的意外后果:
- 不断在代码中重新创建对象 - 将不必要的信息传递给reducer和操作 - 由此导致了糟糕的、不干净的和不可扩展的代码。
我最终改变了我的数据结构并重写了很多代码。你已经被警告了,请不要让自己陷入麻烦。
另外:
大多数具有ID的集合都是用ID作为整个对象的引用,您应该利用这一点。API调用将获取ID,然后获取其余参数,因此会发生操作和减速器。

2
我遇到了一个问题,我们的应用程序有很多数据(1000到10,000个)存储在redux store中的对象中。在视图中,它们都使用排序数组来显示时间序列数据。这意味着每次重新渲染时,它必须将整个对象转换为数组并进行排序。我的任务是改善应用程序的性能。在这种情况下,将数据存储在排序数组中并使用二进制搜索来执行删除和更新是否更加合理? - William Chou
我最终不得不制作一些其他的哈希图,从这些数据中派生出来,以最小化更新时的计算时间。这使得更新所有不同视图需要它们自己的更新逻辑。在此之前,所有组件都将对象从存储中取出,并重新构建其所需的数据结构以创建其视图。我能想到确保UI最小卡顿的方法是使用Web Worker将对象转换为数组。这样做的权衡是检索和更新逻辑更简单,因为所有组件只依赖一种类型的数据进行读写。 - William Chou

9

1)简化reducer是采用按ID作为键的对象方法的动机吗?这种状态形状还有其他优势吗?

将实体保存在以ID为键的对象中(也称为规范化),主要原因是使用深度嵌套对象非常麻烦(在更复杂的应用程序中,这通常是从REST API获得的)。对于您的组件和reducers都是如此。

使用您当前的示例很难说明规范化状态的好处(因为您没有深度嵌套结构)。但是,假设选项(在您的示例中)还具有标题,并且由系统中的用户创建。那么响应将看起来像这样:

[{
  id: 1,
  name: 'View',
  open: false,
  options: [
    {
      id: 10, 
      title: 'Option 10',
      created_by: { 
        id: 1, 
        username: 'thierry' 
      }
    },
    {
      id: 11, 
      title: 'Option 11',
      created_by: { 
        id: 2, 
        username: 'dennis'
      }
    },
    ...
  ],
  selectedOption: ['10'],
  parent: null,
},
...
]

现在假设您想创建一个组件,显示创建选项的所有用户列表。为此,您首先需要请求所有项目,然后迭代每个选项,最后获取created_by.username。
更好的解决方案是将响应规范化为:
results: [1],
entities: {
  filterItems: {
    1: {
      id: 1,
      name: 'View',
      open: false,
      options: [10, 11],
      selectedOption: [10],
      parent: null
    }
  },
  options: {
    10: {
      id: 10,
      title: 'Option 10',
      created_by: 1
    },
    11: {
      id: 11,
      title: 'Option 11',
      created_by: 2
    }
  },
  optionCreators: {
    1: {
      id: 1,
      username: 'thierry',
    },
    2: {
      id: 2,
      username: 'dennis'
    }
  }
}

使用这种结构,列出所有创建选项的用户会更加容易和高效(因为我们已将它们隔离在entities.optionCreators中,所以只需遍历该列表即可)。
此外,显示那些为ID为1的过滤器项创建选项的用户名也非常简单。
entities
  .filterItems[1].options
  .map(id => entities.options[id])
  .map(option => entities.optionCreators[option.created_by].username)

2)使用以对象ID为键的方法似乎使API难以处理标准JSON输入/输出。(这就是我一开始选择对象数组的原因。)所以,如果你采用这种方法,你只需要使用一个函数在JSON格式和状态格式之间进行转换吗?那似乎很笨拙。(尽管如果你提倡这种方法,你的理由部分是因为这比上面的对象数组reducer更不笨拙吗?)

可以使用normalizr等工具来规范化JSON响应。

3)我知道Dan Abramov设计redux的理论目标是状态数据结构无关的(正如“按照惯例,顶层状态是一个对象或其他类似Map的键值集合,但从技术上讲,它可以是任何类型”,强调我的)。但考虑到上述情况,将其保持为由ID键入的对象只是“推荐”,还是我将遇到其他未预见的痛点,使我应该放弃这个计划并尝试坚持由ID键入的对象?

对于具有大量深度嵌套API响应的复杂应用程序,这可能是一种建议。但在您特定的示例中,这并不是很重要。


2
如果资源是单独获取的,那么map会返回未定义值,就像这里一样,这使得filter变得非常复杂。有解决方案吗? - Saravanabalagi Ramachandran
1
@tobiasandersen,你认为服务器返回适合于React/Redux的规范化数据是否可以,以避免客户端通过normalizr等库进行转换?换句话说,让服务器规范化数据而不是客户端。 - Matthew

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接