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) {
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);
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 {
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(
Oder in Kurzform für Klassen:
@provider(
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 => {
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); }
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 => {
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:
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:
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", {
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;
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 .