Inyección de dependencia jerárquica en React y MobX State Tree como modelo de dominio

Después de algunos proyectos en React, tuve la oportunidad de trabajar en una aplicación bajo Angular 2. Francamente, no me impresionó. Pero se recordó una cosa: administrar la lógica y el estado de la aplicación mediante la inyección de dependencias. ¿Y me preguntaba si es conveniente administrar el estado en React usando DDD, una arquitectura en capas e inyección de dependencia?


Si está interesado en cómo hacer esto, y lo más importante, ¿por qué? ¡Bienvenido al corte!


Para ser honesto, incluso en el back-end, DI rara vez se utiliza al máximo. A menos que en aplicaciones realmente grandes. Y en pequeñas y medianas, incluso con un DI, cada interfaz generalmente tiene una sola implementación. Pero la inyección de dependencia todavía tiene sus ventajas:


  • El código está mejor estructurado y las interfaces actúan como contratos explícitos.
  • La creación de stubs en pruebas unitarias se simplifica.

Pero las bibliotecas de prueba modernas para JS, como Jest , le permiten escribir moki simplemente basado en el sistema modular ES6. Entonces aquí no obtendremos muchas ganancias de DI.


El segundo punto sigue siendo: la gestión del alcance y la vida útil de los objetos. En el servidor, la vida útil suele estar vinculada a toda la aplicación (Singleton) o a la solicitud. Y en el cliente, la unidad principal de código es el componente. Estaremos apegados a ello.


Si necesitamos usar el estado a nivel de aplicación, la forma más fácil es establecer la variable en el nivel del módulo ES6 e importarla cuando sea necesario. Y si el estado solo se necesita dentro del componente, simplemente lo ponemos en this.state . Para todo lo demás, hay Context . Pero el Context es demasiado bajo:


  • No podemos usar el contexto fuera del árbol de componentes React. Por ejemplo, en una capa de lógica de negocios.
  • No podemos usar más de un contexto en Class.contextType . Para determinar la dependencia de varios servicios diferentes, tendremos que construir una "pirámide de terror" de una nueva manera:



El nuevo Hook useContext() corrige ligeramente la situación de los componentes funcionales. Pero no nos <Context.Provider> de los muchos <Context.Provider> . Hasta que convertimos nuestro contexto en un Localizador de servicios y su componente principal en una Raíz de composición. Pero aquí no está lejos de DI, ¡así que comencemos!


Puede omitir esta parte e ir directamente a la descripción de la arquitectura.

Implementación del mecanismo DI


Primero necesitamos un contexto de reacción:


 export const InjectorContext= React.createContext(null); 

Como React usa el constructor de componentes para sus necesidades, usaremos la inyección de propiedades. Para hacer esto, defina el decorador @inject , que:


  • establece la propiedad Class.contextType ,
  • obtiene el tipo de dependencia
  • encuentra un objeto Injector y resuelve la dependencia.

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

Ahora podemos definir dependencias entre clases arbitrarias:


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

Para aquellos que no aceptan decoradores, definimos la función inject() con esta firma:


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

Esto le permitirá definir las dependencias explícitamente:


 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é pasa con los componentes funcionales? Para ellos podemos implementar 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 />; } 

Ahora determinaremos cómo se verá nuestro Injector , cómo encontrarlo y cómo resolver las dependencias. El inyector debe contener una referencia al padre, un caché de objetos para las dependencias que ya están resueltas y un diccionario de reglas para las aún no resueltas.


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

Para los componentes React, Injector está disponible a través del campo this.context , y para las clases de dependencia, podemos colocar temporalmente Injector en una variable global. Para acelerar la búsqueda de un inyector para cada clase, almacenaremos en caché el enlace al Injector en un campo oculto.


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

Para encontrar una regla de enlace específica, necesitamos subir el árbol del inyector usando la función 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; } 

Finalmente, pasemos a registrar dependencias. Para hacer esto, necesitamos un provider() HOC provider() , que toma una serie de enlaces de dependencia para sus implementaciones y registra un nuevo Injector través de InjectorContext.Provider


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

Y también, un conjunto de funciones vinculantes que implementan diversas estrategias para crear instancias de dependencia.


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

