状态为对象数组,对象由 id 键控

在关于 设计状态形状的章节中,文档建议将您的状态保存在一个由 ID 键控的对象中:

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

他们进入了州立大学

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

我正在处理过滤器列表的状态形状,其中一些过滤器将打开(它们显示在一个弹出窗口中) ,或者有选择的选项。当我读到“将应用程序的状态看作一个数据库”时,我认为它们是一个 JSON 响应,因为它将从一个 API (它本身由一个数据库支持)返回。

所以我觉得

[{
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',
}
}

从理论上讲,它应该不会像 数据是可序列化的(在标题“状态”下)那样重要。

因此,我愉快地使用了对象数组方法,直到我开始编写我的约简程序。

使用 id 对象键控的方法(以及扩展语法的广泛使用) ,reduce 的 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)简化器的简单性是使用 id 键控对象方法的动机吗?这种状态形状还有其他优势吗?

还有

2)似乎对象键控的 id 方法使得处理 API 的标准 JSON 输入/输出变得更加困难。(这就是我一开始使用对象数组的原因。)因此,如果采用这种方法,是否仅仅使用函数在 JSON 格式和状态形状格式之间来回转换它?看起来很笨重。(不过,如果您支持这种方法,那么您是否认为这种方法比上面的对象数组简化器更简单呢?)

还有

3)我知道丹 · 阿布拉莫夫(Dan Abramov)设计了理论上的不可知状态-数据-结构的归约(按照惯例,顶级状态是一个对象或类似于 Map 的其他键值集合,但是从技术上讲,它可以是任何类型的 强调我的建议)。但是考虑到上述情况,是仅仅“建议”保持它是一个由 ID 键控的对象,还是使用一系列对象会遇到其他不可预见的痛点,使得我应该放弃那个计划并试图坚持使用 ID 键控的对象?

30344 次浏览

Think of the app’s state as a database.

That's the key idea.

1) Having objects with unique IDs allows you to always use that id when referencing the object, so you have to pass the minimum amount of data between actions and reducers. It is more efficient than using array.find(...). If you use the array approach you have to pass the entire object and that can get messy very soon, you might end up recreating the object on different reducers, actions, or even in the container (you dont want that). Views will always be able to get the full object even if their associated reducer only contains the ID, because when mapping the state you'll get the collection somewhere (the view gets the whole state to map it to the properties). Because of all of what i've said, actions end up having the minimal amount of parameters, and reducers the minimal amount of information, give it a try, try both methods and you'll see the architecture ends up more scalable and clean using IDs if collections do have ID.

2) The connection to the API should not affect the architecture of your storage and reducers, that's why you have actions, to keep the separation of concerns. Just put your conversion logic in and out of the API in a reusable module, import that module in the actions that use the API, and that should be it.

3) I used arrays for structures with IDs, and these are the unforeseen consequences i've suffered:

  • Recreating objects constantly throughout the code
  • Passing unnecessary information to reducers and actions
  • As consequence of that, bad, not clean and not scalable code.

I ended up changing my data structure and rewriting a lot of code. You have been warned, please don't get yourself in trouble.

Also:

4) Most collections with IDs are meant to use the ID as a reference to the whole object, you should take advantage of that. The API calls will get the ID and then the rest of the parameters, so will your actions and reducers.

Q1: The simplicity of the reducer is a result of not having to search through the array to find the right entry. Not having to search through the array is the advantage. Selectors and other data accessors may and often do access these items by id. Having to search through the array for each access becomes a performance issue. When your arrays get larger, the performance issue worsens steeply. Also, as your app becomes more complex, showing and filtering data in more places, the issue worsens as well. The combination can be detrimental. By accessing the items by id, the access time changes from O(n) to O(1), which for large n (here array items) makes a huge difference.

Q2: You can use normalizr to help you with the conversion from API to store. As of normalizr V3.1.0 you can use denormalize to go the other way. That said, Apps are often more consumers than producers of data and as such the conversion to store is usually done more frequently.

Q3: The issues you'll run into by using an array are not so much issues with the storage convention and/or incompatibilities, but more performance issues.

1) Is the simplicity of the reducer the motivation for going with the object-keyed-by-id approach? Are there other advantages to that state shape?

The main reason you want to keep keep entities in objects stored with IDs as keys (also called normalized), is that it's really cumbersome to work with deeply nested objects (which is what you typically get from REST APIs in a more complex app) — both for your components and your reducers.

It's a bit hard to illustrate the benefits of a normalized state with your current example (as you don't have a deeply nested structure). But let's say that the options (in your example) also had a title, and were created by users in your system. That would make the response look something like this instead:

[{
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,
},
...
]

Now let's say you wanted to create a component that shows a list of all users that have created options. To do that, you'd first have to request all the items, then iterate over each of their options, and lastly get the created_by.username.

A better solution would be to normalize the response into:

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'
}
}
}

With this structure, it's much easier, and more efficient, to list all users that have created options (we have them isolated in entities.optionCreators, so we just have to loop through that list).

It's also quite simple to show e.g. the usernames of those that have created options for the filter item with ID 1:

entities
.filterItems[1].options
.map(id => entities.options[id])
.map(option => entities.optionCreators[option.created_by].username)

2) It seems like the object-keyed-by-id approach makes it harder to deal with standard JSON in/out for an API. (That's why I went with the array of objects in the first place.) So if you go with that approach, do you just use a function to transform it back and forth between JSON format and state shape format? That seems clunky. (Though if you advocate that approach, is part of your reasoning that that's less clunky than the array-of-objects reducer above?)

A JSON-response can be normalized using e.g. normalizr.

3) I know Dan Abramov designed redux to theoretically be state-data-structure agnostic (as suggested by "By convention, the top-level state is an object or some other key-value collection like a Map, but technically it can be any type," emphasis mine). But given the above, is it just "recommended" to keep it an object keyed by ID, or are there other unforeseen pain points I'm going to run into by using an array of objects that make it such that I should just abort that plan and try to stick with an object keyed by ID?

It's probably a recommendation for more complex apps with lots of deeply nested API responses. In your particular example though, it doesn't really matter that much.