Comment organiser l'état général des applications React sans utiliser de bibliothèques (et pourquoi Mobx est nécessaire)

Immédiatement un petit spoiler - l'organisation d'un état dans mobx n'est pas différente de l'organisation d'un état général sans utiliser mobx dans une pure réaction. La réponse à la question naturelle est pourquoi alors avez-vous besoin de ce mobx à la fin de l'article, mais pour l'instant, l'article sera consacré à l'organisation de l'état dans une application propre et sans bibliothèques externes.




La réaction fournit un moyen de stocker et de mettre à jour l'état des composants à l'aide de la propriété state sur une instance d'un composant de classe et de la méthode setState. Néanmoins, parmi la communauté de réaction, un tas de bibliothèques et d'approches supplémentaires pour travailler avec l'état sont utilisées (flux, redux, redux-ations, effecteur, mobx, cérébral, un tas d'entre eux). Mais est-il possible de construire une application suffisamment grande avec un tas de logique métier avec un grand nombre d'entités et des relations de données complexes entre les composants en utilisant uniquement setState? Existe-t-il un besoin de bibliothèques supplémentaires pour travailler avec l'état? Voyons cela.

Nous avons donc setState et qui met à jour l'état et appelle le rendu du composant. Mais que se passe-t-il si les mêmes données sont requises par de nombreux composants qui ne sont pas interconnectés? Dans le dock officiel de la réaction, il y a une section "lever l'état" avec une description détaillée - nous élevons simplement l'état à l'ancêtre commun à ces composants, en passant des données et des fonctions pour le changer via des accessoires (et via des composants intermédiaires, si nécessaire). Pour les petits exemples, cela semble raisonnable, mais la réalité est que dans les applications complexes, il peut y avoir beaucoup de dépendances entre les composants et la tendance à transférer des états vers un composant commun de l'ancêtre conduit au fait que l'état entier sera poussé de plus en plus haut et se retrouvera dans le composant racine de l'application avec la logique de mise à jour de cet état pour tous les composants. Par conséquent, setState ne se produira que pour mettre à jour le composant de données local ou dans le composant racine de l'application, dans lequel toute la logique sera concentrée.


Mais est-il possible de stocker le processus et l'état de rendu dans une application React sans utiliser setState ou des bibliothèques supplémentaires et fournir un accès général à ces données à partir de n'importe quel composant?


Les objets javascript les plus courants et certaines règles pour les organiser viennent à notre aide.


Mais vous devez d'abord apprendre à décomposer les applications en types d'entité et leurs relations.


Pour commencer, nous introduisons un objet qui stockera des données globales qui s'appliquent à l'ensemble de l'application dans son ensemble - (cela peut être les paramètres pour les styles, la localisation, la taille des fenêtres, etc.) dans un seul objet AppState et nous plaçons simplement cet objet dans un fichier séparé.


// src/stores/AppState.js export const AppState = { locale: "en", theme: "...", .... } 

Maintenant, dans n'importe quel composant, vous pouvez importer et utiliser les données de notre magasin.


 import AppState from "../stores/AppState.js" const SomeComponent = ()=> ( <div> {AppState.locale === "..." ? ... : ...} </div> ) 

Nous allons plus loin - presque chaque application a l'essence de l'utilisateur actuel (peu importe comment elle est créée ou provient du serveur, etc.), donc l'objet singleton de notre utilisateur sera également dans l'état de l'application. Il peut également être déplacé vers un fichier distinct et également importé, ou il peut être stocké immédiatement dans l'objet AppState. Et maintenant, l'essentiel - vous devez déterminer le diagramme des entités qui composent l'application. En termes de base de données, il s'agira de tables avec des relations un-à-plusieurs ou plusieurs-à-plusieurs, et toute cette chaîne de relations part de l'essence principale de l'utilisateur. Eh bien, dans notre cas, l'objet de l'utilisateur stockera simplement un tableau d'autres objets-entités-magasins, où chaque objet-magasin, à son tour, stockera des tableaux d'autres entités-magasins.


Voici un exemple - il y a une logique métier qui s'exprime comme "l'utilisateur peut créer / éditer / supprimer des dossiers, des projets dans chaque dossier, dans chaque projet de tâche et dans chaque tâche de sous-tâche" (il s'avère quelque chose comme un gestionnaire de tâches) et va regarder dans le diagramme d'état quelque chose comme ça:


 export const AppStore = { locale: "en", theme: "...", currentUser: { name: "...", email: "" folders: [ { name: "folder1", projects: [ { name: "project1", tasks: [ { text: "task1", subtasks: [ {text: "subtask1"}, .... ] }, .... ] }, ..... ] }, ..... ] } } 