Ahora podemos registrar enlaces de dependencia a nivel de un componente arbitrario en forma de un conjunto de pares [<>, <>] .


 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 { // ... } 

O en forma abreviada para las clases:


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

Dado que la vida útil de un servicio está determinada por el componente del proveedor en el que está registrado, para cada servicio podemos definir un método de limpieza .dispose() . En él, podemos darnos de baja de algunos eventos, cerrar sockets, etc. Cuando elimine el proveedor del DOM, llamará a .dispose() en todos los servicios que cree.


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

Para separar el código y la carga diferida, es posible que necesitemos invertir el método de registrar servicios con proveedores. El decorador @registerIn() nos ayudará con esto.


proveedorr.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 {} 


Entonces, para 150 líneas y 1 KB de código, puede implementar un contenedor DI jerárquico casi completo.


Arquitectura de la aplicación


Finalmente, pasemos a lo principal: cómo organizar la arquitectura de la aplicación. Hay tres opciones posibles, dependiendo del tamaño de la aplicación, la complejidad del área temática y nuestra pereza.


1. El feo


Tenemos un DOM virtual, lo que significa que debería ser rápido. Al menos con esta salsa, React se sirvió en los albores de una carrera. Por lo tanto, solo recuerde el enlace al componente raíz (por ejemplo, usando el decorador @observer ). Y llamaremos a .forceUpdate() después de cada acción que afecte los servicios compartidos (por ejemplo, usando el decorador @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 {} 

Incluso funcionará. Pero ... tú mismo entiendes :-)


2. Lo malo


No estamos satisfechos con presentar todo por cada estornudo. Pero todavía queremos usar casi objetos regulares y matrices para almacenar estado. ¡Tomemos MobX !


Comenzamos varios almacenamientos de datos con acciones estándar:


 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 { // ... } 

Llevamos la lógica de negocios, E / S, etc. a la capa de servicios:


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

Y los distribuimos en componentes:


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

Lo mismo ocurre con los componentes funcionales y sin decoradores.
 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); // ... }); 

El resultado es una arquitectura clásica de tres niveles.


3. Lo bueno


A veces, el área temática se vuelve tan compleja que ya es inconveniente trabajar con ella utilizando objetos simples (o un modelo anémico en términos de DDD). Esto es especialmente notable cuando los datos tienen una estructura relacional con muchas relaciones. En tales casos, la biblioteca MobX State Tree viene al rescate, lo que le permite aplicar los principios del diseño impulsado por dominio en la arquitectura de la aplicación front-end.


El diseño de un modelo comienza con una descripción de los tipos:


 // 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>; 

modelos / 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>; 

modelos / 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>; 

Y el tipo de almacén de datos:


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

Los tipos de entidad contienen el estado del modelo de dominio y las operaciones básicas con él. Los escenarios más complejos, incluidas las E / S, se implementan en la capa de servicios.


servicios / 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> {} 

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

servicios / 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 característica principal de MobX State Tree es el trabajo eficiente con instantáneas de datos. En cualquier momento, podemos obtener el estado serializado de cualquier entidad, colección o incluso el estado completo de la aplicación utilizando la función getSnapshot() . Y de la misma manera, podemos aplicar una instantánea a cualquier parte del modelo usando applySnapshot() . Esto nos permite inicializar el estado del servidor en varias líneas de código, cargar desde LocalStorage o incluso interactuar con él a través de Redux DevTools.


Como utilizamos un modelo relacional normalizado, necesitamos la biblioteca normalizr para cargar datos. Le permite traducir el árbol JSON en tablas planas de objetos agrupados por id , de acuerdo con el esquema de datos. Solo en el formato que se necesita MobX State Tree como una instantánea.


Para hacer esto, defina los esquemas de los objetos descargados del servidor:


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

Y cargue los datos en el almacenamiento:


 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": [] } ] 

Finalmente, registre los servicios en los componentes apropiados:


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

Resulta la misma arquitectura de tres capas, pero con la capacidad de mantener el estado y la verificación de tiempo de ejecución de los tipos de datos (en modo DEV). Este último le permite asegurarse de que si no se produce una excepción, el estado del almacén de datos corresponde a la especificación.







Para aquellos que estaban interesados, un enlace a github y una demostración .

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


All Articles