React + Mobx: à quoi ça sert?

Aujourd'hui, je veux vous parler de la façon dont la transition vers Mobx s'est déroulée sur notre projet, quels avantages il offre. Un projet type sera également présenté et des explications sur les principaux problèmes seront données. Mais d'abord, introductif.

image

Pourquoi avez-vous besoin de passer à quelque chose? En fait, la réponse à cette question est déjà la moitié de la bataille. Beaucoup aiment désormais appliquer les nouvelles technologies uniquement parce qu'elles sont nouvelles. Une bonne ligne dans le CV, la possibilité de s'épanouir, pour être dans la tendance. C'est génial quand vous pouvez simplement avancer.

Et pourtant, chaque outil doit résoudre ses propres problèmes, et nous les repoussons d'une manière ou d'une autre lorsque nous écrivons du code commercial.

Dans notre projet, il existe un certain nombre de widgets où l'utilisateur entre ses données, interagit avec les formulaires. En règle générale, chaque widget dispose de plusieurs écrans. Il était une fois, tout fonctionnait sur le bon vieux moteur de template MarkoJS + requis jQuery sur le client. L'interaction avec les formulaires a été écrite dans un style impératif, si ... sinon, des rappels et c'est tout de même ce qui semble déjà être dans le passé.

Puis vint le moment de React . La logique métier du client devenait de plus en plus épaisse, il y avait beaucoup d'options d'interaction, le code impératif s'est transformé en un gâchis compliqué. Le code de réaction déclarative s'est avéré beaucoup plus pratique. Il a finalement été possible de se concentrer sur la logique plutôt que sur la présentation, de réutiliser les composants et de répartir facilement les tâches de développement de nouvelles fonctionnalités entre les différents employés.

Mais l'application sur Pure React au fil du temps repose sur des limites tangibles. Bien sûr, nous nous ennuyons d'écrire this.setState pour tout le monde et de penser à son asynchronie, mais lancer des données et des rappels à travers l'épaisseur des composants rend la tâche particulièrement difficile. En bref, le moment est venu de séparer complètement les données et la présentation. Il ne s'agit pas de savoir comment vous pouvez le faire sur Pure React, mais dans les cadres industriels qui implémentent l'architecture Flux des applications frontales ont été populaires récemment.

