Injeção de dependência hierárquica no React e MobX State Tree como um modelo de domínio

Após alguns projetos no React, tive a chance de trabalhar em um aplicativo no Angular 2. Francamente, não fiquei impressionado. Mas uma coisa foi lembrada - gerenciar a lógica e o estado do aplicativo usando a Injeção de Dependência. E me perguntei se seria conveniente gerenciar o estado no React usando DDD, uma arquitetura em camadas e injeção de dependência?


Se você está interessado em como fazer isso, e mais importante, por que - bem-vindo ao corte!


Para ser honesto, mesmo no back-end, o DI raramente é usado ao máximo. A menos que em aplicativos realmente grandes. E em pequenas e médias empresas, mesmo com uma DI, cada interface geralmente possui apenas uma implementação. Mas a injeção de dependência ainda tem suas vantagens:


  • O código é melhor estruturado e as interfaces agem como contratos explícitos.
  • A criação de stubs em testes de unidade é simplificada.

Porém, as modernas bibliotecas de teste para JS, como o Jest , permitem que você escreva moki simplesmente com base no sistema modular ES6. Então aqui não teremos muito lucro com o DI.


O segundo ponto permanece - o gerenciamento do escopo e da vida útil dos objetos. No servidor, a vida útil geralmente é vinculada ao aplicativo inteiro (Singleton) ou à solicitação. E no cliente, a principal unidade de código é o componente. Estaremos apegados a ele.


Se precisarmos usar o estado no nível do aplicativo, a maneira mais fácil é definir a variável no nível do módulo ES6 e importá-la quando necessário. E se o estado é necessário apenas dentro do componente - apenas o colocamos neste this.state . Para todo o resto, há Context . Mas o Context é de nível muito baixo:


  • Não podemos usar o contexto fora da árvore do componente React. Por exemplo, em uma camada de lógica de negócios.
  • Não podemos usar mais de um contexto em Class.contextType . Para determinar a dependência de vários serviços diferentes, teremos que construir uma "pirâmide de horror" de uma nova maneira:



O novo Hook useContext() corrige levemente a situação dos componentes funcionais. Mas não vamos nos livrar de muitos <Context.Provider> . Até transformarmos nosso contexto em um localizador de serviço e seu componente pai em uma raiz de composição. Mas aqui não é muito longe do DI, então vamos começar!


Você pode pular esta parte e ir diretamente para a descrição da arquitetura.

Implementação do mecanismo de DI


Primeiro, precisamos de um contexto de reação:


 export const InjectorContext= React.createContext(null); 

Como o React usa o construtor de componentes para suas necessidades, usaremos a Injeção de propriedades. Para fazer isso, defina o decorador @inject , que:


  • define a propriedade Class.contextType ,
  • obtém o tipo de dependência
  • localiza um objeto Injector e resolve a dependência.

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

Agora podemos definir dependências entre classes arbitrárias:


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

Para aqueles que não aceitam decoradores, definimos a função inject() com esta assinatura:


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

Isso permitirá que você defina explicitamente as dependências:


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

E quanto aos componentes funcionais? Para eles, 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 />; } 

Agora, determinaremos a aparência do nosso Injector , como localizá-lo e como resolver dependências. O injetor deve conter uma referência ao pai, um cache de objeto para dependências que já foram resolvidas e um dicionário de regras para as ainda não resolvidas.


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 componentes React, o Injector está disponível no campo this.context e, para as classes de dependência, podemos colocar temporariamente o Injector em uma variável global. Para acelerar a pesquisa de um injetor para cada classe, armazenaremos em cache o link para o Injector em um 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 uma regra de ligação específica, precisamos subir na árvore do injetor usando a função 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, vamos passar a registrar dependências. Para fazer isso, precisamos do provider() HOC provider() , que leva uma matriz de ligações de dependência para suas implementações e registra um novo Injector por meio de 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); } }; }; 

E também, um conjunto de funções de ligação que implementam várias estratégias para criar instâncias de dependência.


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

