memoized medium createselector javascript reactjs redux react-redux reselect

javascript - medium - ¿Cómo lidiar con los datos relacionales en Redux?



redux-saga (3)

La aplicación que estoy creando tiene muchas entidades y relaciones (la base de datos es relacional). Para tener una idea, hay más de 25 entidades, con cualquier tipo de relación entre ellas (una a muchas, muchas a muchas).

La aplicación está basada en React + Redux. Para obtener datos de la tienda, estamos usando la biblioteca Reselect .

El problema al que me enfrento es cuando trato de obtener una entidad con sus relaciones de la Tienda.

Para explicar mejor el problema, he creado una aplicación de demostración simple, que tiene una arquitectura similar. Destacaré el código base más importante. Al final incluiré un fragmento (violín) para jugar con él.

Aplicación de demostración

Lógica de negocios

Tenemos libros y autores. Un libro tiene un autor. Un autor tiene muchos libros. Lo más sencillo posible.

const authors = [{ id: 1, name: ''Jordan Enev'', books: [1] }]; const books = [{ id: 1, name: ''Book 1'', category: ''Programming'', authorId: 1 }];

Tienda Redux

La tienda está organizada en una estructura plana, que cumple con las mejores prácticas de Redux: normalizar la forma del estado .

Aquí está el estado inicial para los libros y las tiendas de autores:

