
A veces, la implementación más simple de la funcionalidad en última instancia crea más problemas que buenos, solo que aumenta la complejidad en otros lugares. El resultado final es una arquitectura zagged que nadie quiere tocar.
Notas del traductorEl artículo fue escrito en 2017, pero es relevante para este día. Está dirigido a personas con experiencia en RxJS y Ngrx, o que quieran probar Redux en Angular.
Los fragmentos de código se actualizaron según la sintaxis actual de RxJS y se modificaron ligeramente para mejorar la legibilidad y la facilidad de comprensión.
Ngrx / store es una biblioteca angular que ayuda a contener la complejidad de las funciones individuales. Una razón es que ngrx / store abarca la programación funcional, que limita lo que se puede hacer dentro de una función para lograr más razonabilidad fuera de ella. En ngrx / store, cosas como los reductores (en adelante denominados reductores), los selectores (en adelante denominados selectores) y los operadores RxJS son funciones puras.
Las funciones puras son más fáciles de probar, depurar, analizar, paralelizar y combinar. Una función está limpia si:
- con la misma entrada, siempre devuelve la misma salida;
- sin efectos secundarios
Los efectos secundarios no se pueden evitar, pero están aislados en ngrx / store, por lo que el resto de la aplicación puede consistir en funciones puras.
Efectos secundarios
Cuando el usuario envía el formulario, debemos realizar cambios en el servidor. Cambiar el servidor y responder al cliente es un efecto secundario. Esto se puede manejar en el componente:
this.store.dispatch({ type: 'SAVE_DATA', payload: data, }); this.saveData(data)
Sería bueno si pudiéramos enviar la acción (en adelante, la acción) dentro del componente cuando el usuario envía el formulario y maneja el efecto secundario en otro lugar.
Ngrx / effects es middleware para manejar efectos secundarios en ngrx / store. Escucha las acciones enviadas en el hilo observable, realiza efectos secundarios y devuelve nuevas acciones de forma inmediata o asincrónica. Las acciones devueltas se pasan al reductor.
La capacidad de manejar los efectos secundarios de la manera RxJS hace que el código sea más limpio. Después de enviar la acción inicial SAVE_DATA
desde el componente, crea una clase de efecto para manejar el resto:
@Effect() saveData$ = this.actions$.pipe( ofType('SAVE_DATA'), pluck('payload'), switchMap(data => this.saveData(data)), map(res => ({ type: 'DATA_SAVED' })), );
Esto simplifica la operación del componente solo antes de enviar acciones y suscribirse a observables.
Fácil de abusar de Ngrx / efectos
Ngrx / effects es una solución muy poderosa, por lo que es fácil de abusar. Aquí hay algunos antipatrones comunes de ngrx / store que Ngrx / effects simplifica:
1. Estado duplicado
Supongamos que está trabajando en algún tipo de aplicación multimedia y tiene las siguientes propiedades en el árbol de estado:
export interface State { mediaPlaying: boolean; audioPlaying: boolean; videoPlaying: boolean; }
Como el audio es un tipo de medio, siempre que audioPlaying
sea verdadero, mediaPlaying
también debería ser cierto. Así que aquí está la pregunta: "¿Cómo me aseguro de que mediaPlaying se actualice cuando se actualice audioPlaying?"
Respuesta no válida: ¡usa Ngrx / effects!
@Effect() playMediaWithAudio$ = this.actions$.pipe( ofType('PLAY_AUDIO'), map(() => ({ type: 'PLAY_MEDIA' })), );
La respuesta correcta es : si el estado de mediaPlaying
completamente predicho por otra parte del árbol de estado, entonces este no es un estado verdadero. Este es un estado derivado. Pertenece al selector, no a la tienda.
audioPlaying$ = this.store.select('audioPlaying'); videoPlaying$ = this.store.select('videoPlaying'); mediaPlaying$ = combineLatest(this.audioPlaying$, this.videoPlaying$).pipe( map(([audioPlaying, videoPlaying]) => audioPlaying || videoPlaying), );
Ahora nuestra condición puede permanecer limpia y normalizada , y no usamos Ngrx / effects para algo que no sea un efecto secundario.
2. Encadenamiento de acciones con reductor
Imagine que tiene estas propiedades en su árbol de estado:
export interface State { items: { [index: number]: Item }; favoriteItems: number[]; }
Luego, el usuario elimina el elemento. Cuando se devuelve la solicitud de eliminación, se DELETE_ITEM_SUCCESS
acción DELETE_ITEM_SUCCESS
para actualizar el estado de nuestra aplicación. En el reductor de items
, se elimina un Item
individual del objeto de items
. Pero si este identificador de elemento estaba en la matriz favoriteItems
, el elemento al que hace referencia estará ausente. Entonces, la pregunta es, ¿cómo puedo asegurarme de que el identificador se elimine de favoriteItems
al enviar la acción DELETE_ITEM_SUCCESS
?
Respuesta no válida: ¡usa Ngrx / effects!
@Effect() removeFavoriteItemId$ = this.actions$.pipe( ofType('DELETE_ITEM_SUCCESS'), map(() => ({ type: 'REMOVE_FAVORITE_ITEM_ID' })), );
Entonces, ahora tendremos dos acciones enviadas una tras otra, y dos reductores que devuelven nuevos estados uno tras otro.
Respuesta correcta : DELETE_ITEM_SUCCESS
puede ser procesado tanto por el reductor de items
como por el reductor de items
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; } }
El objetivo de la acción es separar lo que sucedió de cómo debería cambiar el estado. Lo que sucedió fue DELETE_ITEM_SUCCESS
. La tarea de los reductores es provocar un cambio de estado correspondiente.
Eliminar un identificador de favoriteItems
no favoriteItems
un efecto secundario de eliminar un Item
. Todo el proceso está totalmente sincronizado y puede ser procesado por reductores. Ngrx / efectos no es necesario.
3. Solicitar datos para el componente
Su componente necesita datos de la tienda, pero primero debe obtenerlos del servidor. La pregunta es, ¿cómo podemos poner los datos en la tienda para que el componente pueda recibirlos?
Manera dolorosa : ¡usa Ngrx / efectos!
En el componente, iniciamos la solicitud enviando una acción:
ngOnInit() { this.store.dispatch({ type: 'GET_USERS' }); }
En la clase de efectos, escuchamos 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 })), );
Ahora suponga que el usuario decide que una ruta determinada tarda demasiado en cargarse, por lo que cambiará de una a otra. Para ser eficiente y no cargar datos innecesarios, queremos cancelar esta solicitud. Cuando se destruye el componente, cancelaremos la suscripción de la solicitud enviando la acción:
ngOnDestroy() { this.store.dispatch({ type: 'CANCEL_GET_USERS' }); }
Ahora en la clase de efectos escuchamos ambas acciones:
@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 }))), ), );
Bueno Ahora, otro desarrollador agrega un componente que requiere la misma solicitud HTTP (no haremos ninguna suposición sobre otros componentes). El componente envía las mismas acciones en los mismos lugares. Si ambos componentes están activos al mismo tiempo, el primer componente inicia una solicitud HTTP para inicializarlo. Cuando se inicializa el segundo componente, no sucederá nada adicional, porque needUsers
será false
. Genial
Luego, cuando se destruye el primer componente, enviará CANCEL_GET_USERS
. Pero el segundo componente todavía necesita estos datos. ¿Cómo podemos evitar que se cancele una solicitud? ¿Quizás comencemos el contador de todos los suscriptores? No voy a implementar esto, pero supongo que entendiste el punto. Estamos comenzando a sospechar que hay una mejor manera de administrar estas dependencias de datos.
Ahora suponga que aparece otro componente y depende de los datos que no se pueden recuperar hasta que users
datos de los users
aparezcan en la tienda. Esto puede ser una conexión a un socket web para chat, información adicional sobre algunos usuarios u otra cosa. No sabemos si este componente se inicializará antes o después de suscribir otros dos componentes a los users
.
La mejor ayuda que he encontrado para este escenario en particular es esta gran publicación . En su ejemplo, callApiY
requiere que callApiX
ya callApiX
haya completado. Eliminé los comentarios para que parezca menos intimidante, pero no dude en leer la publicación original para obtener más información:
@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); }), ), ); }), );
Ahora agregue el requisito de que las solicitudes HTTP deben cancelarse cuando los componentes ya no las necesiten, y esto se volverá aún más complejo.
. . .
Entonces, ¿por qué hay tantos problemas con la gestión de dependencia de datos cuando RxJS debería hacerlo realmente fácil?
Aunque los datos que provienen del servidor son técnicamente un efecto secundario, no me parece que Ngrx / effects sea la mejor manera de manejar esto.
Los componentes son interfaces de entrada / salida de usuario. Muestran datos y envían acciones realizadas por él. Cuando se carga un componente, no envía ninguna acción realizada por este usuario. Quiere mostrar los datos. Esto se parece más a una suscripción que a un efecto secundario.
Muy a menudo puede ver aplicaciones que usan acciones para iniciar una solicitud de datos. Estas aplicaciones implementan una interfaz especial para efectos secundarios observables. Y, como vimos, esta interfaz puede volverse muy incómoda y engorrosa. Suscribirse, darse de baja y conectarse a observables es mucho más fácil.
. . .
De manera menos dolorosa : el componente registrará su interés en los datos suscribiéndose a ellos a través de observables.
Crearemos observables que contengan las solicitudes HTTP necesarias. Veremos qué tan fácil es administrar múltiples suscripciones y cadenas de consulta que dependen unas de otras usando RxJS puro, en lugar de hacerlo a través de efectos.
Cree estos observables en el servicio:
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)), );
La suscripción a los users$
se transmitirá tanto a requireUsers$
como a this.store.pipe(select(selectUsers))
, pero los datos se recibirán solo de this.store.pipe(select(selectUsers))
( muteFirst
implementación muteFirst
y muteFirst
fijo con su prueba )
En componente:
ngOnInit() { this.users$ = this.userService.users$; }
Dado que esta dependencia de datos es ahora un simple observable, podemos suscribirnos y cancelar la suscripción en la plantilla usando una tubería async
, y ya no necesitamos enviar acciones. Si la aplicación deja la ruta del último componente firmado para datos, la solicitud HTTP se cancela o se cierra el socket web.
La cadena de dependencia de datos puede procesarse así:
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)), );
Aquí hay una comparación paralela del método anterior con este método:

El uso de puro observable requiere menos líneas de código y se cancela automáticamente de las dependencias de datos en toda la cadena. (Me salteé las declaraciones de finalize
que se incluyeron originalmente para hacer que la comparación sea más comprensible, pero incluso sin ellas, las consultas se cancelarán en consecuencia).

Conclusión
¡Ngrx / effects es una gran herramienta! Pero considere estas preguntas antes de usarlo:
- ¿Es esto realmente un efecto secundario?
- ¿Es Ngrx / effects la mejor manera de hacer esto?