Maintenant, le composant racine de l'application peut simplement importer cet objet et afficher des informations sur l'utilisateur, puis il peut transférer l'objet utilisateur vers le composant de tableau de bord


  .... <Dashboard user={appState.user}/> .... 

et il peut rendre la liste des dossiers


  ... <div>{user.folders.map(folder=><Folder folder={folder}/>)}</div> ... 

et chaque composant du dossier affichera une liste de projets


  .... <div>{folder.projects.map(project=><Project project={project}/>)}</div> .... 

et chaque composant du projet peut répertorier les tâches


  .... <div>{project.tasks.map(task=><Task task={task}/>)}</div> .... 

et enfin, chaque composant de tâche peut afficher une liste de sous-tâches en passant l'objet souhaité au composant de sous-tâche


  .... <div>{task.subtask.map(subtask=><Subtask subtask={subtask}/>)}</div> .... 

Naturellement, sur une seule page, personne n'affichera toutes les tâches de tous les projets de tous les dossiers, ils seront divisés par des panneaux latéraux (par exemple, une liste de dossiers), par pages, etc. mais la structure générale est à peu près la même - le composant parent rend le composant intégré passant un objet avec des accessoires les données. Un point important doit être noté - tout objet (par exemple, un objet d'un dossier, d'un projet, d'une tâche) n'est pas stocké dans l'état d'un composant - le composant le reçoit simplement via des accessoires dans le cadre d'un objet plus général. Et par exemple, lorsque le composant de projet transmet l'objet de tâche ( <div>{project.tasks.map(task=><Task task={task}/>)}</div> ) au composant enfant de Task, car les objets sont stockés dans un seul objet vous pouvez toujours modifier cet objet de tâche de l'extérieur - par exemple, AppState.currentUser.folders [2] .projects [3] .tasks [4] .text = "édité la tâche", puis provoquer la mise à jour du composant racine (ReactDOM.render (<App /> ) et nous obtenons ainsi l'état actuel de l'application.


Supposons en outre que nous voulons créer une nouvelle sous-tâche en cliquant sur le bouton "+" dans le composant Tâche. Tout est simple


  onClick = ()=>{ this.props.task.subtasks.push({text: ""}); updateDOM() } 

puisque le composant Task reçoit en tant qu'accessoires l'objet de tâche et cet objet n'est pas stocké dans son état mais fait partie du magasin AppState global (c'est-à-dire que l'objet de tâche est stocké à l'intérieur du tableau des tâches de l'objet de projet plus général, et qui à son tour fait partie de l'objet utilisateur et l'utilisateur est déjà stocké à l'intérieur de l'AppState ) et grâce à cette connectivité, après avoir ajouté un nouvel objet de tâche au tableau de sous-tâches, vous pouvez appeler la mise à jour du composant racine et ainsi mettre à jour et mettre à jour la maison pour toutes les modifications de données (peu importe où elles se sont produites) simplement en appelant la fonction upd ateDOM, qui à son tour met simplement à jour le composant racine.


 export function updateDOM(){ ReactDom.render(<App/>, rootElement); } 

Et peu importe les données de quelles parties d'AppState et de quels endroits nous changeons (par exemple, vous pouvez transférer un objet de dossier via des accessoires via des composants intermédiaires de projet et de tâche vers le composant de sous-tâche, et il peut simplement mettre à jour le nom du dossier (this.props.folder.name = "nouveau nom) ") - en raison du fait que les composants reçoivent des données via des accessoires, la mise à jour du composant racine mettra à jour tous les composants imbriqués et mettra à jour l'application entière.


Essayons maintenant d'ajouter un peu de commodité au travail avec le côté. Dans l'exemple ci-dessus, vous pouvez remarquer que la création d'un nouvel objet entité à chaque fois (par exemple project.tasks.push({text: "", subtasks: [], ...}) si l'objet possède de nombreuses propriétés avec des paramètres par défaut, puis à chaque fois de les lister et vous pouvez faire une erreur et oublier quelque chose, etc. La première chose qui vient à l'esprit est de mettre la création d'un objet dans une fonction où les champs par défaut seront assignés et en même temps les redéfinir avec de nouvelles données


 function createTask(data){ return { text: "", subtasks: [], ... //many default fields ...data } } 

mais si vous regardez de l'autre côté, cette fonction est le constructeur d'une certaine entité et les classes javascript sont parfaites pour ce rôle


 class Task { text: ""; subtasks: []; constructor(data){ Object.assign(this, data) } } 

puis la création de l'objet créera simplement une instance de la classe avec la possibilité de remplacer certains champs par défaut


 onAddTask = ()=>{ this.props.project.tasks.push(new Task({...}) } 

De plus, vous pouvez remarquer que de la même manière, en créant des classes pour les objets du projet, les utilisateurs, les sous-tâches, nous obtenons la duplication de code à l'intérieur du constructeur


 constructor(){ Object.assign(this,data) } 

mais nous pouvons profiter de l'héritage et extraire ce code dans le constructeur de la classe de base.


 class BaseStore { constructor(data){ Object.update(this, data); } } 

De plus, vous remarquerez que chaque fois que nous mettons à jour un état, nous modifions manuellement les champs de l'objet


 user.firstName = "..."; user.lastName = "..."; updateDOM(); 

et il devient difficile de suivre, de négocier et de comprendre ce qui se passe dans le composant et il est donc nécessaire de déterminer un canal commun par lequel les mises à jour de toutes les données passeront, puis nous pourrons ajouter la journalisation et toutes sortes d'autres commodités. Pour ce faire, la solution consiste à créer une méthode de mise à jour dans la classe qui prendra un objet temporaire avec de nouvelles données et se mettra à jour et établira la règle selon laquelle les objets peuvent être mis à jour uniquement via la méthode de mise à jour et non par affectation directe


 class Task { update(newData){ console.log("before update", this); Object.assign(this, data); console.log("after update", this); } } //// user.update({firstName: "...", lastName: "..."}) 

Eh bien, afin de ne pas dupliquer le code dans chaque classe, nous déplaçons également cette méthode de mise à jour vers la classe de base.


Vous pouvez maintenant voir que lorsque nous mettons à jour certaines données, nous devons appeler manuellement la méthode updateDOM (). Mais pour des raisons de commodité, il est possible d'effectuer cette mise à jour automatiquement chaque fois qu'un appel à la méthode update ({...}) de la classe de base se produit.
Il s'avère que la classe de base ressemblera à ceci


 class BaseStore { constructor(data){ Object.update(this, data); } update(data){ Object.update(this, data); ReactDOM.render(<App/>, rootElement) } } 

Eh bien, de sorte que lors de l'appel successif de la méthode update () il n'y ait pas de mises à jour inutiles, vous pouvez retarder la mise à jour du composant au prochain cycle d'événements


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

De plus, vous pouvez progressivement augmenter les fonctionnalités de la classe de base - par exemple, afin de ne pas avoir à envoyer manuellement une demande au serveur à chaque fois, en plus de mettre à jour l'état, vous pouvez envoyer une demande à la méthode de mise à jour ({..}) en arrière-plan. Vous pouvez organiser un canal de mise à jour en direct pour les sockets Web en ajoutant un compte de chaque objet créé dans la carte de hachage globale sans modifier les composants et utiliser les données de quelque manière que ce soit.


Il reste encore beaucoup à faire, mais je veux mentionner un sujet intéressant - très souvent, passer un objet avec des données au composant nécessaire (par exemple, lorsqu'un composant de projet rend un composant de tâche -


 <div>{project.tasks.map(task=><Task task={task}/>)}</div> 

le composant même de la tâche peut nécessiter des informations qui ne sont pas stockées directement à l'intérieur de la tâche mais qui se trouvent dans l'objet parent.


Supposons que vous souhaitiez colorer toutes les tâches dans une couleur stockée dans le projet et commune à toutes les tâches. Pour ce faire, en plus des accessoires de tâche, le composant projet doit également transmettre ses accessoires de projet <Task task={task} project={this.props.project}/> . Et si vous devez soudainement colorer la tâche dans une couleur commune à toutes les tâches d'un dossier, vous devrez transférer l'objet de dossier actuel du composant Dossier vers le composant Tâche en le transmettant via le composant Projet intermédiaire.
Une dépendance fragile apparaît que le composant doit savoir ce dont ses composants imbriqués ont besoin. De plus, la possibilité d'un contexte de réaction, même si elle simplifiera le transfert à travers des composants intermédiaires, nécessitera toujours une description du fournisseur et la connaissance des données nécessaires pour les composants enfants.


Mais le principal problème est que chaque fois que vous modifiez une conception ou modifiez la liste de souhaits d'un client lorsqu'un composant a besoin de nouvelles informations, vous devrez modifier les composants supérieurs soit en transmettant des accessoires soit en créant des fournisseurs de contexte. J'aimerais que le composant reçoive par le biais d'accessoires un objet contenant des données pour accéder d'une manière ou d'une autre à l'état de notre application. Et ici, javascript est un bon ajustement (contrairement à tous les langages fonctionnels comme l'orme ou les approches immuables comme redux) - afin que les objets puissent stocker des liens circulaires entre eux. Dans ce cas, l'objet de tâche doit avoir un champ task.project avec un lien vers l'objet du projet parent dans lequel il est stocké et l'objet de projet à son tour doit avoir un lien vers l'objet dossier, etc., vers l'objet AppState racine. Ainsi, le composant, quelle que soit sa profondeur, peut toujours passer par les objets parents via le lien et obtenir toutes les informations nécessaires et n'a pas besoin de le jeter à travers un tas de composants intermédiaires. Par conséquent, nous introduisons une règle - chaque fois que vous créez un objet, vous devez ajouter un lien vers l'objet parent. Par exemple, maintenant la création d'une nouvelle tâche ressemblera à ceci


  ... const {project} = this.props; const newTask = new Task({project: this.props.project}) this.props.project.tasks.push(newTask); 

De plus, avec une augmentation de la logique métier, vous pouvez remarquer que le bolterplate est associé à la prise en charge de backlink (par exemple, attribuer un lien à l'objet parent lors de la création d'un nouvel objet ou par exemple, lors du transfert d'un projet d'un dossier à un autre, vous aurez besoin non seulement de mettre à jour la propriété project.folder = newFolder et de supprimer vous-même à partir du tableau de projets du dossier précédent et en ajoutant un nouveau dossier au tableau de projets) commence à se répéter et il peut également être déplacé vers la classe de base de sorte que lorsque vous créez l'objet, il suffisait de spécifier le parent - new Task({project: this.porps.project}) new Task({project: this.porps.project}) et la classe de base ajouterait automatiquement un nouvel objet au tableau project.tasks et également lors du transfert de la tâche vers un autre projet, il suffirait de mettre à jour le task.update({project: newProject}) et la classe de base supprimerait automatiquement la tâche de un tableau de tâches du projet précédent et ajouté à un nouveau. Mais cela nécessitera déjà la déclaration de relations (par exemple, dans les propriétés ou méthodes statiques) pour que la classe de base sache quels champs mettre à jour.


Conclusion


D'une manière si simple, en utilisant uniquement des objets js, nous sommes arrivés à la conclusion que vous pouvez obtenir toute la commodité de travailler avec l'état général de l'application sans introduire dans l'application la dépendance d'une bibliothèque externe pour travailler avec l'état.


La question est, alors pourquoi avons-nous besoin de bibliothèques pour gérer l'état et, en particulier, mobx?


Le fait est que dans l'approche décrite de l'organisation de l'état général, lors de l'utilisation d'objets js «vanille» natifs ordinaires (ou objets de classe), il y a un gros inconvénient - lorsqu'une petite partie de l'état ou même un champ change, les composants seront mis à jour ou «rendus» qui ne sont en aucun cas connectés. et ne dépendent pas de cette partie de l'État.
Et sur les grandes applications avec une interface utilisateur en gras, cela entraînera des freins car la réaction n'a tout simplement pas le temps de comparer récursivement la maison virtuelle de l'application entière, étant donné qu'en plus de comparer chaque moteur de rendu, une nouvelle arborescence d'objets sera générée à chaque fois décrivant la disposition de tous les composants.


Mais ce problème, malgré son importance, est purement technique - il existe des bibliothèques similaires à la réaction vitale dom qui optimisent mieux le rendu et peuvent augmenter la limite des composants.


Il existe des techniques de rénovation domiciliaire plus efficaces que la création d'un nouvel arbre de maison virtuelle et la passe de comparaison récursive suivante avec l'arbre précédent.


Et enfin, il existe des bibliothèques qui tentent de résoudre le problème des mises à jour lentes par une approche différente - à savoir, pour suivre quelles parties de l'état sont connectées à quels composants et lors du changement de certaines données, calculer et mettre à jour uniquement les composants qui dépendent de ces données et ne pas toucher les composants restants. Redux est également une telle bibliothèque, mais elle nécessite une approche complètement différente de l'organisation de l'État. Mais la bibliothèque mobx, au contraire, n'apporte rien de nouveau et nous pouvons obtenir l'accélération du rendu pratiquement sans rien changer dans l'application - il suffit d'ajouter le décorateur @observable aux champs de la classe et le décorateur @observable est @observable aux composants qui rendent ces champs et il reste pour supprimer uniquement le code de mise à jour inutile pour le composant racine dans la méthode update () de notre classe de base et nous obtiendrons une application pleinement fonctionnelle, mais maintenant changer une partie de l'état ou même un champ ne mettra à jour que ces composants qui a mûri signé (tournant à l'intérieur de la méthode render ()) pour un domaine particulier d'un état particulier de l'objet.

Source: https://habr.com/ru/post/fr416361/


All Articles