Hierarchische Abhängigkeitsinjektion in React und MobX State Tree als Domänenmodell

Nach einigen Projekten zu React hatte ich die Möglichkeit, an einer Anwendung unter Angular 2 zu arbeiten. Ehrlich gesagt war ich nicht beeindruckt. Eines wurde jedoch beachtet: Verwalten der Logik und des Status der Anwendung mithilfe von Dependency Injection. Und ich habe mich gefragt, ob es bequem ist, den Status in React mithilfe von DDD, einer Schichtarchitektur und Abhängigkeitsinjektion zu verwalten.


Wenn Sie daran interessiert sind, wie das geht, und vor allem warum - willkommen zum Schnitt!


Um ehrlich zu sein, wird DI selbst im Backend selten in vollem Umfang genutzt. Es sei denn, in wirklich großen Anwendungen. Und in kleinen und mittleren, selbst mit einem DI, hat jede Schnittstelle normalerweise nur eine Implementierung. Die Abhängigkeitsinjektion hat jedoch immer noch ihre Vorteile:


  • Der Code ist besser strukturiert und die Schnittstellen fungieren als explizite Verträge.
  • Die Erstellung von Stubs in Unit-Tests wird vereinfacht.

Moderne Testbibliotheken für JS wie Jest ermöglichen es Ihnen jedoch, Moki einfach auf der Basis des modularen ES6-Systems zu schreiben. Hier werden wir also nicht viel von DI profitieren.


Der zweite Punkt bleibt - die Verwaltung des Umfangs und der Lebensdauer von Objekten. Auf dem Server ist die Lebensdauer normalerweise an die gesamte Anwendung (Singleton) oder an die Anforderung gebunden. Auf dem Client ist die Hauptcodeeinheit die Komponente. Wir werden daran hängen.


Wenn Sie den Status auf Anwendungsebene verwenden müssen, ist es am einfachsten, die Variable auf ES6-Modulebene festzulegen und gegebenenfalls zu importieren. Und wenn der Status nur innerhalb der Komponente benötigt wird, setzen wir ihn einfach in diesen this.state . Für alles andere gibt es Context . Der Context ist jedoch zu niedrig:


  • Wir können keinen Kontext außerhalb des Komponentenbaums "Reagieren" verwenden. Zum Beispiel in einer Geschäftslogikschicht.
  • Wir können nicht mehr als einen Kontext in Class.contextType . Um die Abhängigkeit von mehreren verschiedenen Diensten zu bestimmen, müssen wir auf neue Weise eine „Horrorpyramide“ bauen:



Der neue Hook useContext() korrigiert die Situation für Funktionskomponenten geringfügig. Aber wir werden die vielen <Context.Provider> nicht los. Bis wir unseren Kontext in einen Service Locator und seine übergeordnete Komponente in einen Composition Root verwandeln. Aber hier ist es nicht weit von DI, also fangen wir an!


Sie können diesen Teil überspringen und direkt zur Beschreibung der Architektur gehen.

Implementierung des DI-Mechanismus


Zuerst brauchen wir einen Reaktionskontext:


 export const InjectorContext= React.createContext(null); 

Da React den Komponentenkonstruktor für seine Anforderungen verwendet, verwenden wir Property Injection. Definieren Sie dazu den @inject Dekorator, der:



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

Jetzt können wir Abhängigkeiten zwischen beliebigen Klassen definieren:


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

Für diejenigen, die keine Dekorateure akzeptieren, definieren wir die Funktion inject() mit dieser Signatur:


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

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

Auf diese Weise können Sie Abhängigkeiten explizit definieren:


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

Was ist mit Funktionskomponenten? Für sie können wir Hook useInstance() implementieren


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

Jetzt bestimmen wir, wie unser Injector aussehen könnte, wie er zu finden ist und wie Abhängigkeiten aufgelöst werden. Der Injektor muss einen Verweis auf das übergeordnete Element, einen Objektcache für bereits aufgelöste Abhängigkeiten und ein Regelwörterbuch für noch nicht aufgelöste Abhängigkeiten enthalten.


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

Für React-Komponenten ist Injector über das Feld this.context verfügbar, und für Abhängigkeitsklassen können wir Injector vorübergehend in eine globale Variable this.context . Um die Suche nach einem Injektor für jede Klasse zu beschleunigen, wird der Link zu Injector in einem ausgeblendeten Feld zwischengespeichert.


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

Um eine bestimmte Bindungsregel zu finden, müssen wir den Injektorbaum mit der Funktion getInstance()


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

Lassen Sie uns abschließend mit dem Registrieren von Abhängigkeiten fortfahren. Dazu benötigen wir den HOC- provider() , der eine Reihe von Abhängigkeitsbindungen in seine Implementierungen Injector und einen neuen Injector über InjectorContext.Provider registriert


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

Außerdem eine Reihe von Bindungsfunktionen, die verschiedene Strategien zum Erstellen von Abhängigkeitsinstanzen implementieren.


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

