Inmediatamente un pequeño spoiler: organizar un estado en mobx no es diferente de organizar un estado general sin usar mobx en una reacción pura. La respuesta a la pregunta natural es por qué, de hecho, ¿se necesita este mobx? Lo encontrará al final del artículo, pero por ahora, el artículo se dedicará al tema de la organización estatal en una aplicación de reacción limpia sin bibliotecas externas.

La reacción proporciona una forma de almacenar y actualizar el estado de los componentes utilizando la propiedad de estado en una instancia de un componente de clase y el método setState. Sin embargo, entre la comunidad de reacción, se utilizan un montón de bibliotecas y enfoques adicionales para trabajar con el estado (flux, redux, redux-ations, efector, mobx, cerebral, un montón de ellos). Pero, ¿es posible construir una aplicación suficientemente grande con un montón de lógica de negocios con una gran cantidad de entidades y complejas relaciones de datos entre componentes usando solo setState? ¿Existe la necesidad de bibliotecas adicionales para trabajar con el estado? Vamos a resolverlo.
Así que tenemos setState y que actualiza el estado y llama al procesador del componente. Pero, ¿qué sucede si muchos componentes que no están interconectados requieren los mismos datos? En el muelle oficial de la reacción hay una sección "levantando el estado" con una descripción detallada: simplemente elevamos el estado al ancestro común a estos componentes, pasando a través de accesorios (y a través de componentes intermedios, si es necesario) datos y funciones para cambiarlo. Para ejemplos pequeños, esto parece razonable, pero la realidad es que en aplicaciones complejas puede haber muchas dependencias entre componentes y la tendencia a transferir estados a un componente común del antepasado lleva al hecho de que todo el estado será más y más alto y terminará en el componente raíz de la aplicación junto con La lógica para actualizar este estado para todos los componentes. Como resultado, setState solo ocurrirá para actualizar el componente de datos local o en el componente raíz de la aplicación, en el que se concentrará toda la lógica.
Pero, ¿es posible almacenar el proceso y el estado de representación en una aplicación de reacción sin usar setState o ninguna biblioteca adicional y proporcionar acceso general a estos datos desde cualquier componente?
Los objetos javascript más comunes y ciertas reglas para organizarlos nos ayudan.
Pero primero debe aprender a descomponer las aplicaciones en tipos de entidad y sus relaciones.
Para comenzar, presentamos un objeto que almacenará datos globales que se aplican a toda la aplicación como un todo (esto puede ser la configuración de estilos, localización, tamaños de ventana, etc.) en un solo objeto AppState y simplemente coloca este objeto en un archivo separado.
// src/stores/AppState.js export const AppState = { locale: "en", theme: "...", .... }
Ahora en cualquier componente puede importar y usar los datos de nuestra tienda.
import AppState from "../stores/AppState.js" const SomeComponent = ()=> ( <div> {AppState.locale === "..." ? ... : ...} </div> )
Vamos más allá: casi todas las aplicaciones tienen la esencia del usuario actual (no importa cómo se crea o proviene del servidor, etc.), por lo que el objeto singleton de nuestro usuario también estará en el estado de la aplicación. También se puede mover a un archivo separado y también importarse, o se puede almacenar inmediatamente dentro del objeto AppState. Y ahora lo principal: debe determinar el diagrama de las entidades que componen la aplicación. En términos de una base de datos, estas serán tablas con relaciones uno a muchos o muchos a muchos, y toda esta cadena de relaciones comienza desde la esencia principal del usuario. Bueno, en nuestro caso, el objeto del usuario simplemente almacenará una matriz de otros objetos-entidades-tiendas, donde cada objeto-tienda, a su vez, almacenará matrices de otras entidades-tiendas.
Aquí hay un ejemplo: hay una lógica de negocios que se expresa como "el usuario puede crear / editar / eliminar carpetas, proyectos en cada carpeta, en cada proyecto de tarea y en cada tarea de subtarea" (resulta algo así como un administrador de tareas) y se verá en el diagrama de estado algo como esto:
export const AppStore = { locale: "en", theme: "...", currentUser: { name: "...", email: "" folders: [ { name: "folder1", projects: [ { name: "project1", tasks: [ { text: "task1", subtasks: [ {text: "subtask1"}, .... ] }, .... ] }, ..... ] }, ..... ] } }
Ahora, el componente raíz de la aplicación puede simplemente importar este objeto y presentar información sobre el usuario, y luego puede transferir el objeto del usuario al componente del tablero
.... <Dashboard user={appState.user}/> ....
y él puede representar la lista de carpetas
... <div>{user.folders.map(folder=><Folder folder={folder}/>)}</div> ...
y cada componente de la carpeta mostrará una lista de proyectos
.... <div>{folder.projects.map(project=><Project project={project}/>)}</div> ....
y cada componente del proyecto puede enumerar tareas
.... <div>{project.tasks.map(task=><Task task={task}/>)}</div> ....
y finalmente, cada componente de la tarea puede representar una lista de subtareas pasando el objeto deseado al componente de subtarea
.... <div>{task.subtask.map(subtask=><Subtask subtask={subtask}/>)}</div> ....
Naturalmente, en una página nadie mostrará todas las tareas de todos los proyectos de todas las carpetas, se dividirán por paneles laterales (por ejemplo, una lista de carpetas), por páginas, etc., pero la estructura general es aproximadamente la misma: el componente principal representa el componente incrustado pasando un objeto con accesorios. datos Cabe señalar un punto importante: cualquier objeto (por ejemplo, un objeto de una carpeta, proyecto, tarea) no se almacena dentro del estado de ningún componente; el componente simplemente lo recibe a través de accesorios como parte de un objeto más general. Y, por ejemplo, cuando el componente del proyecto pasa el objeto de tarea ( <div>{project.tasks.map(task=><Task task={task}/>)}</div>
) al componente hijo de Task, debido al hecho de que los objetos se almacenan dentro de un solo objeto siempre puede cambiar este objeto de tarea desde el exterior, por ejemplo, AppState.currentUser.folders [2] .projects [3] .tasks [4] .text = "tarea editada" y luego hacer que el componente raíz se actualice (ReactDOM.render (<App /> ) y de esta forma obtenemos el estado actual de la aplicación.
Supongamos además que queremos crear una nueva subtarea al hacer clic en el botón "+" en el componente Tarea. Todo es simple
onClick = ()=>{ this.props.task.subtasks.push({text: ""}); updateDOM() }
dado que el componente Tarea recibe como apuntalamiento el objeto de tarea y este objeto no se almacena dentro de su estado sino que es parte del almacén global de AppState (es decir, el objeto de tarea se almacena dentro de la matriz de tareas del objeto de proyecto más general, y eso a su vez es parte del objeto de usuario y el usuario ya está almacenado dentro de AppState ) y gracias a esta conectividad, después de agregar un nuevo objeto de tarea a la matriz de subtareas, puede llamar a la actualización del componente raíz y, por lo tanto, actualizar y actualizar la casa para todos los cambios de datos (sin importar dónde ocurrieron) simplemente llamando a la función upd ateDOM, que a su vez simplemente actualiza el componente raíz.
export function updateDOM(){ ReactDom.render(<App/>, rootElement); }
Y no importa qué datos de qué partes de AppState y desde qué lugares cambiemos (por ejemplo, puede reenviar un objeto de carpeta a través de accesorios a través de componentes intermedios de proyectos y tareas al componente Subtarea, y solo puede actualizar el nombre de la carpeta (this.props.folder.name = "nombre nuevo "): debido a que los componentes reciben datos a través de accesorios, la actualización del componente raíz actualizará todos los componentes anidados y actualizará toda la aplicación.
Ahora intentemos agregar algo de conveniencia para trabajar con el lateral. En el ejemplo anterior, puede observar que al crear un nuevo objeto de entidad cada vez (por ejemplo, project.tasks.push({text: "", subtasks: [], ...})
si el objeto tiene muchas propiedades con parámetros predeterminados, cada vez para enumerarlos y cometer un error y olvidar algo, etc. Lo primero que viene a la mente es poner la creación de un objeto en una función donde se asignarán los campos predeterminados y, al mismo tiempo, redefinirlos con nuevos datos.
function createTask(data){ return { text: "", subtasks: [], ... //many default fields ...data } }
pero si miras desde el otro lado, esta función es el constructor de una determinada entidad y las clases de JavaScript son excelentes para este rol
class Task { text: ""; subtasks: []; constructor(data){ Object.assign(this, data) } }
y luego crear el objeto simplemente creará una instancia de la clase con la capacidad de anular algunos campos predeterminados
onAddTask = ()=>{ this.props.project.tasks.push(new Task({...}) }
Además, puede notar que de la misma manera, al crear clases para objetos de proyecto, usuarios, subtareas, obtenemos duplicación de código dentro del constructor
constructor()
pero podemos aprovechar la herencia y extraer este código en el constructor de la clase base.
class BaseStore { constructor(data){ Object.update(this, data); } }
Además, notará que cada vez que actualizamos algún estado, cambiamos manualmente los campos del objeto
user.firstName = "..."; user.lastName = "..."; updateDOM();
y se hace difícil rastrear, negociar y comprender lo que está sucediendo en el componente y, por lo tanto, es necesario determinar un canal común a través del cual pasarán las actualizaciones de cualquier información y luego podemos agregar el registro y todo tipo de otras comodidades. Para hacer esto, la solución es crear un método de actualización en la clase que tome un objeto temporal con nuevos datos y se actualice a sí mismo y establezca la regla de que los objetos se pueden actualizar solo a través del método de actualización y no mediante asignación directa
class Task { update(newData){ console.log("before update", this); Object.assign(this, data); console.log("after update", this); } }
Bueno, para no duplicar el código en cada clase, también movemos este método de actualización a la clase base.
Ahora puede ver que cuando actualizamos algunos datos, tenemos que llamar manualmente al método updateDOM (). Pero es conveniente realizar esta actualización automáticamente cada vez que se realiza una llamada al método de actualización ({...}) de la clase base.
Resulta que la clase base se verá así
class BaseStore { constructor(data){ Object.update(this, data); } update(data){ Object.update(this, data); ReactDOM.render(<App/>, rootElement) } }
Bueno, para que durante la llamada sucesiva del método update () no haya actualizaciones innecesarias, puede retrasar la actualización del componente al siguiente bucle de eventos
let TimerId = 0; class BaseStore { constructor(data){ Object.update(this, data); } update(data){ Object.update(this, data); if(TimerId === 0) { TimerId = setTimeout(()=>{ TimerId = 0; ReactDOM.render(<App/>, rootElement); }) } } }
Además, puede aumentar gradualmente la funcionalidad de la clase base, por ejemplo, para no tener que enviar manualmente una solicitud al servidor cada vez, además de actualizar el estado, puede enviar una solicitud al método de actualización ({..}) en segundo plano. Puede organizar un canal de actualización en vivo para sockets web agregando una cuenta de cada objeto creado en el mapa global de hash sin cambiar los componentes y trabajar con datos de ninguna manera.
Todavía queda mucho por hacer, pero quiero mencionar un tema interesante: muy a menudo pasar un objeto con datos al componente necesario (por ejemplo, cuando un componente del proyecto representa un componente de tarea)
<div>{project.tasks.map(task=><Task task={task}/>)}</div>
El componente mismo de la tarea puede necesitar cierta información que no se almacena directamente dentro de la tarea, sino que se encuentra en el objeto principal.
Suponga que desea colorear todas las tareas en un color que esté almacenado en el proyecto y que sea común a todas las tareas. Para hacer esto, además de los accesorios de la tarea, el componente del proyecto también debe transmitir sus accesorios del proyecto <Task task={task} project={this.props.project}/>
. Y si de repente necesita colorear la tarea en un color común a todas las tareas en una carpeta, tendrá que transferir el objeto de carpeta actual del componente Carpeta al componente Tarea enviándolo a través del componente Proyecto intermedio.
Parece una dependencia frágil que el componente debe saber lo que requieren sus componentes anidados. Además, la posibilidad de un contexto de reacción, aunque simplificará la transferencia a través de componentes intermedios, aún requerirá una descripción del proveedor y el conocimiento de qué datos se necesitan para los componentes secundarios.
Pero el problema principal es que cada vez que edita un diseño o cambia la lista de deseos de un cliente cuando un componente necesita nueva información, tendrá que cambiar los componentes superiores, ya sea reenviando accesorios o creando proveedores de contexto. Me gustaría que el componente reciba a través de accesorios un objeto con datos para acceder de alguna manera a cualquier parte del estado de nuestra aplicación. Y aquí, javascript es una buena opción (a diferencia de cualquier lenguaje funcional como elm o enfoques inmutables como redux), para que los objetos puedan almacenar enlaces circulares entre sí. En este caso, el objeto de tarea debe tener un campo task.project con un enlace al objeto del proyecto principal en el que está almacenado, y el objeto del proyecto a su vez debe tener un enlace al objeto de carpeta, etc., al objeto raíz de AppState. Por lo tanto, el componente, no importa cuán profundo sea, siempre puede atravesar los objetos principales a través del enlace y obtener toda la información necesaria y no es necesario lanzarlo a través de un grupo de componentes intermedios. Por lo tanto, presentamos una regla: cada vez que cree un objeto, debe agregar un enlace al objeto principal. Por ejemplo, ahora crear una nueva tarea se verá así
... const {project} = this.props; const newTask = new Task({project: this.props.project}) this.props.project.tasks.push(newTask);
Además, con un aumento en la lógica empresarial, puede notar que la placa de identificación está asociada con el soporte de vínculo de retroceso (por ejemplo, al asignar un enlace al objeto principal al crear un nuevo objeto o, por ejemplo, al transferir un proyecto de una carpeta a otra, no solo necesitará actualizar la propiedad project.folder = newFolder y eliminarla). usted mismo desde la matriz del proyecto de la carpeta anterior y agregando una nueva carpeta a la matriz del proyecto) comienza a repetirse y también se puede mover a la clase base para que cuando cree el objeto sea suficiente para especificar la new Task({project: this.porps.project})
new Task({project: this.porps.project})
y la clase base agregaría automáticamente un nuevo objeto a la matriz project.tasks
y también al transferir la tarea a otro proyecto bastaría con actualizar el campo task.update({project: newProject})
y la clase base eliminaría automáticamente la tarea de un conjunto de tareas del proyecto anterior y agregado a uno nuevo. Pero esto ya requerirá la declaración de relaciones (por ejemplo, en propiedades o métodos estáticos) para que la clase base sepa qué campos actualizar.
Conclusión
De una manera tan simple, usando solo objetos js, llegamos a la conclusión de que puede obtener toda la conveniencia de trabajar con el estado general de la aplicación sin introducir en la aplicación la dependencia de una biblioteca externa para trabajar con el estado.
La pregunta es, ¿por qué entonces necesitamos bibliotecas para administrar el estado y, en particular, mobx?
El hecho es que en el enfoque descrito para la organización del estado general, cuando se usan objetos js "vainilla" nativos ordinarios (u objetos de clase) hay un gran inconveniente: cuando una pequeña parte del estado o incluso un campo cambia, los componentes se actualizarán o "renderizarán" y no estarán conectados de ninguna manera y no dependen de esta parte del estado.
Y en aplicaciones grandes con interfaz de usuario en negrita, esto conducirá a frenos porque la reacción simplemente no tiene tiempo para comparar recursivamente la casa virtual de toda la aplicación, dado que además de comparar cada renderizador, se generará un nuevo árbol de objetos cada vez que describa el diseño de absolutamente todos los componentes.
Pero este problema, a pesar de la importancia, es puramente técnico: hay bibliotecas similares a la reacción de vitual dom que optimizan mejor el renderizador y pueden aumentar el límite del componente.
Existen técnicas de renovación del hogar más efectivas que la creación de un nuevo árbol de inicio virtual y la posterior comparación recursiva con el árbol anterior.
Y finalmente, hay bibliotecas que intentan resolver el problema de las actualizaciones lentas a través de un enfoque diferente, es decir, rastrear qué partes del estado están conectadas a qué componentes y al cambiar algunos datos, calcular y actualizar solo aquellos componentes que dependen de estos datos y no tocan los componentes restantes. Redux también es una biblioteca de este tipo, pero requiere un enfoque completamente diferente de la organización estatal. Pero la biblioteca mobx, por el contrario, no trae nada nuevo y podemos acelerar el renderizador prácticamente sin cambiar nada en la aplicación: simplemente agregue el decorador @observable
a los campos de la clase y el decorador @observable
a los componentes que representan estos campos y permanece. para cortar solo el código de actualización innecesario para el componente raíz en el método update () de nuestra clase base y obtendremos una aplicación completamente funcional, pero ahora cambiando una parte del estado o incluso un campo actualizará solo esos componentes que vencieron firmado (girando método dentro render ()) para un campo particular de un estado particular del objeto.