L'application que je crée a beaucoup d'entités et de relations (la base de données est relationnelle). Pour avoir une idée, il y a plus de 25 entités, avec tout type de relations entre elles (un-à-plusieurs, plusieurs-à-plusieurs).
L'application est basée sur React + Redux. Pour obtenir des données du magasin, nous utilisons la bibliothèque Reselect .
Le problème auquel je suis confronté est lorsque j'essaie d'obtenir une entité avec ses relations à partir du magasin.
Afin de mieux expliquer le problème, j'ai créé une application de démonstration simple, qui a une architecture similaire. Je vais mettre en évidence la base de code la plus importante. À la fin, je vais inclure un extrait (violon) afin de jouer avec.
Nous avons des livres et des auteurs. Un livre a un auteur. Un auteur a de nombreux livres. Aussi simple que possible.
const authors = [{
id: 1,
name: 'Jordan Enev',
books: [1]
}];
const books = [{
id: 1,
name: 'Book 1',
category: 'Programming',
authorId: 1
}];
Le magasin est organisé en structure plate, conforme aux meilleures pratiques Redux - Normalizing State Shape .
Voici l'état initial des magasins de livres et d'auteurs:
const initialState = {
// Keep entities, by id:
// { 1: { name: '' } }
byIds: {},
// Keep entities ids
allIds:[]
};
Les composants sont organisés en conteneurs et présentations.
Le composant <App />
Fait office de conteneur (obtient toutes les données nécessaires):
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);
Le composant <View />
Est juste pour la démo. Il envoie des données factices au magasin et rend tous les composants de la présentation sous la forme <Author />, <Book />
.
Pour les sélecteurs simples, cela semble simple:
/**
* 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]) ));
Cela devient compliqué, lorsque vous avez un sélecteur, qui calcule/interroge les données relationnelles. L'application de démonstration comprend les exemples suivants:
Voici les méchants sélecteurs:
/**
* 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()
et imaginez si l'auteur a beaucoup plus de relations.Alors, comment gérez-vous les relations dans Redux?
Cela ressemble à un cas d'utilisation courant, mais étonnamment, il n'y a pas de bonnes pratiques.
* J'ai vérifié redux-orm bibliothèque et elle semble prometteuse, mais son API est toujours instable et je ne suis pas sûr qu'elle soit prête pour la production.
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/[email protected]/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 .
Cela me rappelle comment j'ai commencé l'un de mes projets où les données étaient hautement relationnelles. Vous pensez encore trop à la façon de faire des choses en arrière-plan, mais vous devez commencer à penser à plus de la façon de faire des JS (une pensée effrayante pour certains, bien sûr).
Vous avez fait un bon travail de normalisation de vos données, mais vraiment, ce n'est que quelque peu normalisé. Pourquoi dis-je ça?
...
books: [1]
...
...
authorId: 1
...
Vous avez les mêmes données conceptuelles stockées à deux endroits. Cela peut facilement devenir désynchronisé. Par exemple, supposons que vous receviez de nouveaux livres du serveur. S'ils ont tous authorId
de 1, vous devez également modifier le livre lui-même et y ajouter ces identifiants! C'est beaucoup de travail supplémentaire qui n'a pas besoin d'être fait. Et si ce n'est pas fait, les données seront désynchronisées.
Une règle générale avec une architecture de style redux est de ne jamais stocker (en l'état) ce que vous pouvez calculer . Cela inclut cette relation, elle est facilement calculée par authorId
.
Nous avons mentionné que la normalisation des données dans l'État n'était pas bonne. Mais le dénormaliser dans les sélecteurs est correct, non? Et bien ça l'est. Mais la question est, est-ce nécessaire? J'ai fait la même chose que vous faites maintenant, en faisant en sorte que le sélecteur agisse essentiellement comme un ORM backend. "Je veux juste pouvoir appeler author.books
et prenez tous les livres! "vous pensez peut-être. Il serait si facile de pouvoir parcourir author.books
dans votre composant React, et rendez chaque livre, non?
Mais, voulez-vous vraiment normaliser chaque élément de données dans votre état? React n'en a pas besoin. En fait, cela augmentera également votre utilisation de la mémoire. Pourquoi donc?
Parce que maintenant vous aurez deux copies du même author
, par exemple:
const authors = [{
id: 1,
name: 'Jordan Enev',
books: [1]
}];
et
const authors = [{
id: 1,
name: 'Jordan Enev',
books: [{
id: 1,
name: 'Book 1',
category: 'Programming',
authorId: 1
}]
}];
Ainsi, getHealthAuthorsWithBooksSelector
crée maintenant un nouvel objet pour chaque auteur, qui ne sera pas ===
à celui de l'état.
Ce n'est pas mal. Mais je dirais que ce n'est pas idéal . En plus de l'utilisation de la mémoire redondante (<- mot clé), il est préférable d'avoir une seule référence faisant autorité pour chaque entité de votre magasin. À l'heure actuelle, il existe deux entités pour chaque auteur qui sont identiques sur le plan conceptuel, mais votre programme les considère comme des objets totalement différents.
Alors maintenant, quand nous regardons votre mapStateToProps
:
const mapStateToProps = state => ({
books: getBooksSelector(state),
authors: getAuthorsSelector(state),
healthAuthors: getHealthAuthorsSelector(state),
healthAuthorsWithBooks: getHealthAuthorsWithBooksSelector(state)
});
Vous fournissez essentiellement au composant 3-4 copies différentes de toutes les mêmes données.
Tout d'abord, avant de créer de nouveaux sélecteurs et de les rendre rapides et sophistiqués, imaginons simplement une solution naïve.
const mapStateToProps = state => ({
books: getBooksSelector(state),
authors: getAuthors(state),
});
Ahh, les seules données dont ce composant a vraiment besoin! books
et authors
. En utilisant les données qu'il contient, il peut calculer tout ce dont il a besoin.
Notez que je l'ai changé de getAuthorsSelector
à seulement getAuthors
? C'est parce que toutes les données dont nous avons besoin pour l'informatique sont dans le tableau books
, et nous pouvons simplement extraire les auteurs par id
celui que nous avons!
N'oubliez pas que nous ne nous inquiétons pas encore de l'utilisation de sélecteurs, réfléchissons simplement au problème en termes simples. Donc, à l'intérieur le composant, construisons un "index" des livres par leur auteur.
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;
}, {});
Et comment l'utilisons-nous?
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>);
})
...
Etc.
Mais mais mais vous avez mentionné la mémoire plus tôt, c'est pourquoi nous n'avons pas dénormalisé les choses avec getHealthAuthorsWithBooksSelector
, non? Correct! Mais dans ce cas, nous n'utilisons pas de mémoire avec des informations redondantes . En fait, chaque entité unique, les books
et les author
s, sont juste une référence aux objets originaux dans le magasin! Cela signifie que la seule nouvelle mémoire occupée est celle des tableaux/objets de conteneur eux-mêmes, et non les éléments réels qu'ils contiennent.
J'ai trouvé ce type de solution idéale pour de nombreux cas d'utilisation. Bien sûr, je ne le garde pas dans le composant comme ci-dessus, je l'extrait dans une fonction réutilisable qui crée des sélecteurs basés sur certains critères. Bien que, je dois admettre que je n'ai pas eu de problème avec la même complexité que la vôtre, en ce que vous devez filtrer une entité spécifique, à travers une autre entité. Oui! Mais toujours faisable.
Extrayons notre fonction d'indexeur dans une fonction réutilisable:
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,
);
};
Maintenant, cela ressemble à une sorte de monstruosité. Mais nous devons rendre certaines parties de notre code complexes, afin de pouvoir nettoyer beaucoup plus de parties. Nettoyez comment?
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>);
})
...
Bien sûr, ce n'est pas aussi facile à comprendre, car cela repose principalement sur la composition de ces fonctions et sélecteurs pour construire des représentations de données, au lieu de les renormaliser.
Le fait est que nous ne cherchons pas à recréer des copies de l'état avec des données normalisées. Nous essayons de * créer des représentations indexées (lire: références) de cet état qui sont facilement digérées par les composants.
L'indexation que j'ai présentée ici est très réutilisable, mais pas sans certains problèmes (je laisserai tout le monde les comprendre). Je ne m'attends pas à ce que vous l'utilisiez, mais je m'attends à ce que vous en tiriez une leçon: plutôt que d'essayer de forcer vos sélecteurs à vous fournir des versions imbriquées de type backend, de type ORM de vos données, utilisez la capacité inhérente de lier vos données à l'aide des outils dont vous disposez déjà: identifiants et références d'objets.
Ces principes peuvent même être appliqués à vos sélecteurs actuels. Plutôt que de créer un tas de sélecteurs hautement spécialisés pour chaque combinaison imaginable de données ... 1) Créer des fonctions qui créent des sélecteurs pour vous en fonction de certains paramètres 2) Créer des fonctions qui peuvent être utilisées comme resultFunc
de nombreuses différentes sélecteurs
L'indexation n'est pas pour tout le monde, je laisse les autres suggérer d'autres méthodes.
L'auteur de la question est ici!
Un an plus tard, je vais maintenant résumer mon expérience et mes réflexions ici.
Je cherchais deux approches possibles pour gérer les données relationnelles:
aaronofleonard , nous a déjà donné une réponse excellente et très détaillée ici , où son concept principal est le suivant:
Nous ne cherchons pas à recréer des copies de l'état avec des données normalisées. Nous essayons de * créer des représentations indexées (lire: références) de cet état qui sont facilement digérées par les composants.
Cela correspond parfaitement aux exemples, mentionne-t-il. Mais il est important de souligner que ses exemples créent des index uniquement pour les relations un-à-plusieurs (un livre a plusieurs auteurs). J'ai donc commencé à réfléchir à la manière dont cette approche s'adaptera à toutes mes exigences possibles:
Bien sûr, c'est faisable, mais comme vous pouvez le voir, les choses peuvent devenir sérieuses très bientôt.
Si vous vous sentez à l'aise avec la gestion d'une telle complexité avec l'indexation, assurez-vous d'avoir suffisamment de temps de conception pour créer vos sélecteurs et composer des utilitaires d'indexation.
J'ai continué à chercher une solution, car la création d'un tel utilitaire d'indexation semble totalement hors de portée du projet. Cela ressemble plus à la création d'une bibliothèque tierce.
J'ai donc décidé d'essayer la bibliothèque Redux-ORM .
Un ORM petit, simple et immuable pour gérer les données relationnelles dans votre boutique Redux.
Sans être verbeux, voici comment j'ai géré toutes les exigences, en utilisant simplement la bibliothèque:
// 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()
})
})
Comme vous pouvez le voir - la bibliothèque gère toutes les relations pour nous et nous pouvons facilement accéder à toutes les entités enfants et effectuer des calculs complexes.
Utilisant également .ref
nous renvoyons la référence de l'entité Store, au lieu de créer une nouvelle copie d'objet (vous êtes inquiet pour la mémoire).
Donc, avec ce type de sélecteurs, mon flux est le suivant:
Cependant, rien n'est parfait comme cela puisse paraître. Redux-ORM traite les opérations relationnelles comme l'interrogation, le filtrage, etc. d'une manière très simple d'utilisation. Cool!
Mais lorsque nous parlons de réutilisation des sélecteurs, de composition, d'extension, etc. - c'est une tâche délicate et délicate. Ce n'est pas un problème Redux-ORM, que la bibliothèque reselect
elle-même et la façon dont cela fonctionne. Ici nous avons discuté du sujet.
Pour des projets relationnels plus simples, je voudrais essayer l'approche d'indexation.
Sinon, je resterais avec Redux-ORM, comme je l'ai utilisé dans l'application, pour laquelle j'ai posé la question. Là, j'ai plus de 70 entités et je compte toujours!
Lorsque vous commencez à "surcharger" vos sélecteurs (comme getHealthAuthorsSelector
) avec d'autres sélecteurs nommés (comme getHealthAuthorsWithBooksSelector
, ...), vous pourriez vous retrouver avec quelque chose comme getHealthAuthorsWithBooksWithRelatedBooksSelector
etc etc.
Ce n'est pas durable. Je vous suggère de vous en tenir à ceux de haut niveau (c'est-à-dire getHealthAuthorsSelector
) et d'utiliser un mécanisme pour que leurs livres et les livres associés de ces livres, etc. soient toujours disponibles.
Vous pouvez utiliser TypeScript et tourner le author.books
dans un getter, ou tout simplement travailler avec des fonctions de commodité pour obtenir les livres du magasin chaque fois qu'ils sont nécessaires. Avec une action, vous pouvez combiner un get from store avec un fetch de db pour afficher directement (éventuellement) des données périmées et demander à Redux/React de prendre en charge la mise à jour visuelle une fois les données récupérées de la base de données.
Je n'avais pas entendu parler de cette resélection, mais il semble que ce soit un bon moyen d'avoir toutes sortes de filtres en un seul endroit pour éviter la duplication de code dans les composants.
Aussi simples soient-ils, ils sont également facilement testables. Les tests de logique métier/domaine sont généralement une (très?) Bonne idée, surtout lorsque vous n'êtes pas vous-même un expert en domaine.
Gardez également à l'esprit qu'une jonction de plusieurs entités en quelque chose de nouveau est utile de temps à autre, par exemple l'aplatissement d'entités afin qu'elles puissent être facilement liées à un contrôle de grille.