Injection de dépendance hiérarchique dans React et MobX State Tree en tant que modèle de domaine

Après quelques projets sur React, j'ai eu la chance de travailler sur une application sous Angular 2. Franchement, je n'ai pas été impressionné. Mais une chose a été retenue: la gestion de la logique et de l'état de l'application à l'aide de l'injection de dépendance. Et je me suis demandé s'il était pratique de gérer l'état dans React en utilisant DDD, une architecture en couches et une injection de dépendances?


Si vous êtes intéressé par la façon de procéder, et surtout, pourquoi - bienvenue dans la coupe!


Pour être honnête, même sur le backend, DI est rarement utilisé à son maximum. Sauf dans les très grandes applications. Et dans les petites et moyennes, même avec une DI, chaque interface n'a généralement qu'une seule implémentation. Mais l'injection de dépendance a toujours ses avantages:


  • Le code est mieux structuré et les interfaces agissent comme des contrats explicites.
  • La création de stubs dans les tests unitaires est simplifiée.

Mais les bibliothèques de test modernes pour JS, telles que Jest , vous permettent d'écrire moki simplement sur la base du système modulaire ES6. Donc, ici, nous ne tirerons pas beaucoup de profit de DI.


Le deuxième point demeure - la gestion de la portée et de la durée de vie des objets. Sur le serveur, la durée de vie est généralement liée à l'ensemble de l'application (Singleton) ou à la demande. Et sur le client, l'unité de code principale est le composant. Nous y serons attachés.


Si nous devons utiliser l'état au niveau de l'application, le moyen le plus simple consiste à définir la variable au niveau du module ES6 et à l'importer si nécessaire. Et si l'état n'est nécessaire qu'à l'intérieur du composant - nous le mettons simplement dans this.state . Pour tout le reste, il y a Context . Mais le Context est trop bas:


  • Nous ne pouvons pas utiliser de contexte en dehors de l'arborescence des composants React. Par exemple, dans une couche de logique métier.
  • Nous ne pouvons pas utiliser plus d'un contexte dans Class.contextType . Pour déterminer la dépendance vis-à-vis de plusieurs services différents, nous devrons construire une «pyramide d'horreur» d'une manière nouvelle:



Le nouveau crochet useContext() corrige légèrement la situation des composants fonctionnels. Mais nous ne nous débarrasserons pas des nombreux <Context.Provider> . Jusqu'à ce que nous transformions notre contexte en localisateur de service et son composant parent en racine de composition. Mais ici ce n'est pas loin de DI, alors commençons!


Vous pouvez ignorer cette partie et aller directement à la description de l'architecture.

Implémentation du mécanisme DI


Nous avons d'abord besoin d'un contexte React:


 export const InjectorContext= React.createContext(null); 

Puisque React utilise le constructeur de composants pour ses besoins, nous utiliserons Property Injection. Pour ce faire, définissez le décorateur @inject , qui:


  • définit la propriété Class.contextType ,
  • obtient le type de dépendance
  • recherche un objet Injector et résout la dépendance.