Selon le nombre d'articles et de références dans les postes vacants, Redux est le plus célèbre d'entre nous. En fait, j'ai déjà apporté ma main pour l'installer dans notre projet et commencer le développement, comme au tout dernier moment (et c'est littéralement!) Le diable a traversé le Habr, puis il y a eu juste une discussion sur le sujet "Redux ou Mobx?" Voici cet article: habr.com/en/post/459706 . Après l'avoir lu, ainsi que tous les commentaires en dessous, j'ai réalisé que j'utiliserais toujours Mobx .

Encore une fois. La réponse à la question la plus importante - pourquoi tout cela? - cela ressemble à ceci: il est temps de séparer la présentation et les données, je voudrais construire la gestion des données dans un style déclaratif (comme le dessin), pas de pollinisation croisée des rappels et des attributs transmis.

Nous sommes maintenant prêts à continuer.

1. À propos de l'application


Nous devons construire à l'avant un concepteur d'écrans et de formulaires, qui pourraient ensuite être rapidement mélangés, connectés les uns aux autres en fonction des exigences changeantes de l'entreprise. Cela nous amène inévitablement à ce qui suit: créer une collection de composants complètement isolés, ainsi que certains composants de base qui correspondent à chacun de nos widgets (en fait, ce sont des SPA distincts créés à chaque fois dans le cadre d'une nouvelle analyse de rentabilité dans l'application générale).

Les exemples montreront une version tronquée de l'un de ces gadgets. Afin de ne pas empiler de code supplémentaire, que ce soit une forme de trois champs de saisie et boutons.

2. Données


Mobx n'est pas essentiellement un framework, c'est juste une bibliothèque. Le manuel déclare explicitement qu'il n'organise pas directement vos données. Vous devez vous-même créer une telle organisation. Soit dit en passant, nous utilisons Mobx 4 car la version 5 utilise le type de données Sybmol, qui, malheureusement, n'est pas pris en charge par tous les navigateurs.

Ainsi, toutes les données sont allouées à des entités distinctes. Notre application vise un ensemble de deux dossiers:
- composants où nous mettons toute la vue
- les magasins , qui contiendront des données, ainsi que la logique de travailler avec eux.
Par exemple, un composant de saisie de données typique pour nous se compose de deux fichiers: Input.js et InputStore.js . Le premier fichier est un stupide composant React qui est strictement responsable de l'affichage, le second est les données de ce composant, les règles utilisateur ( onClick , onChange , etc ...)

Avant de passer directement aux exemples, nous devons résoudre un autre problème important.

3. Gestion


Eh bien, nous avons des composants complètement autonomes du View-Store, mais comment pouvons-nous nous réunir dans une application entière? Pour l'affichage, nous aurons le composant racine d' App.js , et pour la gestion des flux de données, le stockage principal est mainStore.js . Le principe est simple: mainStore sait tout sur tous les référentiels de tous les composants nécessaires (il sera montré ci-dessous comment cela est réalisé). D'autres référentiels ne connaissent rien du monde (enfin, il y aura une exception - les dictionnaires). Ainsi, nous avons la garantie de savoir où vont nos données et où les intercepter.

image

mainStore de manière déclarative, en modifiant des parties de son état, peut contrôler le reste des composants. Dans la figure suivante, Actions et État font référence aux magasins de composants et les valeurs calculées font référence à mainStore :

image

Commençons à écrire le code. Le fichier d'application principal index.js:

import React from "react"; import ReactDOM from "react-dom"; import {Provider} from "mobx-react"; import App from "./components/App"; import mainStore from "./stores/mainStore"; import optionsStore from "./stores/optionsStore"; //  IE11 require("es6-object-assign").polyfill(); require( "./static/less/main.less"); const stores = { mainStore, optionsStore, ButtonStore : mainStore.ButtonStore, FioStore : mainStore.FioStore, EmailStore : mainStore.EmailStore }; ReactDOM.render(( <Provider {...stores}> <App /> </Provider> ), document.getElementById('reactContainer')); 

Ici, vous pouvez voir le concept de base de Mobx. Les données (magasins) sont disponibles n'importe où dans l'application via le mécanisme du fournisseur . Nous emballons notre application en répertoriant les installations de stockage nécessaires. Pour utiliser le fournisseur, connectez le module mobx-react . Pour que le magasin principal de contrôle mainStore ait accès à toutes les autres données depuis le début, nous initialisons les magasins enfants dans mainStore :

 // mainStore.js import optionsStore from "./optionsStore"; import ButtonStore from "./ButtonStore"; import FioStore from "./FioStore"; import EmailStore from "./EmailStore"; .... class mainStore { constructor() { /** *    */ this.ButtonStore = new ButtonStore(); this.FioStore = new FioStore(); this.EmailStore = new EmailStore(); ... 


Maintenant App.js , le squelette de notre application

 import React from "react"; import {observer, inject} from "mobx-react"; import ButtonArea from "./ButtonArea"; import Email from "./Email"; import Fio from "./Fio"; import l10n from "../../../l10n/localization.js"; @inject("mainStore") @observer export default class App extends React.Component { constructor(props) { super(props); }; render() { const mainStore = this.props.mainStore; return ( <div className="container"> <Fio label={l10n.ru.profile.name} name={"name"} value={mainStore.userData.name} daData={true} /> <Fio label={l10n.ru.profile.surname} name={"surname"} value={mainStore.userData.surname} daData={true} /> <Email label={l10n.ru.profile.email} name={"email"} value={mainStore.userData.email} /> <ButtonArea /> </div> ); } } 

Il existe deux autres concepts de base de Mobx: l' injection et l' observateur .
inject n'implémente que le magasin nécessaire dans l'application. Différentes parties de notre application utilisent différents référentiels, que nous listons en inject , séparés par des virgules. Naturellement, les magasins enfichables doivent être initialement répertoriés dans le fournisseur . Les référentiels sont disponibles dans le composant via this.props.yourStoreName .
observateur - le décorateur indique que notre composant sera abonné à des données modifiées à l'aide de Mobx. Les données ont changé - une réaction s'est produite dans le composant (elle sera montrée ci-dessous). Ainsi, aucun abonnement ni rappel spéciaux - Mobx fournit les modifications lui-même!

Nous reviendrons sur la gestion de l’ensemble de l’application dans mainStore , mais pour le moment nous allons faire les composants. Nous en avons trois types - Fio , Email , Button . Que le premier et le troisième soient universels et que la messagerie soit personnalisée. Commençons par lui.

L'écran est le composant muet habituel de React:

Email.js
 import React from "react"; import {inject, observer} from 'mobx-react'; @inject("EmailStore") @observer export default class Email extends React.Component { constructor(props) { super(props); }; componentDidMount = () => { this.props.EmailStore.validate(this.props.name); }; componentWillUnmount = () => { this.props.EmailStore.unmount(this.props.name); }; render() { const name = this.props.name; const EmailStore = this.props.EmailStore; const params = EmailStore.params; let status = "form-group email "; if (params.isCorrect && params.onceValidated) status += "valid"; if (params.isWrong && params.onceValidated) status += "error"; return ( <div className={status}> <label htmlFor={name}>{this.props.label}</label> <input type="email" disabled={this.props.disabled} name={name} id={name} value={params.value} onChange={(e) => EmailStore.bindData(e, name)} /> </div> ); } } 

Nous connectons le composant externe de validation, et il est important de le faire une fois que l'élément est déjà inclus dans la mise en page. Par conséquent, la méthode du magasin est appelée dans componentDidMount .

Maintenant, le référentiel lui-même:

EmailStore.js
 import {action, observable} from 'mobx'; import reactTriggerChange from "react-trigger-change"; import Validators from "../../../helpers/Validators"; import { getTarget } from "../../../helpers/elementaries"; export default class EmailStore { @observable params = { value : "", disabled : null, isCorrect : null, isWrong : null, onceValidated : null, prevalidated : null } /** *    */ @action bindData = (e, name) => { this.params.value = getTarget(e).value; }; /** *   */ @action validate = (name) => { const callbacks = { success : (formatedValue) => { this.params.value = formatedValue; this.params.isCorrect = true; this.params.isWrong = false; this.params.onceValidated = true; }, fail : (formatedValue) => { this.params.value = formatedValue; this.params.isCorrect = false; this.params.isWrong = true; } }; const options = { type : "email" }; const element = document.getElementById(name); new Validators(element, options, callbacks).init(); //    reactTriggerChange(element); this.params.prevalidated = true; }; } 


Il convient de prêter attention à deux nouvelles entités.

observable - un objet dont tout changement de champs est surveillé par Mobx (et envoie des signaux à l' observateur , qui est abonné à notre stockage spécifique).
action - ce décorateur doit envelopper tout gestionnaire qui modifie l'état de l'application et / ou provoque des effets secondaires. Ici, nous modifions la valeur de la valeur dans les paramètres @observable .

Voilà, notre composant simple est prêt! Il peut suivre les données des utilisateurs et les enregistrer. Plus tard, nous verrons comment le référentiel central mainStore s'abonne pour modifier ces données.

Maintenant un composant Fio typique. Sa différence avec le précédent est que nous allons utiliser des composants de ce type un nombre illimité de fois dans une seule application. Cela impose des exigences supplémentaires au magasin de composants. En plus de cela, nous ferons plus de conseils sur les caractères d'entrée en utilisant l'excellent service DaData . Affichage:

Fio.js
 import React from "react"; import {inject, observer} from 'mobx-react'; import {get} from 'mobx'; @inject("FioStore") @observer export default class Fio extends React.Component { constructor(props) { super(props); }; componentDidMount = () => { /** *       */ this.props.FioStore.registration(this.props); }; componentWillUnmount = () => { this.props.FioStore.unmount(this.props.name); }; render() { /** *       *  DaData: * data.surname -  * data.name -  * https://dadata.ru/api/suggest/name */ const FioStore = this.props.FioStore; const name = this.props.name; const item = get(FioStore.items, name); if (item && item.isCorrect && item.onceValidated && !item.prevalidated) status = "valid"; if (item && item.isWrong && item.onceValidated) status = "error"; //    store let value = this.props.value; if (item) value = item.value; return ( <div className="form-group fio"> <label htlmfor={name}>{this.props.label}</label> <input type="text" disabled={this.props.disabled} name={name} id={name} value={value} onChange={(e) => FioStore.bindData(e, name)} /> {(item && item.suggestions && item.suggestions.length > 0) && <div className="hint-container" id={"hint-container-" + item.id}>{item.suggestions.map((suggestion, i) => { return ( <div className={"suggestion-item fs-" + i} key={i} value={suggestion.data[name]} onClick={(e) => FioStore.setSuggestion(e, name)}> <span className="suggestion-text">{suggestion.data[name]}</span> </div>) })}</div>} </div> ); } } 

Il y a quelque chose de nouveau ici: nous n'accédons pas directement à l'état du composant, mais via get :
 get(FioStore.items, name) 

Le fait est que le nombre d'instances de composants est illimité et que le référentiel est un pour tous les composants de ce type. Par conséquent, lors de l'enregistrement, nous entrons les paramètres de chaque instance dans Map :

Fiostore.js
 import {action, autorun, observable, get, set} from 'mobx'; import reactTriggerChange from "react-trigger-change"; import Validators from "../../../helpers/Validators"; import { getDaData, blockValidate } from "../../../helpers/functions"; import { getAttrValue, scrollToElement, getTarget } from "../../../helpers/elementaries"; export default class FioStore { constructor() { autorun(() => { /** *        .         setSuggestion() */ const self = this; $("body").click((e) => { if (e.target.className !== "suggestion-item" && e.target.className !== "suggestion-text") { const items = self.items.entries(); for (var [key, value] of items) { value.suggestions = []; } } }); }) } /** *   items       Fio    */ @observable items = new Map([]); /** *    */ @action registration = (params) => { const nameExists = get(this.items, params.name); if (!blockValidate({params, nameExists, type: "Fio"})) return false; //  items   const value = { value : params.value, disabled : params.disabled, isCorrect : null, isWrong : null, suggestions : [], daData : params.daData, startValidation : true, //      onceValidated : false, //      prevalidated : false }; set(this.items, params.name, value); this.validate(params.name); }; /** *    */ @action unmount = (name) => { this.items.delete(name); }; /** *    *    */ @action bindData = (e, name) => { const value = getTarget(e).value; const item = get(this.items, name); /** *     DaData */ if (item.daData && !item.startValidation) { getDaData({value, type: "fio", name}) .then((result) => { item.suggestions = result.suggestions; }) .catch((error) => {console.log(error)}) } else { item.startValidation = false; item.value = value; } }; /** *   */ @action setSuggestion = (e, name) => { if (e) e.preventDefault(); get(this.items, name).value = getAttrValue(e); //     get(this.items, name).suggestions = []; get(this.items, name).isCorrect = true; get(this.items, name).isWrong = false; }; /** *   */ @action validate = (name) => { const callbacks = { success : (formatedValue) => { get(this.items, name).value = formatedValue; get(this.items, name).isCorrect = true; get(this.items, name).isWrong = false; get(this.items, name).onceValidated = true; }, fail : (formatedValue) => { get(this.items, name).value = formatedValue; get(this.items, name).isCorrect = false; get(this.items, name).isWrong = true; } }; const options = { type : "fio" }; const element = document.getElementById(name); new Validators(element, options, callbacks).init(); //    reactTriggerChange(element); get(this.items, name).prevalidated = true; }; } 

L'état de notre composant générique est initialisé comme suit:

 @observable items = new Map([]); 

Il serait plus pratique de travailler avec un objet JS standard, cependant, il ne sera pas "exploité" lors de la modification des valeurs de ses champs, car les champs sont ajoutés dynamiquement lorsque de nouveaux composants sont ajoutés à la page. La réception des conseils DaData que nous supprimons séparément.

Le composant bouton est similaire, mais il n'y a pas de conseils:

Button.js
 import React from "react"; import {inject, observer} from 'mobx-react'; @inject("ButtonStore") @observer export default class CustomButton extends React.Component { constructor(props) { super(props); }; componentDidMount = () => { /** *       */ this.props.ButtonStore.registration(this.props); }; componentWillUnmount = () => { this.props.ButtonStore.unmount(this.props.name); }; render() { const name = this.props.name; return ( <div className="form-group button"> <button disabled={this.props.disabled} onClick={(e) => this.props.ButtonStore.bindClick(e, name)} name={name} id={name} >{this.props.text}</button> </div> ); } } 


ButtonStore.js
 import {action, observable, get, set} from 'mobx'; import {blockValidate} from "../../../helpers/functions"; export default class ButtonStore { constructor() {} /** *   items       Button    */ @observable items = new Map([]) /** *    */ @action registration = (params) => { const nameExists = get(this.items, params.name); if (!blockValidate({params, nameExists, type: "Button"})) return false; //  items   const value = { disabled : params.disabled, isClicked : false }; set(this.items, params.name, value); }; /** *    */ @action unmount = (name) => { this.items.delete(name); }; /** *    */ @action bindClick = (e, name) => { e.preventDefault(); get(this.items, name).isClicked = true; }; } 


Le composant du Button est encapsulé par le composant HOC de ButtonArea . Veuillez noter que l'ancien composant comprend son propre ensemble de magasins et le plus jeune. Dans les chaînes de composants imbriqués, il n'est pas nécessaire de transmettre de paramètres et de rappels. Tout ce qui est nécessaire au fonctionnement d'un composant particulier y est ajouté directement.

ButtonArea.js
 import React from "react"; import {inject, observer} from 'mobx-react'; import l10n from "../../../l10n/localization.js"; import Button from "./Button"; @inject("mainStore", "optionsStore") @observer export default class ButtonArea extends React.Component { constructor(props) { super(props); }; render() { return ( <div className="button-container"> <p>{this.props.optionsStore.dict.buttonsHeading}</p> <Button name={"send_data"} disabled={this.props.mainStore.buttons.sendData.disabled ? true : false} text={l10n.ru.common.continue} /> </div> ); } } 


Nous avons donc tous les composants prêts. L'affaire est laissée au directeur de mainStore. Tout d'abord, tout le code de stockage:

mainStore.js
 import {observable, computed, autorun, reaction, get, action} from 'mobx'; import optionsStore from "./optionsStore"; import ButtonStore from "./ButtonStore"; import FioStore from "./FioStore"; import EmailStore from "./EmailStore"; import { fetchOrdinary, sendStats } from "../../../helpers/functions"; import l10n from "../../../l10n/localization.js"; class mainStore { constructor() { /** *    */ this.ButtonStore = new ButtonStore(); this.FioStore = new FioStore(); this.EmailStore = new EmailStore(); autorun(() => { this.fillBlocks(); this.fillData(); }); /** *      */ reaction( () => this.dataInput, (result) => { let isIncorrect = false; for (let i in result) { for (let j in result[i]) { const res = result[i][j]; if (!res.isCorrect) isIncorrect = true; this.userData[j] = res.value; } }; if (!isIncorrect) { this.buttons.sendData.disabled = false } else { this.buttons.sendData.disabled = true }; } ); /** *     */ reaction( () => this.sendDataButton, (result) => { if (result) { if (result.isClicked) { get(this.ButtonStore.items, "send_data").isClicked = false; const authRequestSuccess = () => { console.log("request is success!") }; const authRequestFail = () => { console.log("request is fail!") }; const request = { method : "send_userdata", params : { name : this.userData.name, surname : this.userData.surname, email : this.userData.email } }; console.log("Request body is:"); console.log(request); fetchOrdinary( optionsStore.OPTIONS.sendIdentUrl, JSON.stringify(request), { success: authRequestSuccess, fail: authRequestFail } ); } } } ); } @observable userData = { name : "", surname : "", email : "" }; @observable buttons = { sendData : { disabled : true } }; /** *     * @key -   * @value -      (name, type),          */ componentsMap = { userData : [ ["name", "fio"], ["surname", "fio"], ["email", "email"], ["send_data", "button"] ] }; /** *     listener'  stores */ @observable listenerBlocks = {}; /** *    */ @action fillBlocks = () => { for (let i in this.componentsMap) { const pageBlock = this.componentsMap[i]; //      (key)     (value) const blocks = {}; pageBlock.forEach((item, i) => { const _name = item[0]; const _type = item[1]; if (!blocks[_type]) { blocks[_type] = [_name] } else { blocks[_type].push(_name) } }) this.listenerBlocks[i] = blocks; } }; /** *    */ @action fillData = () => { if (optionsStore.preset) { // ,   undefined,     if (optionsStore.preset.name) this.userData.name = optionsStore.preset.name; if (optionsStore.preset.surname) this.userData.surname = optionsStore.preset.surname; } }; /** * Listener    */ @computed get dataInput() { const mappedResult = { fio : {}, email : { email : {} } }; if (this.FioStore && this.FioStore.items) { this.listenerBlocks.userData.fio.forEach((item) => { const i = get(this.FioStore.items, item); if (i) { mappedResult.fio[item] = { isCorrect : i.isCorrect, value : i.value } } }) } if (this.EmailStore && this.EmailStore.params) { mappedResult.email.email = { isCorrect : this.EmailStore.params.isCorrect, prevalidated : this.EmailStore.params.prevalidated, value : this.EmailStore.params.value } } return mappedResult } /** * Listener     */ @computed get sendDataButton() { let result = {}; const i = get(this.ButtonStore.items, "send_data"); if (i) { result = { isClicked : i.isClicked } } return result } } export default new mainStore(); 


Quelques autres entités clés.

calculé est un décorateur pour les fonctions qui suivent les changements dans notre observable . Un avantage important de Mobx est qu'il ne suit que les données qui sont calculées puis renvoyées en conséquence. La réaction et, par conséquent, le redessin du DOM viral se produit uniquement lorsque cela est nécessaire.
réaction - un outil pour organiser les effets secondaires en fonction d'un état modifié. Il prend deux fonctions: la première calculée, renvoyant l'état calculé, la seconde avec des effets qui devraient suivre les changements d'état. Dans notre exemple, la réaction est appliquée deux fois. Dans le premier, nous examinons l'état des champs et concluons si le formulaire entier est correct, et enregistrons également la valeur de chaque champ. Dans le second, on clique sur un bouton (plus précisément, s'il y a un signe "bouton enfoncé"), on envoie des données au serveur. L'objet de données s'affiche dans la console du navigateur. Puisque mainStore connaît tous les référentiels, immédiatement après avoir traité le clic d'un bouton, nous pouvons nous permettre de désactiver le drapeau dans un style impératif:

 get(this.ButtonStore.items, "send_data").isClicked = false; 

Vous pouvez discuter de l'acceptabilité de la présence d'un tel «impératif», mais dans tous les cas, le contrôle n'a lieu que dans une seule direction - de mainStore à ButtonStore .
l'exécution automatique est utilisée lorsque nous voulons exécuter certaines actions directement, pas en réaction à la sauvegarde des modifications. Dans notre exemple, une fonction auxiliaire est lancée, ainsi que le préremplissage des champs du formulaire avec les données du dictionnaire.

Ainsi, la séquence d'actions que nous avons est la suivante. Les composants suivent les événements utilisateur et changent leur état. mainStore à travers calculé calcule le résultat basé uniquement sur les états qui ont changé. Différents calculés recherchent des changements dans différents états dans différents référentiels. De plus, par réaction, sur la base des résultats calculés , nous effectuons des actions avec des observables , ainsi que des effets secondaires (par exemple, nous faisons une demande AJAX). Obsevables s'abonne aux composants enfants, qui sont redessinés si nécessaire. Un flux de données unidirectionnel avec un contrôle total sur où et ce qui change.

Vous pouvez essayer l' exemple et le code vous-même. Lien vers le référentiel: github.com/botyaslonim/mobx-habr .
Puis comme d'habitude: npm i , npm run local . Dans le dossier public , fichier index.html . Les indices DaData fonctionnent sur mon compte gratuit, par conséquent, ils peuvent probablement tomber à certains moments en raison de l'effet habr.

Je serai heureux de tout commentaire constructif et suggestions sur le travail des applications sur Mobx!

En conclusion, je dirai que la bibliothèque a grandement simplifié le travail avec les données. Pour les petites et moyennes applications, ce sera certainement un outil très pratique pour oublier les propriétés des composants et des rappels et se concentrer directement sur la logique métier.

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


All Articles