如何处理 Redux 中的关系数据?
Posted
技术标签:
【中文标题】如何处理 Redux 中的关系数据?【英文标题】:How to deal with relational data in Redux? 【发布时间】:2018-01-04 12:23:10 【问题描述】:我正在创建的应用程序有很多实体和关系(数据库是关系型的)。大致了解一下,有 25 多个实体,它们之间具有任何类型的关系(一对多、多对多)。
该应用基于 React + Redux。为了从应用商店获取数据,我们使用Reselect 库。
我面临的问题是当我尝试从商店中获取实体及其关系时。
为了更好地解释问题,我创建了一个简单的演示应用程序,它具有类似的架构。我将重点介绍最重要的代码库。最后,我将包含一个 sn-p(小提琴)以便使用它。
演示应用
业务逻辑
我们有书籍和作者。一本书有一个作者。一位作者有很多书。尽可能简单。
const authors = [
id: 1,
name: 'Jordan Enev',
books: [1]
];
const books = [
id: 1,
name: 'Book 1',
category: 'Programming',
authorId: 1
];
Redux 商店
Store 以扁平结构组织,符合 Redux 最佳实践 - Normalizing State Shape。
这是 Books 和 Authors Stores 的初始状态:
const initialState =
// Keep entities, by id:
// 1: name: ''
byIds: ,
// Keep entities ids
allIds:[]
;
组件
组件被组织为容器和演示文稿。
<App />
组件充当容器(获取所有需要的数据):
const mapStateToProps = state => (
books: getBooksSelector(state),
authors: getAuthorsSelector(state),
healthAuthors: getHealthAuthorsSelector(state),
healthAuthorsWithBooks: getHealthAuthorsWithBooksSelector(state)
);
const mapDispatchToProps =
addBooks, addAuthors
const App = connect(mapStateToProps, mapDispatchToProps)(View);
<View />
组件仅用于演示。它将虚拟数据推送到 Store 并将所有 Presentation 组件呈现为 <Author />, <Book />
。
选择器
对于简单的选择器,它看起来很简单:
/**
* Get Books Store entity
*/
const getBooks = (books) => books;
/**
* Get all Books
*/
const getBooksSelector = createSelector(getBooks,
(books => books.allIds.map(id => books.byIds[id]) ));
/**
* Get Authors Store entity
*/
const getAuthors = (authors) => authors;
/**
* Get all Authors
*/
const getAuthorsSelector = createSelector(getAuthors,
(authors => authors.allIds.map(id => authors.byIds[id]) ));
当你有一个选择器来计算/查询关系数据时,它会变得很乱。 演示应用包括以下示例:
-
获取所有在特定类别中至少拥有一本书的作者。
获得相同的作者,但连同他们的书。
这里是讨厌的选择器:
/**
* Get array of Authors ids,
* which have books in 'Health' category
*/
const getHealthAuthorsIdsSelector = createSelector([getAuthors, getBooks],
(authors, books) => (
authors.allIds.filter(id =>
const author = authors.byIds[id];
const filteredBooks = author.books.filter(id => (
books.byIds[id].category === 'Health'
));
return filteredBooks.length;
)
));
/**
* Get array of Authors,
* which have books in 'Health' category
*/
const getHealthAuthorsSelector = createSelector([getHealthAuthorsIdsSelector, getAuthors],
(filteredIds, authors) => (
filteredIds.map(id => authors.byIds[id])
));
/**
* Get array of Authors, together with their Books,
* which have books in 'Health' category
*/
const getHealthAuthorsWithBooksSelector = createSelector([getHealthAuthorsIdsSelector, getAuthors, getBooks],
(filteredIds, authors, books) => (
filteredIds.map(id => (
...authors.byIds[id],
books: authors.byIds[id].books.map(id => books.byIds[id])
))
));
总结
-
如您所见,在选择器中计算/查询关系数据变得过于复杂。
-
正在加载子关系(作者->书籍)。
按子实体过滤 (
getHealthAuthorsWithBooksSelector()
)。
getHealthAuthorsWithBooksSelector()
并想象一下作者是否有更多的关系。
那么在 Redux 中如何处理关系呢?
这看起来像是一个常见的用例,但令人惊讶的是没有任何好的实践。
*我检查了redux-orm 库,它看起来很有希望,但它的 API 仍然不稳定,我不确定它是否已准备好生产。
const Component = React
const combineReducers, createStore = Redux
const connect, Provider = ReactRedux
const createSelector = Reselect
/**
* Initial state for Books and Authors stores
*/
const initialState =
byIds: ,
allIds:[]
/**
* Book Action creator and Reducer
*/
const addBooks = payload => (
type: 'ADD_BOOKS',
payload
)
const booksReducer = (state = initialState, action) =>
switch (action.type)
case 'ADD_BOOKS':
let byIds =
let allIds = []
action.payload.map(entity =>
byIds[entity.id] = entity
allIds.push(entity.id)
)
return byIds, allIds
default:
return state
/**
* Author Action creator and Reducer
*/
const addAuthors = payload => (
type: 'ADD_AUTHORS',
payload
)
const authorsReducer = (state = initialState, action) =>
switch (action.type)
case 'ADD_AUTHORS':
let byIds =
let allIds = []
action.payload.map(entity =>
byIds[entity.id] = entity
allIds.push(entity.id)
)
return byIds, allIds
default:
return state
/**
* Presentational components
*/
const Book = ( book ) => <div>`Name: $book.name`</div>
const Author = ( author ) => <div>`Name: $author.name`</div>
/**
* Container components
*/
class View extends Component
componentWillMount ()
this.addBooks()
this.addAuthors()
/**
* Add dummy Books to the Store
*/
addBooks ()
const books = [
id: 1,
name: 'Programming book',
category: 'Programming',
authorId: 1
,
id: 2,
name: 'Healthy book',
category: 'Health',
authorId: 2
]
this.props.addBooks(books)
/**
* Add dummy Authors to the Store
*/
addAuthors ()
const authors = [
id: 1,
name: 'Jordan Enev',
books: [1]
,
id: 2,
name: 'Nadezhda Serafimova',
books: [2]
]
this.props.addAuthors(authors)
renderBooks ()
const books = this.props
return books.map(book => <div key=book.id>
`Name: $book.name`
</div>)
renderAuthors ()
const authors = this.props
return authors.map(author => <Author author=author key=author.id />)
renderHealthAuthors ()
const healthAuthors = this.props
return healthAuthors.map(author => <Author author=author key=author.id />)
renderHealthAuthorsWithBooks ()
const healthAuthorsWithBooks = this.props
return healthAuthorsWithBooks.map(author => <div key=author.id>
<Author author=author />
Books:
author.books.map(book => <Book book=book key=book.id />)
</div>)
render ()
return <div>
<h1>Books:</h1> this.renderBooks()
<hr />
<h1>Authors:</h1> this.renderAuthors()
<hr />
<h2>Health Authors:</h2> this.renderHealthAuthors()
<hr />
<h2>Health Authors with loaded Books:</h2> this.renderHealthAuthorsWithBooks()
</div>
;
const mapStateToProps = state => (
books: getBooksSelector(state),
authors: getAuthorsSelector(state),
healthAuthors: getHealthAuthorsSelector(state),
healthAuthorsWithBooks: getHealthAuthorsWithBooksSelector(state)
)
const mapDispatchToProps =
addBooks, addAuthors
const App = connect(mapStateToProps, mapDispatchToProps)(View)
/**
* Books selectors
*/
/**
* Get Books Store entity
*/
const getBooks = ( books ) => books
/**
* Get all Books
*/
const getBooksSelector = createSelector(getBooks,
books => books.allIds.map(id => books.byIds[id]))
/**
* Authors selectors
*/
/**
* Get Authors Store entity
*/
const getAuthors = ( authors ) => authors
/**
* Get all Authors
*/
const getAuthorsSelector = createSelector(getAuthors,
authors => authors.allIds.map(id => authors.byIds[id]))
/**
* Get array of Authors ids,
* which have books in 'Health' category
*/
const getHealthAuthorsIdsSelector = createSelector([getAuthors, getBooks],
(authors, books) => (
authors.allIds.filter(id =>
const author = authors.byIds[id]
const filteredBooks = author.books.filter(id => (
books.byIds[id].category === 'Health'
))
return filteredBooks.length
)
))
/**
* Get array of Authors,
* which have books in 'Health' category
*/
const getHealthAuthorsSelector = createSelector([getHealthAuthorsIdsSelector, getAuthors],
(filteredIds, authors) => (
filteredIds.map(id => authors.byIds[id])
))
/**
* Get array of Authors, together with their Books,
* which have books in 'Health' category
*/
const getHealthAuthorsWithBooksSelector = createSelector([getHealthAuthorsIdsSelector, getAuthors, getBooks],
(filteredIds, authors, books) => (
filteredIds.map(id => (
...authors.byIds[id],
books: authors.byIds[id].books.map(id => books.byIds[id])
))
))
// Combined Reducer
const reducers = combineReducers(
books: booksReducer,
authors: authorsReducer
)
// Store
const store = createStore(reducers)
const render = () =>
ReactDOM.render(<Provider store=store>
<App />
</Provider>, document.getElementById('root'))
render()
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react-dom.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.24/browser.js"></script>
<script src="https://npmcdn.com/reselect@3.0.1/dist/reselect.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/3.3.1/redux.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/4.4.6/react-redux.min.js"></script>
JSFiddle.
【问题讨论】:
讨厌的选择器是什么意思?那些看起来很正常的过滤器/加入我 您不需要为每个可能的属性编写连接/过滤方法。您可以使用适用于动态键的辅助函数。 lodash 充满了这些类型的方法 就其价值而言,Redux-ORM 的 API 相当稳定。 0.9 版本确实有一些重大更改,但迁移非常简单,我预计未来不会有任何重大的额外更改。我毫不犹豫地鼓励它在生产中使用。你可能想看看我的一些博客文章:Practical Redux, Part 1: Redux-ORM Basics 和 Practical Redux, Part 9: Upgrading Redux-ORM。 我刚刚发布了一个名为“relational-reselect”的包,它解决(或至少尝试)这个用例。这个概念证明不是关于应该如何设计他的商店,而是关于如何创建实现实体之间关系的复杂选择器。它很新,所以如果好奇的家伙想尝试它,我会很高兴!我无意重新创建 RDBMS,因此它可能会出现严重的性能问题。例如,所有连接都以笛卡尔积开始......但我希望它会对一些有经验的人有所帮助或提供想法 【参考方案1】:这让我想起了我是如何开始我的一个数据高度相关的项目的。您仍然对后端的做事方式想得太多,但您必须开始考虑更多的 JS 做事方式(当然,这对某些人来说是一个可怕的想法)。
1) 状态中的归一化数据
您在规范化数据方面做得很好,但实际上,它只是稍微规范化了。为什么这么说?
...
books: [1]
...
...
authorId: 1
...
您在两个地方存储了相同的概念数据。这很容易变得不同步。例如,假设您从服务器接收新书。如果它们的authorId
都为 1,那么您还必须修改图书本身并将这些 id 添加到其中!这是很多不需要完成的额外工作。如果不这样做,数据将不同步。
redux 风格架构的一般经验法则是永远不要存储(在状态中)你可以计算的东西。包括这个关系,很容易被authorId
计算出来。
2) 选择器中的非规范化数据
我们提到在该州拥有标准化数据并不好。但是在选择器中对其进行非规范化是可以的,对吧?嗯,是的。但问题是,需要吗?我做了你现在正在做的同样的事情,让选择器基本上像一个后端 ORM。 “我只是希望能够打电话给author.books
并得到所有的书!”你可能在想。只需能够在 React 组件中循环遍历 author.books
并渲染每本书,就很容易了,对吧?
但是,您真的要规范化您所在州的每条数据吗? React 不需要这个。事实上,它也会增加你的内存使用量。这是为什么呢?
因为现在您将拥有相同author
的两个副本,例如:
const authors = [
id: 1,
name: 'Jordan Enev',
books: [1]
];
和
const authors = [
id: 1,
name: 'Jordan Enev',
books: [
id: 1,
name: 'Book 1',
category: 'Programming',
authorId: 1
]
];
所以getHealthAuthorsWithBooksSelector
现在为每个作者创建一个新对象,该对象不会是 ===
到状态中的那个。
这还不错。但我会说这不是理想。在冗余之上(
所以现在当我们查看您的mapStateToProps
:
const mapStateToProps = state => (
books: getBooksSelector(state),
authors: getAuthorsSelector(state),
healthAuthors: getHealthAuthorsSelector(state),
healthAuthorsWithBooks: getHealthAuthorsWithBooksSelector(state)
);
您基本上是在为组件提供所有相同数据的 3-4 个不同副本。
思考解决方案
首先,在我们开始制作新的选择器并让它变得又快又好之前,让我们先想出一个简单的解决方案。
const mapStateToProps = state => (
books: getBooksSelector(state),
authors: getAuthors(state),
);
啊,这个组件真正需要的唯一数据! books
和 authors
。使用其中的数据,它可以计算它需要的任何东西。
注意到我将它从 getAuthorsSelector
更改为 getAuthors
?这是因为我们计算所需的所有数据都在 books
数组中,我们可以通过 id
拉取作者,我们有他们!
请记住,我们还不担心使用选择器,让我们简单地考虑一下这个问题。因此,在组件内部,让我们为作者的书籍建立一个“索引”。
const books, authors = this.props;
const healthBooksByAuthor = books.reduce((indexedBooks, book) =>
if (book.category === 'Health')
if (!(book.authorId in indexedBooks))
indexedBooks[book.authorId] = [];
indexedBooks[book.authorId].push(book);
return indexedBooks;
, );
我们如何使用它?
const healthyAuthorIds = Object.keys(healthBooksByAuthor);
...
healthyAuthorIds.map(authorId =>
const author = authors.byIds[authorId];
return (<li> author.name
<ul>
healthBooksByAuthor[authorId].map(book => <li> book.name </li>
</ul>
</li>);
)
...
等等等等
但是但是你之前提到了内存,这就是为什么我们没有用getHealthAuthorsWithBooksSelector
对东西进行非规范化,对吧?
正确的!但在这种情况下,我们不会用 冗余 信息占用内存。事实上,每一个实体,books
和author
s,都只是对存储中原始对象的引用!这意味着唯一占用的新内存是容器数组/对象本身,而不是它们中的实际项目。
我发现这种解决方案非常适合许多用例。当然,我不会像上面那样将它保存在组件中,而是将其提取到一个可重用的函数中,该函数根据某些标准创建选择器。 虽然,我承认我没有遇到与您的复杂性相同的问题,因为您必须过滤特定实体,通过另一个实体。哎呀!但仍然可行。
让我们将索引器函数提取为可重用函数:
const indexList = fieldsBy => list =>
// so we don't have to create property keys inside the loop
const indexedBase = fieldsBy.reduce((obj, field) =>
obj[field] = ;
return obj;
, );
return list.reduce(
(indexedData, item) =>
fieldsBy.forEach((field) =>
const value = item[field];
if (!(value in indexedData[field]))
indexedData[field][value] = [];
indexedData[field][value].push(item);
);
return indexedData;
,
indexedBase,
);
;
现在这看起来有点像怪物。但是我们必须使代码的某些部分变得复杂,这样我们才能使更多的部分变得干净。怎么清理?
const getBooksIndexed = createSelector([getBooksSelector], indexList(['category', 'authorId']));
const getBooksIndexedInCategory = category => createSelector([getBooksIndexed],
booksIndexedBy =>
return indexList(['authorId'])(booksIndexedBy.category[category])
);
// you can actually abstract this even more!
...
later that day
...
const mapStateToProps = state => (
booksIndexedBy: getBooksIndexedInCategory('Health')(state),
authors: getAuthors(state),
);
...
const booksIndexedBy, authors = this.props;
const healthyAuthorIds = Object.keys(booksIndexedBy.authorId);
healthyAuthorIds.map(authorId =>
const author = authors.byIds[authorId];
return (<li> author.name
<ul>
healthBooksByAuthor[authorId].map(book => <li> book.name </li>
</ul>
</li>);
)
...
这当然不是那么容易理解,因为它主要依赖于组合这些函数和选择器来构建数据的表示,而不是重新规范化它。
重点是:我们不希望使用标准化数据重新创建状态副本。我们正在尝试*创建易于被组件消化的状态的索引表示(阅读:引用)。
我在这里介绍的索引非常可重用,但并非没有某些问题(我会让其他人弄清楚这些问题)。我不希望您使用它,但我确实希望您从中学到这一点:与其试图强迫您的选择器为您提供类似后端、类似 ORM 的数据嵌套版本,不如使用固有的链接能力使用您已有的工具来处理您的数据:ID 和对象引用。
这些原则甚至可以应用于您当前的选择器。而不是为每个可能的数据组合创建一堆高度专业化的选择器......
1)创建基于某些参数为您创建选择器的函数
2)创建可以用作许多不同选择器的resultFunc
的函数
索引并不适合所有人,我会让其他人建议其他方法。
【讨论】:
【参考方案2】:问题的作者在这里!
一年后,现在我将在这里总结一下我的经验和想法。
我正在研究处理关系数据的两种可能方法:
1。索引
aaronofleonard,已经给了我们一个非常详细的答案here,他的主要概念如下:
我们不希望重新创建标准化状态的副本 数据。我们正在尝试*创建索引表示(阅读: 容易被组件消化的那个状态的引用)。
他提到,它非常适合示例。但重要的是要强调他的示例仅为 一对多 关系创建索引(一本书有许多作者)。所以我开始思考这种方法如何满足我所有可能的要求:
-
处理多对多案例。示例:一本书有多个作者,通过 BookStore。
处理深度过滤。示例:从健康类别中获取所有书籍,其中至少作者来自特定国家/地区。现在想象一下,如果我们有更多嵌套级别的实体。
当然可行,但正如您所见,事情很快就会变得严重。
如果您对使用索引管理这种复杂性感到满意,那么请确保您有足够的设计时间来创建选择器和编写索引实用程序。
我继续寻找解决方案,因为创建这样的索引实用程序看起来完全超出了项目的范围。这更像是创建一个第三方库。
所以我决定尝试Redux-ORM library。
2。 Redux-ORM
一个小型、简单且不可变的 ORM,用于管理 Redux 存储中的关系数据。
不冗长,这里是我管理所有需求的方法,只使用库:
// Handing many-to-many case.
const getBooks = createSelector( Book =>
return Books.all().toModelArray()
.map( book => (
book: book.ref,
authors: book.authors.toRefArray()
)
)
// Handling Deep filtration.
// Keep in mind here you can pass parameters, instead of hardcoding the filtration criteria.
const getFilteredBooks = createSelector( Book =>
return Books.all().toModelArray()
.filter( book =>
const authors = book.authors.toModelArray()
const hasAuthorInCountry = authors.filter(a => a.country.name === 'Bulgaria').length
return book.category.type === 'Health' && hasAuthorInCountry
)
.map( book => (
book: book.ref,
authors: book.authors.toRefArray()
)
)
如您所见 - 库为我们处理所有关系,我们可以轻松访问所有子实体并执行复杂计算。
同样使用.ref
,我们返回实体存储的引用,而不是创建一个新的对象副本(你担心内存)。
所以有了这种类型的选择器,我的流程如下:
-
容器组件通过 API 获取数据。
选择器只获取所需的数据片段。
呈现 Presentation 组件。
然而,没有什么是完美的。 Redux-ORM 以非常易于使用的方式处理查询、过滤等关系操作。酷!
但是当我们谈论选择器的可重用性、组合、扩展等等时——这是一项棘手而尴尬的任务。这不是 Redux-ORM 问题,而是reselect
库本身及其工作方式。 Here我们讨论了这个话题。
结论(个人)
对于更简单的关系项目,我会尝试索引方法。
否则,我会坚持使用 Redux-ORM,因为我在应用程序中使用了它,为此我提出了这个问题。我有 70 多个实体,而且还在计数!
【讨论】:
非常好,最终这似乎是一个很好的解决方案。我有一个关于如何在选择器中使用参数而不是硬编码的问题。您是否使用创建“选择工厂”的方法,也就是为每个使用它的组件创建选择器的新副本的工厂函数?或者你是否使用像重新选择这样的工具来保持简单?谢谢 谢谢!在应用程序中,我有两种类型的选择器参数案例 - 非常动态的参数(想象类别 -> 子类别过滤,按类别和子类别给我所有书籍)而不是这样(通过路由 Country 参数给我所有书籍)。这两种情况我都是通过将第二个参数传递给选择器来处理的。在这里,我描述了如何:***.com/a/50592917/4312466。目前还好!如果需要提高性能,我会考虑其他选项。【参考方案3】:当您开始使用其他命名选择器(例如 getHealthAuthorsWithBooksSelector
,...)“重载”您的选择器(例如 getHealthAuthorsSelector
)时,您最终可能会得到类似 getHealthAuthorsWithBooksWithRelatedBooksSelector
等的东西。
这是不可持续的。我建议您坚持使用高级别的(即getHealthAuthorsSelector
)并使用一种机制,以便他们的书籍以及这些书籍的相关书籍等始终可用。
您可以使用 TypeScript 并将 author.books
转换为 getter,或者仅使用便利函数在需要时从商店获取书籍。通过一个操作,您可以将 get from store 与 fetch from db 结合起来,直接显示(可能)陈旧的数据,并在从数据库中检索数据后让 Redux/React 负责视觉更新。
我没有听说过这种重新选择,但它似乎是一种将各种过滤器放在一个地方以避免在组件中重复代码的好方法。 尽管它们很简单,但它们也很容易测试。业务/领域逻辑测试通常是一个(非常?)好主意,尤其是当您自己不是领域专家时。
另外请记住,将多个实体连接成新的东西有时很有用,例如展平实体,以便它们可以轻松绑定到网格控件。
【讨论】:
【参考方案4】:有一个解决关系选择的库:ngrx-entity-relationship。
它的类似演示在codesandbox上
对于书籍和作者的情况,应该是这样的:
下一个代码我们需要定义一次。
// first we need proper state selectors, because of custom field names
const bookState = stateKeys(getBooks, 'byIds', 'allIds');
const authorState = stateKeys(getAuthors, 'byIds', 'allIds');
// now let's define root and relationship selector factories
const book = rootEntitySelector(bookState);
const bookAuthor = relatedEntitySelector(
authorState,
'authorId',
'author'
);
// the same for authors
const author = rootEntitySelector(authorState);
const authorBooks = relatedEntitySelector(
bookState,
'books', // I would rename it to `booksId`
'booksEntities', // and would use here `books`
);
现在我们可以构建选择器并在需要时重用它们。
// now we can build a selector
const getBooksWithAuthors = rootEntities(
book(
bookAuthor(
authorBooks(), // if we want to go crazy
),
),
);
// and connect it
const mapStateToProps = state => (
books: getBooksWithAuthors(state, [1, 2, 3]), // or a selector for ids
// ...
);
结果是
this.props.books = [
id: 1,
name: 'Book 1',
category: 'Programming',
authorId: 1
author:
id: 1,
name: 'Jordan Enev',
books: [1],
booksEntities: [
id: 1,
name: 'Book 1',
category: 'Programming',
authorId: 1,
,
],
,
,
];
【讨论】:
以上是关于如何处理 Redux 中的关系数据?的主要内容,如果未能解决你的问题,请参考以下文章
从 JSON + Swift 或 ObjC 检索数据时如何处理 CoreData 中的关系