inject.js
 import "reflect-metadata"; export function inject(target, key) { //  static cotextType target.constructor.contextType = InjectorContext; //    const type = Reflect.getMetadata("design:type", target, key); //  property Object.defineProperty(target, key, { configurable: true, enumerable: true, get() { //  Injector       const instance = getInstance(getInjector(this), type); Object.defineProperty(this, key, { enumerable: true, writable: true, value: instance }); return instance; }, // settet     Dependency Injection set(instance) { Object.defineProperty(this, key, { enumerable: true, writable: true, value: instance }); } }); } 

Maintenant, nous pouvons définir des dépendances entre des classes arbitraires:


 import { inject } from "react-ioc"; class FooService {} class BarService { @inject foo: FooService; } class MyComponent extends React.Component { @inject foo: FooService; @inject bar: BarService; } 

Pour ceux qui n'acceptent pas les décorateurs, nous définissons la fonction inject() avec cette signature:


 type Constructor<T> = new (...args: any[]) => T; function inject<T>(target: Object, type: Constructor<T> | Function): T; 

inject.js
 export function inject(target, keyOrType) { if (isFunction(keyOrType)) { return getInstance(getInjector(target), keyOrType); } // ... } 

Cela vous permettra de définir explicitement les dépendances:


 class FooService {} class BarService { foo = inject(this, FooService); } class MyComponent extends React.Component { foo = inject(this, FooService); bar = inject(this, BarService); //   static contextType = InjectorContext; } 

Qu'en est-il des composants fonctionnels? Pour eux, nous pouvons implémenter Hook useInstance()


hooks.js
 import { useRef, useContext } from "react"; export function useInstance(type) { const ref = useRef(null); const injector = useContext(InjectorContext); return ref.current || (ref.current = getInstance(injector, type)); } 

 import { useInstance } from "react-ioc"; const MyComponent = props => { const foo = useInstance(FooService); const bar = useInstance(BarService); return <div />; } 

Nous allons maintenant déterminer à quoi pourrait ressembler notre Injector , comment le trouver et comment résoudre les dépendances. L'injecteur doit contenir une référence au parent, un cache d'objets pour les dépendances déjà résolues et un dictionnaire de règles pour les dépendances non encore résolues.


injector.js
 type Binding = (injector: Injector) => Object; export abstract class Injector extends React.Component { //    Injector _parent?: Injector; //    _bindingMap: Map<Function, Binding>; //      _instanceMap: Map<Function, Object>; } 

Pour les composants React, Injector est disponible via le champ this.context , et pour les classes de dépendance, nous pouvons temporairement placer Injector dans une variable globale. Pour accélérer la recherche d'un injecteur pour chaque classe, nous mettrons en cache le lien vers Injector dans un champ caché.


injector.js
 export const INJECTOR = typeof Symbol === "function" ? Symbol() : "__injector__"; let currentInjector = null; export function getInjector(target) { let injector = target[INJECTOR]; if (injector) { return injector; } injector = currentInjector || target.context; if (injector instanceof Injector) { target[INJECTOR] = injector; return injector; } return null; } 

Pour trouver une règle de liaison spécifique, nous devons remonter l'arborescence des injecteurs à l'aide de la fonction getInstance()


injector.js
 export function getInstance(injector, type) { while (injector) { let instance = injector._instanceMap.get(type); if (instance !== undefined) { return instance; } const binding = injector._bindingMap.get(type); if (binding) { const prevInjector = currentInjector; currentInjector = injector; try { instance = binding(injector); } finally { currentInjector = prevInjector; } injector._instanceMap.set(type, instance); return instance; } injector = injector._parent; } return undefined; } 

Enfin, passons à l'enregistrement des dépendances. Pour ce faire, nous avons besoin du provider() HOC provider() , qui prend un tableau de liaisons de dépendance vers leurs implémentations et enregistre un nouvel Injector via InjectorContext.Provider


provider.js
 export const provider = (...definitions) => Wrapped => { const bindingMap = new Map(); addBindings(bindingMap, definitions); return class Provider extends Injector { _parent = this.context; _bindingMap = bindingMap; _instanceMap = new Map(); render() { return ( <InjectorContext.Provider value={this}> <Wrapped {...this.props} /> </InjectorContext.Provider> ); } static contextType = InjectorContext; static register(...definitions) { addBindings(bindingMap, definitions); } }; }; 

Et aussi, un ensemble de fonctions de liaison qui implémentent diverses stratégies pour créer des instances de dépendance.


bindings.js
 export const toClass = constructor => asBinding(injector => { const instance = new constructor(); if (!instance[INJECTOR]) { instance[INJECTOR] = injector; } return instance; }); export const toFactory = (depsOrFactory, factory) => asBinding( factory ? injector => factory(...depsOrFactory.map(type => getInstance(injector, type))) : depsOrFactory ); export const toExisting = type => asBinding(injector => getInstance(injector, type)); export const toValue = value => asBinding(() => value); const IS_BINDING = typeof Symbol === "function" ? Symbol() : "__binding__"; function asBinding(binding) { binding[IS_BINDING] = true; return binding; } export function addBindings(bindingMap, definitions) { definitions.forEach(definition => { let token, binding; if (Array.isArray(definition)) { [token, binding = token] = definition; } else { token = binding = definition; } bindingMap.set(token, binding[IS_BINDING] ? binding : toClass(binding)); }); } 

Nous pouvons maintenant enregistrer des liaisons de dépendance au niveau d'un composant arbitraire sous la forme d'un ensemble de paires [<>, <>] .


 import { provider, toClass, toValue, toFactory, toExisting } from "react-ioc"; @provider( //    [FirstService, toClass(FirstServiceImpl)], //     [SecondService, toValue(new SecondServiceImpl())], //    [ThirdService, toFactory( [FirstService, SecondService], (first, second) => ThirdServiceFactory.create(first, second) )], //      [FourthService, toExisting(FirstService)] ) class MyComponent extends React.Component { // ... } 

Ou sous forme abrégée pour les classes:


 @provider( // [FirstService, toClass(FirstService)] FirstService, // [SecondService, toClass(SecondServiceImpl)] [SecondService, SecondServiceImpl] ) class MyComponent extends React.Component { // ... } 

Puisque la durée de vie d'un service est déterminée par le composant fournisseur dans lequel il est enregistré, nous pouvons définir pour chaque service une méthode de nettoyage .dispose() . Dans celui-ci, nous pouvons nous désinscrire de certains événements, fermer des sockets, etc. Lorsque vous supprimez le fournisseur du DOM, il appellera .dispose() sur tous les services qu'il crée.


provider.js
 export const provider = (...definitions) => Wrapped => { // ... return class Provider extends Injector { // ... componentWillUnmount() { this._instanceMap.forEach(instance => { if (isObject(instance) && isFunction(instance.dispose)) { instance.dispose(); } }); } // ... }; }; 

Pour séparer le code et le chargement paresseux, nous devrons peut-être inverser la méthode d'enregistrement des services auprès des fournisseurs. Le décorateur @registerIn() nous aidera avec cela.


provider.js
 export const registrationQueue = []; export const registerIn = (getProvider, binding) => constructor => { registrationQueue.push(() => { getProvider().register(binding ? [constructor, binding] : constructor); }); return constructor; }; 

injector.js
 export function getInstance(injector, type) { if (registrationQueue.length > 0) { registrationQueue.forEach(registration => { registration(); }); registrationQueue.length = 0; } while (injector) { // ... } 

 import { registerIn } from "react-ioc"; import { HomePage } from "../components/HomePage"; @registerIn(() => HomePage) class MyLazyLoadedService {} 


Ainsi, pour 150 lignes et 1 Ko de code, vous pouvez implémenter un conteneur DI hiérarchique presque complet.


Architecture d'application


Enfin, passons à l'essentiel - comment organiser l'architecture de l'application. Il y a trois options possibles, selon la taille de l'application, la complexité du sujet et notre paresse.


1. Le laid


Nous avons un DOM virtuel, ce qui signifie qu'il devrait être rapide. Au moins avec cette sauce, React a été servi à l'aube d'une carrière. Par conséquent, n'oubliez pas le lien vers le composant racine (par exemple, en utilisant le décorateur @observer ). Et nous appellerons .forceUpdate() dessus après chaque action affectant les services partagés (par exemple, en utilisant le décorateur @action )


observer.js
 export function observer(Wrapped) { return class Observer extends React.Component { componentDidMount() { observerRef = this; } componentWillUnmount() { observerRef = null; } render() { return <Wrapped {...this.props} />; } } } let observerRef = null; 

action.js
 export function action(_target, _key, descriptor) { const method = descriptor.value; descriptor.value = function() { let result; runningCount++; try { result = method.apply(this, arguments); } finally { runningCount--; } if (runningCount === 0 && observerRef) { observerRef.forceUpdate(); } return result; }; } let runningCount = 0; 

 class UserService { @action doSomething() {} } class MyComponent extends React.Component { @inject userService: UserService; } @provider(UserService) @observer class App extends React.Component {} 

Cela fonctionnera même. Mais ... vous comprenez vous-même :-)


2. Le mauvais


Nous ne sommes pas satisfaits de tout rendre pour chaque éternuement. Mais nous voulons toujours utiliser presque objets et tableaux réguliers pour stocker l'état. Prenons MobX !


Nous démarrons plusieurs stockages de données avec des actions standard:


 import { observable, action } from "mobx"; export class UserStore { byId = observable.map<number, User>(); @action add(user: User) { this.byId.set(user.id, user); } // ... } export class PostStore { // ... } 

Nous retirons la logique métier, les E / S, etc. à la couche services:


 import { action } from "mobx"; import { inject } from "react-ioc"; export class AccountService { @inject userStore userStore; @action updateUserInfo(userInfo: Partial<User>) { const user = this.userStore.byId.get(userInfo.id); Object.assign(user, userInfo); } } 

Et nous les distribuons en composants:


 import { observer } from "mobx-react"; import { provider, inject } from "react-ioc"; @provider(UserStore, PostStore) class App extends React.Component {} @provider(AccountService) @observer class AccountPage extends React.Component{} @observer class UserForm extends React.Component { @inject accountService: AccountService; } 

Il en va de même pour les composants fonctionnels et sans décorateurs
 import { action } from "mobx"; import { inject } from "react-ioc"; export class AccountService { userStore = inject(this, UserStore); updateUserInfo = action((userInfo: Partial<User>) => { const user = this.userStore.byId.get(userInfo.id); Object.assign(user, userInfo); }); } 

 import { observer } from "mobx-react-lite"; import { provider, useInstance } from "react-ioc"; const App = provider(UserStore, PostStore)(props => { // ... }); const AccountPage = provider(AccountService)(observer(props => { // ... })); const UserFrom = observer(props => { const accountService = useInstance(AccountService); // ... }); 

Le résultat est une architecture classique à trois niveaux.


3. Le bien


Parfois, le sujet devient si complexe qu'il est déjà gênant de travailler avec lui à l'aide d'objets simples (ou d'un modèle anémique en termes de DDD). Cela est particulièrement visible lorsque les données ont une structure relationnelle avec de nombreuses relations. Dans de tels cas, la bibliothèque MobX State Tree vient à la rescousse, vous permettant d'appliquer les principes de la conception pilotée par domaine dans l'architecture de l'application frontale.


La conception d'un modèle commence par une description des types:


 // models/Post.ts import { types as t, Instance } from "mobx-state-tree"; export const Post = t .model("Post", { id: t.identifier, title: t.string, body: t.string, date: t.Date, rating: t.number, author: t.reference(User), comments: t.array(t.reference(Comment)) }) .actions(self => ({ voteUp() { self.rating++; }, voteDown() { self.rating--; }, addComment(comment: Comment) { self.comments.push(comment); } })); export type Post = Instance<typeof Post>; 

models / User.ts
 import { types as t, Instance } from "mobx-state-tree"; export const User = t.model("User", { id: t.identifier, name: t.string }); export type User = Instance<typeof User>; 

modèles / Comment.ts
 import { types as t, Instance } from "mobx-state-tree"; import { User } from "./User"; export const Comment = t .model("Comment", { id: t.identifier, text: t.string, date: t.Date, rating: t.number, author: t.reference(User) }) .actions(self => ({ voteUp() { self.rating++; }, voteDown() { self.rating--; } })); export type Comment = Instance<typeof Comment>; 

Et le type de magasin de données:


 // models/index.ts import { types as t } from "mobx-state-tree"; export { User, Post, Comment }; export default t.model({ users: t.map(User), posts: t.map(Post), comments: t.map(Comment) }); 

Les types d'entité contiennent l'état du modèle de domaine et les opérations de base avec celui-ci. Des scénarios plus complexes, y compris les E / S, sont implémentés dans la couche services.


services / DataContext.ts
 import { Instance, unprotect } from "mobx-state-tree"; import Models from "../models"; export class DataContext { static create() { const models = Models.create(); unprotect(models); return models; } } export interface DataContext extends Instance<typeof Models> {} 

services / AuthService.ts
 import { observable } from "mobx"; import { User } from "../models"; export class AuthService { @observable currentUser: User; } 

services / PostService.ts
 import { inject } from "react-ioc"; import { action } from "mobx"; import { Post } from "../models"; export class PostService { @inject dataContext: DataContext; @inject authService: AuthService; async publishPost(postInfo: Partial<Post>) { const response = await fetch("/posts", { method: "POST", body: JSON.stringify(postInfo) }); const { id } = await response.json(); this.savePost(id, postInfo); } @action savePost(id: string, postInfo: Partial<Post>) { const post = Post.create({ id, rating: 0, date: new Date(), author: this.authService.currentUser.id, comments: [], ...postInfo }); this.dataContext.posts.put(post); } } 

La principale caractéristique de MobX State Tree est le travail efficace avec les instantanés de données. À tout moment, nous pouvons obtenir l'état sérialisé de toute entité, collection ou même l'état complet de l'application à l'aide de la fonction getSnapshot() . Et de la même manière, nous pouvons appliquer un instantané à n'importe quelle partie du modèle en utilisant applySnapshot() . Cela nous permet d'initialiser l'état à partir du serveur dans plusieurs lignes de code, de charger depuis LocalStorage ou même d'interagir avec lui via Redux DevTools.


Puisque nous utilisons un modèle relationnel normalisé, nous avons besoin de la bibliothèque normalizr pour charger les données. Il vous permet de traduire l'arborescence JSON en tables plates d'objets regroupés par id , selon le schéma de données. Juste au format que MobX State Tree est nécessaire comme instantané.


Pour ce faire, définissez les schémas d'objets téléchargés depuis le serveur:


 import { schema } from "normalizr"; const UserSchema = new schema.Entity("users"); const CommentSchema = new schema.Entity("comments", { author: UserSchema }); const PostSchema = new schema.Entity("posts", { //   - //      author: UserSchema, comments: [CommentSchema] }); export { UserSchema, PostSchema, CommentSchema }; 

Et chargez les données dans le stockage:


 import { inject } from "react-ioc"; import { normalize } from "normalizr"; import { applySnapshot } from "mobx-state-tree"; export class PostService { @inject dataContext: DataContext; // ... async loadPosts() { const response = await fetch("/posts.json"); const posts = await response.json(); const { entities } = normalize(posts, [PostSchema]); applySnapshot(this.dataContext, entities); } // ... } 

posts.json
 [ { "id": 123, "title": "    React", "body": "  -     React...", "date": "2018-12-10T18:18:58.512Z", "rating": 0, "author": { "id": 12, "name": "John Doe" }, "comments": [{ "id": 1234, "text": "Hmmm...", "date": "2018-12-10T18:18:58.512Z", "rating": 0, "author": { "id": 12, "name": "John Doe" } }] }, { "id": 234, "title": "Lorem ipsum", "body": "Lorem ipsum dolor sit amet...", "date": "2018-12-10T18:18:58.512Z", "rating": 0, "author": { "id": 23, "name": "Marcus Tullius Cicero" }, "comments": [] } ] 

Enfin, enregistrez les services dans les composants appropriés:


 import { observer } from "mobx-react"; import { provider, inject } from "react-ioc"; @provider(AuthService, PostService, [ DataContext, toFactory(DataContext.create) ]) class App extends React.Component { @inject postService: PostService; componentDidMount() { this.postService.loadPosts(); } } 

Il s'avère que la même architecture à trois couches, mais avec la possibilité de maintenir l'état et la vérification d'exécution des types de données (en mode DEV). Ce dernier vous permet de vous assurer que si aucune exception ne se produit, l'état de l'entrepôt de données correspond à la spécification.







Pour ceux qui étaient intéressés, un lien vers github et une démo .

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


All Articles