Agora podemos registrar ligações de dependência no nível de um componente arbitrário na forma de um 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 { // ... } 

Ou de forma abreviada para as classes:


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

Como a vida útil de um serviço é determinada pelo componente do provedor no qual está registrado, para cada serviço, podemos definir um método de limpeza .dispose() . Nele, podemos cancelar a inscrição em alguns eventos, fechar soquetes etc. Quando você remove o provedor do DOM, ele chama .dispose() em todos os serviços que cria.


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

Para separar o código e o carregamento lento, talvez seja necessário inverter o método de registro de serviços nos provedores. O decorador @registerIn() nos ajudará com isso.


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


Portanto, para 150 linhas e 1 KB de código, você pode implementar um contêiner DI hierárquico quase completo.


Arquitetura de aplicativos


Finalmente, vamos passar para o principal - como organizar a arquitetura do aplicativo. Existem três opções possíveis, dependendo do tamanho do aplicativo, da complexidade da área de assunto e da nossa preguiça.


1. O Feio


Temos um DOM virtual, o que significa que deve ser rápido. Pelo menos com este molho, o React foi servido no início de uma carreira. Portanto, lembre-se do link para o componente raiz (por exemplo, usando o decorador @observer ). E chamaremos .forceUpdate() após cada ação que afeta os serviços compartilhados (por exemplo, usando o 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 {} 

Até funcionará. Mas ... você mesmo entende :-)


2. O Mau


Não estamos satisfeitos em renderizar tudo para cada espirro. Mas ainda queremos usar quase objetos regulares e matrizes para armazenamento de estado. Vamos pegar o MobX !


Iniciamos vários armazenamentos de dados com ações padrão:


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

Colocamos lógica de negócios, E / S, etc. na camada de serviços:


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

E nós os distribuímos em 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; } 

O mesmo vale para componentes funcionais e sem 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); // ... }); 

O resultado é uma arquitetura clássica de três camadas.


3. O Bom


Às vezes, a área de assunto se torna tão complexa que já é inconveniente trabalhar com ela usando objetos simples (ou um modelo anêmico em termos de DDD). Isso é especialmente perceptível quando os dados têm uma estrutura relacional com muitos relacionamentos. Nesses casos, a biblioteca MobX State Tree vem em socorro, permitindo que você aplique os princípios do Design Orientado a Domínio na arquitetura do aplicativo front-end.


O design de um modelo começa com uma descrição dos 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>; 

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

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

E o tipo de armazenamento de dados:


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

Os tipos de entidade contêm o estado do modelo de domínio e as operações básicas com ele. Cenários mais complexos, incluindo E / S, são implementados na camada de serviços.


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

A principal característica do MobX State Tree é o trabalho eficiente com snapshots de dados. A qualquer momento, podemos obter o estado serializado de qualquer entidade, coleção ou mesmo todo o estado do aplicativo usando a função getSnapshot() . E da mesma maneira, podemos aplicar a captura instantânea a qualquer parte do modelo usando applySnapshot() . Isso nos permite inicializar o estado do servidor em várias linhas de código, carregar no LocalStorage ou até interagir com ele através do Redux DevTools.


Como usamos um modelo relacional normalizado, precisamos da biblioteca normalizr para carregar dados. Ele permite converter o JSON da árvore em tabelas simples de objetos agrupados por id , de acordo com o esquema de dados. Apenas no formato em que a MobX State Tree é necessária como um instantâneo.


Para fazer isso, defina os esquemas de objetos baixados do 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 }; 

E carregue os dados no armazenamento:


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

Por fim, registre os serviços nos componentes apropriados:


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

Acontece a mesma arquitetura de três camadas, mas com a capacidade de manter a verificação de estado e tempo de execução dos tipos de dados (no modo DEV). O último permite garantir que, se nenhuma exceção ocorrer, o estado do armazém de dados corresponda à especificação.







Para quem estava interessado, um link para o github e uma demonstração .

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


All Articles