const initialState = { // Keep entities, by id: // { 1: { name: '''' } } byIds: {}, // Keep entities ids allIds:[] };

Componentes

Los componentes están organizados como Contenedores y Presentaciones.

<App /> componente <App /> actúa como contenedor (obtiene todos los datos necesarios):

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 /> componente <View /> es solo para la demo. Envía datos ficticios a la Tienda y presenta todos los componentes de la Presentación como <Author />, <Book /> .

Selectores

Para los selectores simples, parece sencillo:

/** * 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]) ));

Se ensucia, cuando tienes un selector, que calcula / consulta datos relacionales. La aplicación de demostración incluye los siguientes ejemplos:

  1. Obtención de todos los autores, que tienen al menos un libro en una categoría específica.
  2. Obteniendo los mismos autores, pero junto con sus libros.

Aquí están los desagradables selectores:

/** * 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]) })) ));

Resumiendo

  1. Como puede ver, la computación / consulta de datos relacionales en los selectores se complica demasiado.
    1. Cargando relaciones infantiles (Autor-> Libros).
    2. Filtrado por entidades getHealthAuthorsWithBooksSelector() ).
  2. Habrá demasiados parámetros de selección, si una entidad tiene muchas relaciones secundarias. getHealthAuthorsWithBooksSelector() e imagina si el autor tiene muchas más relaciones.

Entonces, ¿cómo lidiar con las relaciones en Redux?

Parece un caso de uso común, pero sorprendentemente no hay una ronda de buenas prácticas.

* redux-orm biblioteca de redux-orm y parece prometedora, pero su API sigue siendo inestable y no estoy seguro de que esté lista para la producción.

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 .


¡El autor de la pregunta está aquí!

Un año después, ahora voy a resumir mi experiencia y pensamientos aquí.

Estaba estudiando dos posibles enfoques para manejar los datos relacionales:

1. indexación

, ya nos dio una gran y muy detallada respuesta here , donde su concepto principal es el siguiente:

No estamos buscando recrear copias del estado con datos normalizados. Estamos tratando de * crear representaciones indexadas (leer: referencias) de ese estado que son fácilmente digeridas por los componentes.

Se ajusta perfectamente a los ejemplos, menciona. Pero es importante destacar que sus ejemplos crean índices solo para relaciones de uno a muchos (un Libro tiene muchos Autores). Así que empecé a pensar cómo este enfoque se ajustaría a todos mis posibles requisitos:

  1. Entrega de muchos a muchos casos. Ejemplo: Un libro tiene muchos autores, a través de BookStore.
  2. Manipulación de filtración profunda . Ejemplo: Obtenga todos los libros de la categoría Saludable, donde al menos en Autor es de un país específico. Ahora imagínense si tenemos muchos más niveles anidados de entidades.

Por supuesto que es factible, pero como puede ver, las cosas pueden ponerse serias muy pronto.

Si se siente cómodo administrando tal complejidad con la indexación, entonces asegúrese de tener suficiente tiempo de diseño para crear sus selectores y componer las utilidades de indexación.

Continué buscando una solución, porque crear una utilidad de indexación de este tipo parece totalmente fuera del alcance del proyecto. Es más como crear una biblioteca de terceros.

Así que decidí darle una oportunidad a la biblioteca redux-orm .

2. Redux-ORM

Un ORM pequeño, simple e inmutable para administrar datos relacionales en su tienda Redux.

Sin ser detallado, así es como gestioné todos los requisitos, solo con la biblioteca:

// 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() }) })

Como puede ver, la biblioteca maneja todas las relaciones por nosotros y podemos acceder fácilmente a todas las entidades secundarias y realizar cálculos complejos.

También utilizando .ref devolvemos la referencia de la entidad Store, en lugar de crear una nueva copia de objeto (le preocupa la memoria).

Así que teniendo este tipo de selectores mi flujo es el siguiente:

  1. Los componentes del contenedor obtienen los datos a través de la API.
  2. Los selectores obtienen solo la porción de datos necesaria.
  3. Renderizar los componentes de la presentación.

Sin embargo, nada es perfecto como suena. Redux-ORM trata las operaciones relacionales como la consulta, el filtrado, etc. de una manera muy fácil de usar. ¡Guay!

Pero cuando hablamos de la reutilización de los selectores, la composición, la extensión, etc., es una tarea un tanto complicada e incómoda. No es un problema de Redux-ORM, sino a la biblioteca de reselect sí y la forma en que funciona. Here discutimos el tema.

Conclusión (personal)

Para proyectos relacionales más simples, probaría el enfoque de indexación.

De lo contrario, me quedaría con Redux-ORM, como lo usé en la aplicación, para lo cual hice la pregunta. ¡Ahí tengo más de 70 entidades y sigo contando!


Cuando comience a "sobrecargar" sus selectores (como getHealthAuthorsSelector ) con otros selectores con nombre (como getHealthAuthorsWithBooksSelector , ...) podría terminar con algo como getHealthAuthorsWithBooksWithRelatedBooksSelector etc. etc.

Eso no es sostenible. Le sugiero que se getHealthAuthorsSelector a los de alto nivel (es decir, getHealthAuthorsSelector ) y utilice un mecanismo para que sus libros y los libros relacionados de esos libros estén siempre disponibles.

Puede usar TypeScript y convertir los author.books en un captador, o simplemente trabajar con funciones de conveniencia para obtener los libros de la tienda cuando sea necesario. Con una acción, puede combinar un acceso desde la tienda con una búsqueda desde db para mostrar (posiblemente) datos obsoletos directamente y hacer que Redux / React se encargue de la actualización visual una vez que los datos se recuperan de la base de datos.

No había oído hablar de esta nueva selección, pero parece que podría ser una buena manera de tener todo tipo de filtros en un lugar para evitar la duplicación de código en los componentes.
Por simples que sean, también son fáciles de probar. Las pruebas de lógica de negocios / dominio son generalmente una idea (¿muy?) Buena, especialmente cuando usted mismo no es un experto en dominios.

También tenga en cuenta que una combinación de varias entidades en algo nuevo es útil de vez en cuando, por ejemplo, al aplanar entidades para que puedan vincularse fácilmente a un control de cuadrícula.


Esto me recuerda cómo comencé uno de mis proyectos donde los datos eran altamente relacionales. Piensas demasiado en la manera de hacer las cosas desde el fondo, pero debes empezar a pensar en más de la manera en que JS hace las cosas (un pensamiento aterrador para algunos, sin duda).

1) Datos normalizados en estado

Has hecho un buen trabajo normalizando tus datos, pero en realidad, solo está un poco normalizado. ¿Por qué digo eso?

... books: [1] ... ... authorId: 1 ...

Tienes los mismos datos conceptuales almacenados en dos lugares. Esto puede desincronizarse fácilmente. Por ejemplo, digamos que recibe nuevos libros del servidor. Si todos tienen un authorId de authorId 1, también tiene que modificar el libro y agregarle esos ID. Eso es mucho trabajo extra que no necesita hacerse. Y si no se hace, los datos no estarán sincronizados.

Una regla general con una arquitectura de estilo redux nunca es almacenar (en el estado) lo que puede calcular . Eso incluye esta relación, es fácilmente computable por authorId .

2) Datos desnormalizados en selectores

Mencionamos que tener datos normalizados en el estado no era bueno. Pero desnormalizarlo en selectores está bien, ¿no? Bueno, lo es. Pero la pregunta es, ¿es necesario? Hice lo mismo que está haciendo ahora, logrando que el selector actúe básicamente como un ORM backend. "¡Solo quiero poder llamar a author.books y obtener todos los libros!" puedes estar pensando Sería tan fácil simplemente poder recorrer los author.books de author.books en su componente React y renderizar cada libro, ¿verdad?

Pero, ¿realmente desea normalizar cada dato en su estado? Reaccionar no necesita eso . De hecho, también aumentará su uso de memoria. ¿Porqué es eso?

Porque ahora tendrás dos copias del mismo author , por ejemplo:

const authors = [{ id: 1, name: ''Jordan Enev'', books: [1] }];

y

const authors = [{ id: 1, name: ''Jordan Enev'', books: [{ id: 1, name: ''Book 1'', category: ''Programming'', authorId: 1 }] }];

Entonces, getHealthAuthorsWithBooksSelector ahora crea un nuevo objeto para cada autor, que no será === al del estado.

Esto no es malo. Pero diría que no es lo ideal . Además del uso de la memoria redundante (<- keyword), es mejor tener una única referencia autoritativa para cada entidad en su tienda. En este momento, hay dos entidades para cada autor que son conceptualmente iguales, pero su programa las ve como objetos totalmente diferentes.

Así que ahora cuando nos fijamos en su mapStateToProps :

const mapStateToProps = state => ({ books: getBooksSelector(state), authors: getAuthorsSelector(state), healthAuthors: getHealthAuthorsSelector(state), healthAuthorsWithBooks: getHealthAuthorsWithBooksSelector(state) });

Básicamente, está proporcionando al componente 3-4 copias diferentes de todos los mismos datos.

Pensando en soluciones

Primero, antes de comenzar a hacer nuevos selectores y hacer que todo sea rápido y elegante, solo inventemos una solución ingenua.

const mapStateToProps = state => ({ books: getBooksSelector(state), authors: getAuthors(state), });

Ahh, los únicos datos que este componente realmente necesita! Los books , y los authors . Usando los datos que contiene, puede calcular cualquier cosa que necesite.

¿Nota que lo cambié de getAuthorsSelector a solo getAuthors ? Esto se debe a que todos los datos que necesitamos para la computación están en la matriz de books , ¡y podemos simplemente jalar a los autores por id uno que tenemos!

Recuerde, no estamos preocupados por el uso de selectores todavía, solo pensemos en el problema en términos simples. Entonces, dentro del componente, construyamos un "índice" de libros por su autor.

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; }, {});

¿Y cómo lo usamos?

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étera etcétera.

Pero pero mencionó la memoria anteriormente, es por eso que no desnormalizamos las cosas con getHealthAuthorsWithBooksSelector , ¿verdad? ¡Correcto! Pero en este caso no estamos ocupando memoria con información redundante . De hecho, todas las entidades, los books y los author , son solo una referencia a los objetos originales en la tienda. Esto significa que la única nueva memoria que está siendo ocupada es por los arrays / objetos del contenedor, no por los elementos reales en ellos.

He encontrado este tipo de solución ideal para muchos casos de uso. Por supuesto, no lo guardo en el componente como se muestra arriba, lo extraigo en una función reutilizable que crea selectores basados ​​en ciertos criterios. Sin embargo, debo admitir que no he tenido un problema con la misma complejidad que el suyo, ya que tiene que filtrar una entidad específica, a través de otra entidad. ¡Ay! Pero todavía es factible.

Extraigamos nuestra función de indexador en una función reutilizable:

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, ); };

Ahora esto parece una especie de monstruosidad. Pero debemos hacer que ciertas partes de nuestro código sean complejas, para que podamos hacer que muchas más partes queden limpias. Limpiar como?

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>); }) ...

Por supuesto, esto no es tan fácil de entender, ya que se basa principalmente en la composición de estas funciones y los selectores para construir representaciones de datos, en lugar de renormalizarlos.

El punto es: no estamos buscando recrear copias del estado con datos normalizados. Estamos tratando de * crear representaciones indexadas (leer: referencias) de ese estado que son fácilmente digeridas por los componentes.

La indexación que he presentado aquí es muy reutilizable, pero no sin ciertos problemas (dejaré que todos los demás lo resuelvan). No espero que lo uses, pero sí espero que aprendas esto: en lugar de tratar de forzar a tus selectores para que te den versiones anidadas, similares a ORM de tus datos, utiliza la capacidad inherente de vincular sus datos utilizando las herramientas que ya tiene: identificadores y referencias de objetos.

Estos principios pueden incluso aplicarse a sus selectores actuales. En lugar de crear un grupo de selectores altamente especializados para cada combinación de datos concebible ... 1) Cree funciones que creen selectores para usted basándose en ciertos parámetros 2) Cree funciones que puedan usarse como el resultFunc de muchos selectores diferentes

La indexación no es para todos, dejaré que otros sugieran otros métodos.