Jetzt können wir Abhängigkeitsbindungen auf der Ebene einer beliebigen Komponente in Form einer Menge von Paaren [<>, <>] registrieren.


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

Oder in Kurzform für Klassen:


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

Da die Lebensdauer eines Dienstes von der Anbieterkomponente bestimmt wird, in der er registriert ist, können wir für jeden Dienst eine Reinigungsmethode .dispose() definieren. Darin können wir einige Ereignisse abbestellen, Sockets schließen usw. Wenn Sie den Anbieter aus dem DOM entfernen, ruft er .dispose() für alle von ihm erstellten Dienste auf.


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

Um den Code und das verzögerte Laden zu trennen, müssen wir möglicherweise die Methode zum Registrieren von Diensten bei Anbietern umkehren. Der Dekorateur @registerIn() hilft uns dabei.


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

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


Für 150 Zeilen und 1 KB Code können Sie also einen fast vollständigen hierarchischen DI-Container implementieren.


Anwendungsarchitektur


Kommen wir zum Schluss zur Hauptsache - wie man die Anwendungsarchitektur organisiert. Abhängig von der Größe der Anwendung, der Komplexität des Themenbereichs und unserer Faulheit gibt es drei mögliche Optionen.


1. Der Hässliche


Wir haben ein virtuelles DOM, was bedeutet, dass es schnell sein sollte. Zumindest mit dieser Sauce wurde React zu Beginn einer Karriere serviert. @observer daher nur an den Link zur @observer (z. B. mithilfe des @observer Dekorators). Und wir werden .forceUpdate() nach jeder Aktion aufrufen, die sich auf gemeinsam genutzte Dienste auswirkt (z. B. mithilfe des @action Dekorators).


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

Es wird sogar funktionieren. Aber ... du selbst verstehst :-)


2. Das Böse


Wir geben uns nicht damit zufrieden, alles für jedes Niesen zu rendern. Aber wir wollen immer noch verwenden fast reguläre Objekte und Arrays zum Speichern des Status. Nehmen wir MobX !


Wir starten mehrere Datenspeicher mit Standardaktionen:


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

Wir bringen Geschäftslogik, E / A usw. in die Serviceschicht:


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

Und wir verteilen sie in Komponenten:


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

Gleiches gilt für Funktionskomponenten und ohne Dekorateure
 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); // ... }); 

Das Ergebnis ist eine klassische dreistufige Architektur.


3. Das Gute


Manchmal wird der Themenbereich so komplex, dass es bereits unpraktisch ist, mit einfachen Objekten (oder einem anämischen Modell in Bezug auf DDD) damit zu arbeiten. Dies macht sich insbesondere dann bemerkbar, wenn die Daten eine relationale Struktur mit vielen Beziehungen haben. In solchen Fällen hilft die MobX State Tree- Bibliothek, sodass Sie die Prinzipien des domänengesteuerten Designs in der Architektur der Front-End-Anwendung anwenden können.


Das Entwerfen eines Modells beginnt mit einer Beschreibung der Typen:


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

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

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

Und die Art des Datenspeichers:


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

Entitätstypen enthalten den Status des Domänenmodells und grundlegende Operationen damit. Komplexere Szenarien, einschließlich E / A, werden in der Serviceschicht implementiert.


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

Das Hauptmerkmal von MobX State Tree ist die effiziente Arbeit mit Datenschnappschüssen. Mit der Funktion getSnapshot() können wir jederzeit den serialisierten Status einer Entität, Sammlung oder sogar des gesamten Status der Anwendung getSnapshot() . Auf die gleiche Weise können wir mit applySnapshot() einen Snapshot auf jeden Teil des Modells applySnapshot() . Auf diese Weise können wir den Status vom Server in mehreren Codezeilen initialisieren, aus LocalStorage laden oder sogar über Redux DevTools mit ihm interagieren.


Da wir ein normalisiertes relationales Modell verwenden, benötigen wir die normalizr- Bibliothek, um Daten zu laden. Sie können Baum-JSON gemäß dem Datenschema in flache Tabellen von Objekten übersetzen, die nach id gruppiert sind. Nur in dem Format, in dem MobX State Tree als Snapshot benötigt wird.


Definieren Sie dazu die Schemata der vom Server heruntergeladenen Objekte:


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

Und laden Sie die Daten in den Speicher:


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

Registrieren Sie abschließend die Dienste in den entsprechenden Komponenten:


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

Es stellt sich heraus, dass es sich um dieselbe dreischichtige Architektur handelt, jedoch mit der Fähigkeit, die Status- und Laufzeitüberprüfung von Datentypen beizubehalten (im DEV-Modus). Mit letzterem können Sie sicher sein, dass der Status des Data Warehouse der Spezifikation entspricht, wenn keine Ausnahme auftritt.







Für Interessierte ein Link zu Github und eine Demo .

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


All Articles