
Manchmal verursacht die einfachste Implementierung von Funktionen letztendlich mehr Probleme als gute und erhöht nur die Komplexität an anderer Stelle. Das Endergebnis ist eine gezackte Architektur, die niemand berühren möchte.
Anmerkungen des ÜbersetzersDer Artikel wurde 2017 geschrieben, ist aber bis heute relevant. Es richtet sich an Personen, die Erfahrung mit RxJS und Ngrx haben oder Redux in Angular ausprobieren möchten.
Die Codefragmente wurden basierend auf der aktuellen RxJS-Syntax aktualisiert und leicht modifiziert, um die Lesbarkeit und das Verständnis zu verbessern.
Ngrx / store ist eine Angular-Bibliothek, mit deren Hilfe die Komplexität einzelner Funktionen berücksichtigt werden kann. Ein Grund dafür ist, dass ngrx / store eine funktionale Programmierung umfasst, die die Möglichkeiten innerhalb einer Funktion einschränkt, um außerhalb eine vernünftigere Funktion zu erzielen. In ngrx / store sind Dinge wie Reduzierer (im Folgenden als Reduzierer bezeichnet), Selektoren (im Folgenden als Selektoren bezeichnet) und RxJS-Operatoren reine Funktionen.
Reine Funktionen sind einfacher zu testen, zu debuggen, zu analysieren, zu parallelisieren und zu kombinieren. Eine Funktion ist sauber, wenn:
- Mit derselben Eingabe wird immer dieselbe Ausgabe zurückgegeben.
- Keine Nebenwirkungen.
Nebenwirkungen können nicht vermieden werden, sind jedoch in ngrx / store isoliert, sodass der Rest der Anwendung aus reinen Funktionen bestehen kann.
Nebenwirkungen
Wenn der Benutzer das Formular sendet, müssen wir Änderungen auf dem Server vornehmen. Das Ändern des Servers und das Antworten auf den Client ist ein Nebeneffekt. Dies kann in der Komponente behandelt werden:
this.store.dispatch({ type: 'SAVE_DATA', payload: data, }); this.saveData(data)
Es wäre schön, wenn wir die Aktion (im Folgenden die Aktion) nur innerhalb der Komponente auslösen könnten, wenn der Benutzer das Formular einreicht und die Nebenwirkung an anderer Stelle behandelt.
Ngrx / Effects ist eine Middleware zur Behandlung von Nebenwirkungen in Ngrx / Store. Es wartet auf übermittelte Aktionen im beobachtbaren Thread, führt Nebenwirkungen aus und gibt neue Aktionen sofort oder asynchron zurück. Die zurückgegebenen Aktionen werden an den Reduzierer übergeben.
Die Fähigkeit, Nebenwirkungen auf RxJS-Weise zu behandeln, macht den Code sauberer. Nachdem Sie die erste Aktion SAVE_DATA
von der Komponente SAVE_DATA
, erstellen Sie eine SAVE_DATA
, um den Rest zu erledigen:
@Effect() saveData$ = this.actions$.pipe( ofType('SAVE_DATA'), pluck('payload'), switchMap(data => this.saveData(data)), map(res => ({ type: 'DATA_SAVED' })), );
Dies vereinfacht den Betrieb der Komponente nur vor dem Senden von Aktionen und dem Abonnieren von Observable.
Leicht zu missbrauchen Ngrx / Effekte
Ngrx / Effekte ist eine sehr leistungsfähige Lösung, daher ist es leicht zu missbrauchen. Hier sind einige gängige Anti-Patterns für ngrx / store, die durch Ngrx / -Effekte vereinfacht werden:
1. Doppelter Status
Angenommen, Sie arbeiten an einer Multimedia-Anwendung und haben im Statusbaum die folgenden Eigenschaften:
export interface State { mediaPlaying: boolean; audioPlaying: boolean; videoPlaying: boolean; }
Da Audio eine Art von Medium ist, sollte audioPlaying
auch dann wahr sein, wenn audioPlaying
wahr ist. Hier ist also die Frage: "Wie stelle ich sicher, dass mediaPlaying aktualisiert wird, wenn audioPlaying aktualisiert wird?"
Ungültige Antwort : Ngrx / Effekte verwenden!
@Effect() playMediaWithAudio$ = this.actions$.pipe( ofType('PLAY_AUDIO'), map(() => ({ type: 'PLAY_MEDIA' })), );
Die richtige Antwort lautet : Wenn der Status von mediaPlaying
vollständig von einem anderen Teil des mediaPlaying
vorhergesagt wird, ist dies kein echter Status. Dies ist ein abgeleiteter Zustand. Es gehört dem Selektor, nicht dem Laden.
audioPlaying$ = this.store.select('audioPlaying'); videoPlaying$ = this.store.select('videoPlaying'); mediaPlaying$ = combineLatest(this.audioPlaying$, this.videoPlaying$).pipe( map(([audioPlaying, videoPlaying]) => audioPlaying || videoPlaying), );
Jetzt kann unser Zustand sauber und normal bleiben, und wir verwenden Ngrx / Effekte nicht für etwas, das keine Nebenwirkung ist.
2. Verkettungsaktionen mit Reduzierstück
Stellen Sie sich vor, Sie haben diese Eigenschaften in Ihrem Statusbaum:
export interface State { items: { [index: number]: Item }; favoriteItems: number[]; }
Dann löscht der Benutzer das Element. Wenn die DELETE_ITEM_SUCCESS
zurückgegeben wird, wird die Aktion DELETE_ITEM_SUCCESS
gesendet, um den Status unserer Anwendung zu aktualisieren. Im Artikelreduzierer wird ein einzelner Item
aus dem Item
entfernt. Wenn sich dieser Elementbezeichner jedoch im Array " favoriteItems
, fehlt das Element, auf das er verweist. Die Frage ist also, wie ich sicherstellen kann, dass der Bezeichner beim Senden der Aktion DELETE_ITEM_SUCCESS
aus favoriteItems
entfernt wird.
Ungültige Antwort : Ngrx / Effekte verwenden!
@Effect() removeFavoriteItemId$ = this.actions$.pipe( ofType('DELETE_ITEM_SUCCESS'), map(() => ({ type: 'REMOVE_FAVORITE_ITEM_ID' })), );
Jetzt werden zwei Aktionen nacheinander gesendet, und zwei Reduzierer geben nacheinander neue Zustände zurück.
Richtige Antwort : DELETE_ITEM_SUCCESS
kann sowohl vom DELETE_ITEM_SUCCESS
als auch vom favoriteItems
.
export function favoriteItemsReducer(state = initialState, action: Action) { switch (action.type) { case 'REMOVE_FAVORITE_ITEM': case 'DELETE_ITEM_SUCCESS': const itemId = action.payload; return state.filter(id => id !== itemId); default: return state; } }
Ziel der Aktion ist es, zu trennen, was passiert ist und wie sich der Staat ändern soll. Was passiert ist, war DELETE_ITEM_SUCCESS
. Die Aufgabe von Reduzierern besteht darin, eine entsprechende Zustandsänderung zu bewirken.
Das Entfernen eines Bezeichners aus favoriteItems
kein Nebeneffekt beim Löschen eines Item
. Der gesamte Prozess ist vollständig synchronisiert und kann von Reduzierern verarbeitet werden. Ngrx / Effekte werden nicht benötigt.
3. Fordern Sie Daten für die Komponente an
Ihre Komponente benötigt Daten aus dem Speicher, aber zuerst müssen Sie sie vom Server abrufen. Die Frage ist, wie können wir die Daten in den Speicher stellen, damit die Komponente sie empfangen kann?
Schmerzhafter Weg : Ngrx / Effekte verwenden!
In der Komponente initiieren wir die Anforderung, indem wir eine Aktion senden:
ngOnInit() { this.store.dispatch({ type: 'GET_USERS' }); }
In der GET_USERS
hören wir GET_USERS
:
@Effect getUsers$ = this.actions$.pipe( ofType('GET_USERS'), withLatestFrom(this.userSelectors.needUsers$), filter(([action, needUsers]) => needUsers), switchMap(() => this.getUsers()), map(users => ({ type: 'RECEIVE_USERS', users })), );
Angenommen, der Benutzer entscheidet, dass das Laden einer bestimmten Route zu lange dauert, sodass er von einer zur anderen wechselt. Um effizient zu sein und keine unnötigen Daten zu laden, möchten wir diese Anfrage abbrechen. Wenn die Komponente zerstört wird, werden wir die Anforderung durch Senden der folgenden Aktion abbestellen:
ngOnDestroy() { this.store.dispatch({ type: 'CANCEL_GET_USERS' }); }
Jetzt hören wir in der Effektklasse beide Aktionen:
@Effect getUsers$ = this.actions$.pipe( ofType('GET_USERS', 'CANCEL_GET_USERS'), withLatestFrom(this.userSelectors.needUsers$), filter(([action, needUsers]) => needUsers), map(([action, needUsers]) => action), switchMap( action => action.type === 'CANCEL_GET_USERS' ? of() : this.getUsers().pipe(map(users => ({ type: 'RECEIVE_USERS', users }))), ), );
Gut. Jetzt fügt ein anderer Entwickler eine Komponente hinzu, für die dieselbe HTTP-Anforderung erforderlich ist (wir werden keine Annahmen über andere Komponenten treffen). Die Komponente sendet dieselben Aktionen an denselben Orten. Wenn beide Komponenten gleichzeitig aktiv sind, initiiert die erste Komponente eine HTTP-Anforderung, um sie zu initialisieren. Wenn die zweite Komponente initialisiert wird, geschieht nichts needUsers
, da needUsers
false
. Großartig!
Wenn die erste Komponente zerstört wird, sendet sie CANCEL_GET_USERS
. Die zweite Komponente benötigt diese Daten jedoch weiterhin. Wie können wir verhindern, dass eine Anfrage storniert wird? Vielleicht starten wir den Zähler aller Abonnenten? Ich werde dies nicht umsetzen, aber ich nehme an, Sie haben den Punkt verstanden. Wir beginnen zu vermuten, dass es einen besseren Weg gibt, diese Datenabhängigkeiten zu verwalten.
Angenommen, eine andere Komponente wird angezeigt. Dies hängt von Daten ab, die erst abgerufen werden können, wenn users
im Geschäft angezeigt werden. Dies kann eine Verbindung zu einem Web-Socket für den Chat, zusätzliche Informationen zu einigen Benutzern oder etwas anderes sein. Wir wissen nicht, ob diese Komponente vor oder nach dem Abonnieren von zwei anderen Komponenten für users
initialisiert wird.
Die beste Hilfe, die ich für dieses spezielle Szenario gefunden habe, ist dieser großartige Beitrag . In seinem Beispiel erfordert callApiY
, dass callApiX
bereits abgeschlossen ist. Ich habe die Kommentare entfernt, damit sie weniger einschüchternd wirken. Sie können jedoch auch den Originalbeitrag lesen, um mehr zu erfahren:
@Effect() actionX$ = this.actions$.pipe( ofType('ACTION_X'), map(toPayload), switchMap(payload => this.api.callApiX(payload).pipe( map(data => ({ type: 'ACTION_X_SUCCESS', payload: data })), catchError(err => of({ type: 'ACTION_X_FAIL', payload: err })), ), ), ); @Effect() actionY$ = this.actions$.pipe( ofType('ACTION_Y'), map(toPayload), withLatestFrom(this.store.select(state => state.someBoolean)), switchMap(([payload, someBoolean]) => { const callHttpY = v => { return this.api.callApiY(v).pipe( map(data => ({ type: 'ACTION_Y_SUCCESS', payload: data, })), catchError(err => of({ type: 'ACTION_Y_FAIL', payload: err, }), ), ); }; if (someBoolean) { return callHttpY(payload); } return of({ type: 'ACTION_X', payload }).merge( this.actions$.pipe( ofType('ACTION_X_SUCCESS', 'ACTION_X_FAIL'), first(), switchMap(action => { if (action.type === 'ACTION_X_FAIL') { return of({ type: 'ACTION_Y_FAIL', payload: 'Because ACTION_X failed.', }); } return callHttpY(payload); }), ), ); }), );
Fügen Sie nun die Anforderung hinzu, dass HTTP-Anforderungen abgebrochen werden müssen, wenn Komponenten sie nicht mehr benötigen. Dies wird noch komplexer.
. . .
Warum gibt es so viele Probleme mit dem Datenabhängigkeitsmanagement, wenn RxJS es wirklich einfach machen sollte?
Obwohl die vom Server kommenden Daten technisch gesehen ein Nebeneffekt sind, scheint es mir nicht, dass Ngrx / Effekte der beste Weg ist, damit umzugehen.
Komponenten sind Benutzereingabe- / Ausgabeschnittstellen. Sie zeigen Daten und senden von ihm ausgeführte Aktionen. Wenn eine Komponente geladen wird, sendet sie keine von diesem Benutzer ausgeführten Aktionen. Er möchte die Daten anzeigen. Dies ist eher ein Abonnement als ein Nebeneffekt.
Sehr oft sehen Sie Anwendungen, die Aktionen verwenden, um eine Datenanforderung zu initiieren. Diese Anwendungen implementieren eine spezielle Schnittstelle zur Beobachtung durch Nebenwirkungen. Und wie wir gesehen haben, kann diese Schnittstelle sehr unpraktisch und umständlich werden. Das Abonnieren, Abbestellen und Verbinden von Observable selbst ist viel einfacher.
. . .
Weniger schmerzhaft : Die Komponente registriert ihr Interesse an den Daten, indem sie diese über Observable abonniert.
Wir werden Observable erstellen, die die notwendigen HTTP-Anfragen enthalten. Wir werden sehen, wie viel einfacher es ist, mehrere Abonnements und Abfrageketten, die voneinander abhängig sind, mit reinem RxJS zu verwalten, anstatt dies durch Effekte zu tun.
Erstellen Sie diese im Service beobachtbaren:
requireUsers$ = this.store.pipe( select(selectNeedUser), filter(needUsers => needUsers), tap(() => this.store.dispatch({ type: 'GET_USERS' })), switchMap(() => this.getUsers()), tap(users => this.store.dispatch({ type: 'RECEIVE_USERS', users })), finalize(() => this.store.dispatch({ type: 'CANCEL_GET_USERS' })), share(), ); users$ = muteFirst( this.requireUsers$.pipe(startWith(null)), this.store.pipe(select(selectUsers)), );
Das Abonnement für users$
wird sowohl an requireUsers$
als auch an this.store.pipe(select(selectUsers))
, die Daten werden jedoch nur von this.store.pipe(select(selectUsers))
muteFirst
( muteFirst
Implementierung von muteFirst
und festes muteFirst
mit ihrem Test .)
In Komponente:
ngOnInit() { this.users$ = this.userService.users$; }
Da diese Datenabhängigkeit jetzt einfach zu beobachten ist, können wir die Vorlage mithilfe der async
Pipe abonnieren und abbestellen, und wir müssen keine Aktionen mehr senden. Wenn die Anwendung die Route der letzten für Daten signierten Komponente verlässt, wird die HTTP-Anforderung abgebrochen oder der Web-Socket geschlossen.
Die Datenabhängigkeitskette kann folgendermaßen verarbeitet werden:
requireUsers$ = this.store.pipe( select(selectNeedUser), filter(needUsers => needUsers), tap(() => this.store.dispatch({ type: 'GET_USERS' })), switchMap(() => this.getUsers()), tap(users => this.store.dispatch({ type: 'RECEIVE_USERS', users })), share(), ); users$ = muteFirst( this.requireUsers$.pipe(startWith(null)), this.store.pipe(select(selectUsers)), ); requireUsersExtraData$ = this.users$.pipe( withLatestFrom(this.store.pipe(select(selectNeedUsersExtraData))), filter(([users, needData]) => Boolean(users.length) && needData), tap(() => this.store.dispatch({ type: 'GET_USERS_EXTRA_DATA' })), switchMap(() => this.getUsers()), tap(users => this.store.dispatch({ type: 'RECEIVE_USERS_EXTRA_DATA', users, }), ), share(), ); public usersExtraData$ = muteFirst( this.requireUsersExtraData$.pipe(startWith(null)), this.store.pipe(select(selectUsersExtraData)), );
Hier ist ein paralleler Vergleich der obigen Methode mit dieser Methode:

Die Verwendung von rein beobachtbar erfordert weniger Codezeilen und wird automatisch von Datenabhängigkeiten in der gesamten Kette abgemeldet. (Ich habe die ursprünglich enthaltenen finalize
Anweisungen übersprungen, um den Vergleich verständlicher zu machen, aber auch ohne sie werden Abfragen entsprechend abgebrochen.)

Fazit
Ngrx / Effekte ist ein großartiges Werkzeug! Berücksichtigen Sie jedoch diese Fragen, bevor Sie sie verwenden:
- Ist das wirklich ein Nebeneffekt?
- Ist Ngrx / Effects der beste Weg, dies zu tun?