Cualquiera que trabaje en una aplicación Angular debería al menos estar familiarizado con RxJS. De hecho, el propio framework está construido usando RxJS y alrededor de algunos de sus conceptos. Pero hay más que eso. En realidad, podemos utilizar RxJS y los streams observables para obtener un código mejor y más legible e incluso reducir el número de líneas (que se traduce en el tamaño del bundle) que escribimos.
¡Así que vamos a sumergirnos en ello!
Usando RxJS para reducir el estado de nuestros componentes
Todo en Angular gira en torno al estado de los componentes (y de la aplicación en general) y cómo se proyecta a la UI. Y hay muchas ocasiones en las que podemos utilizar streams para representar piezas volátiles de nuestros datos dentro de la vista. Es especialmente útil cuando se trabaja con formularios y otras cosas que cambian mucho.
Así es como hacemos algunas cosas mal:
- Cuando tenemos una nueva tarea entre manos, pensamos en cómo cambiará el estado.
- Almacenamos nuevas piezas de estado en nuestro componente (nuevas propiedades, objetos anidados, entre otros)
- Ideamos nuevos métodos que encapsulen las formas en que nuestro nuevo estado cambia.
- Escribimos una lógica enrevesada dentro de nuestra plantilla.
Veamos esto a través de un ejemplo:
import { Component } from '@angular/core';
@Component({
selector: 'my-component',
template: `
<select [(ngModel)]="selectedUserId" (ngModelChange)="changeUser()">
<option>Select a User</option>
<option *ngFor="let user of users" [value]="user.id">{{ user.name }}</option>
</select>
<select [(ngModel)]="blackListedUsers" (ngModelChange)="changeUser()" multiple>
<option *ngFor="let user of users" [value]="user.id">{{ user.name }}</option>
</select>
Allow black listed users <input type="checkbox" [(ngModel)]="allowBlackListedUsers"/>
<button [disabled]="isUserBlackListed && !allowBlackListedUsers">Submit</button>
`,
})
export class MyComponent {
users = [
{name: 'John', id: 1},
{name: 'Andrew', id: 2},
{name: 'Anna', id: 3},
{name: 'Iris', id: 4},
];
blackListedUsers = [];
selectedUserId = null;
isUserBlackListed = false;
allowBlackListedUsers = false;
changeUser() {
this.isUserBlackListed = !!this.blackListedUsers.find(
blackListedUserId => +this.selectedUserId === blackListedUserId
);
}
}
Imaginemos que tenemos una página que contiene un select
desde la que podemos elegir un usuario (la caja se rellena desde un array usando *ngFor
).
Ahora también hay otro select
con los mismos usuarios, pero ahora la selección de algunos de ellos los pondrá en la lista negra, por lo que no se permite su envío, y si el usuario elige uno de los primeros select
el Submit
se desactivará.
Pero espera hay otro truco: también existe esta casilla de verificación "permitir usuarios en la lista negra", que, cuando se selecciona, permitirá tanto poner a alguien en la lista negra como enviarlo (por lo que el botón no se desactivará aunque algunos de los usuarios seleccionados estén en la lista negra).
Vamos a intentar implementarlo sin usar RxJS en absoluto, con modelos de formularios sencillos basados en plantillas:
Así que tenemos dos matrices, tres enlaces de formulario, y otro método que está siendo llamado en (ngModelChange)
para manejar el cambio de nuestro estado.
Este es un ejemplo clásico del pensamiento de 4 pasos que mencioné anteriormente, que nos hace escribir un código más enrevesado.
- Creemos que necesitamos algún estado (
isUserBlackListed
yisUserBlackListed
) - Escribimos esas propiedades de estado en nuestro componente y las vinculamos utilizando
[(ngModel)]
- Escribimos un método
changeUser
para manejar ese cambio de estado. - También dejamos algo de lógica en la plantilla
([disabled]=”isUserBlackListed && !allowBlackListedUsers”
)
¿Por qué es esto "malo"? Para empezar, seguir la lógica de la aplicación se hace más difícil. Por ejemplo, si yo soy el que lee este código, y veo que un botón a veces se desactiva, seguiré los siguientes pasos:
- Encuentra el
[disabled]
y ver que se vincula a dos propiedades,isUserBlackListed
yallowBlackListedUsers
- Búscalos en el código del componente y verás que son sólo algunas propiedades básicas, empezando por false.
- A continuación, busca en el
component.ts
esto puede parecer una obviedad, pero ¿qué pasa si nuestras propiedades son referenciadas en múltiples métodos? Tendría que examinarlos cuidadosamente todos para saber exactamente cuál afecta al botón que se está desactivando. - Lea y comprenda el método que finalmente encuentre. En nuestro ejemplo, es fácil; en un componente de la vida real la lógica puede estar lejos de ser trivial.
Y otra desventaja es que cuando surja otra pieza de esa lógica, acabaremos multiplicando las propiedades y los métodos que las modifican en el código de nuestro componente.
Entonces, ¿qué debemos hacer?
Más pensamiento reactivo
Ahora vamos a idear un sencillo plan de pensamiento en tres pasos. Tratando de resolver el mismo problema, pero ahora usando Formas Reactivas y RxJS, haremos lo siguiente
- Entender qué parte del estado afecta a la UI y convertirlo en un flujo Observable.
- Utilizar los operadores de RxJS para realizar los cálculos y derivar el estado final que se utilizará en la UI.
- Utilizar la tubería asíncrona para poner el resultado de nuestros cálculos en la plantilla.
Aquí está la implementación:
import { Component } from '@angular/core';
import { FormControl } from '@angular/forms';
import { combineLatest } from 'rxjs';
import { map, startWith } from 'rxjs/operators';
@Component({
selector: 'my-app',
template: `
<select [formControl]="selectedUserId">
<option>Select a User</option>
<option *ngFor="let user of users" [value]="user.id">{{ user.name }}</option>
</select>
<select [formControl]="blackListedUsers" multiple>
<option *ngFor="let user of users" [value]="user.id">{{ user.name }}</option>
</select>
Allow black listed users <input type="checkbox" [formControl]="allowBlackListedUsers"/>
<button [disabled]="isDisabled$ | async">Submit</button>
`,
styleUrls: [ './app.component.css' ]
})
export class AppComponent {
users = [
{name: 'John', id: 1},
{name: 'Andrew', id: 2},
{name: 'Anna', id: 3},
{name: 'Iris', id: 4},
];
blackListedUsers = new FormControl([]);
selectedUserId = new FormControl(null);
allowBlackListedUsers = new FormControl(false);
isDisabled$ = combineLatest([
this.allowBlackListedUsers.valueChanges.pipe(startWith(false)),
this.blackListedUsers.valueChanges.pipe(startWith([])),
this.selectedUserId.valueChanges.pipe(startWith(null), map(id => +id)),
]).pipe(
map(
([allowBlackListed, blackList, selected]) => !allowBlackListed && blackList.includes(selected),
),
)
}
Como puedes ver, hemos implementado una propiedad, que es un Observable, para manejar algo de UI. Combina la salida de nuestros tres controles de formulario utilizando combineLatest
, y luego utiliza su salida combinada para derivar el estado booleano.
Nota: usamos startWith
porque en Angular formControl.valueChanges
no empieza a emitir hasta que el usuario lo cambia manualmente a través de los controles UI, o imperativamente a través de setValue
y combineLatest
no se dispara hasta que todos los Observables fuente emiten al menos una vez; así que hacemos que todos emitan sus valores por defecto una vez inmediatamente.
Ahora, cuando leo la plantilla de este componente y pienso "¿cuándo se desactiva este botón?
- Ver el
[disabled]="isDisabled$ | async"
vinculante; el signo de dólar al final delatará inmediatamente que se trata de un Observable. - Ve a la definición de esa propiedad y verás que es una combinación de tres fuentes de datos.
- Mira cómo los datos se convierten en un booleano.
Y eso es todo. El isDisabled$
El observable no es referenciado en ningún otro método, y aunque lo fuera, no importaría otros pueden suscribirse a él, pero no pueden cambiar sus datos.
Si hay un error y el botón está deshabilitado cuando no debería estarlo (o viceversa), entonces podemos estar 100% seguros de que el error está en la definición de isDisabled$
y sus operadores y en ningún otro lugar.
Así que este cambio hizo que en nuestro código sea:
- Más fácil de buscar y encontrar algo dentro.
- Más conciso; las piezas de lógica que se afectan mutuamente se recogen en un solo lugar y no se dispersan por el componente.
- Declarativo en lugar de imperativo.
Y todo ello gracias a un simple concepto: Las propiedades son más fáciles de razonar que los métodos
Hasta aquí todo bien. Pero, ¿cuáles son otros ejemplos de cómo el uso de RxJS puede mejorar nuestro código en Angular?
Conmutación de estados
Hay muchos ejemplos de cómo dos datos son interdependientes; uno puede cambiar al otro y el segundo puede cambiar al primero.
He aquí un ejemplo vivo: imaginemos que tenemos un componente que tiene un botón de búsqueda; siempre que hacemos clic en él, aparece una entrada de búsqueda justo al lado; cuando hacemos clic en otro lugar, desaparece, pero no si ya hay algo escrito dentro.
Algo así como la entrada de búsqueda que tiene el propio Medium compruébalo, vamos a construir uno ahora.
De nuevo, vamos a construirlo sin RxJS primero. Aquí está la implementación:
@Component({
selector: 'my-app',
template: `
<button (click)="showSearchInput($event)">Search</button>
<input [(ngModel)]="query" *ngIf="isSearchInputVisible"/>
`,
styleUrls: [ './app.component.css' ]
})
export class AppComponent {
query = '';
isSearchInputVisible = false;
showSearchInput(event: MouseEvent) {
event.stopPropagation();
this.isSearchInputVisible = true;
}
@HostListener('document:click')
hideSearchInput() {
if (this.query === '') {
this.isSearchInputVisible = false;
}
}
}
Esto es lo que hace: almacenamos un trozo de estado (booleano llamado isSearchInputVisible), luego lo conmutamos usando dos métodos diferentes, uno en el evento de clic del botón, el otro en los clics generales del documento. También detenemos la propagación al hacer clic en el botón para que no se confunda con otros clics en otras partes de los documentos: ¡se supone que abre la entrada de búsqueda, no la cierra!
Es una implementación ingenua, pero es lo que uno haría al hacerlo sin RxJS. Ahora incorpora en sí misma los 4 pasos erróneos que describí en la primera parte de este artículo. Bien, ahora hagámoslo usando RxJS:
@Component({
selector: 'my-app',
template: `
<button #btn>Search</button>
<input [(ngModel)]="query" *ngIf="isSearchInputVisible$ | async"/>
`,
styleUrls: [ './app.component.css' ]
})
export class AppComponent implements AfterViewInit {
@ViewChild('btn', {static: true}) buttonRef: ElementRef<HTMLButtonElement>;
query = '';
isSearchInputVisible$: Observable<boolean> = of(false);
ngAfterViewInit() {
this.isSearchInputVisible$ = merge(
fromEvent(this.buttonRef.nativeElement, 'click').pipe(tap(e => e.stopPropagation()), mapTo(true)),
fromEvent(document.body, 'click').pipe(filter(() => this.query === ''), mapTo(false))
).pipe(startWith(false));
}
}
Aquí lo tenemos: Tenemos una única fuente de verdad, sin métodos, y toda la lógica proviene de estos operadores sobre las fuentes observables. Aquí está una explicación de lo que hemos hecho:
- Primero tomamos dos flujos: Los clics en el botón y los clics en el documento completo.
- En el primer flujo los clics de los botones primero llamamos a
stopPropagation
y luego lo mapeamos para que seatrue
- El segundo flujo los clics en todo el documento se asigna simplemente al valor
false
pero no cuando elquery
en no vacío (de ahí elfilter
operador) - El
merge
de estas dos corrientes es exactamente lo que queremos: ¡el botón abre la entrada de búsqueda y el resto la cierra!
Puedes preguntarte...
- ¿Por qué lo pusimos dentro
ngAfterViewInit
?
Porque el buttonRef
no estará disponible hasta que se pinte la vista, por lo que tendremos que esperar hasta después de ese momento para poder leer los eventos de la misma.
2. ¿Por qué necesitamos .pipe(startsWith(false))
después del merge Observable
?
Si no lo hemos hecho, el valor del Observable
habría cambiado de undefined
a false
demasiado rápido, lo que resulta en un ExpressionChangedAfterItHasBeenCheckedError
¿No nos olvidamos de algo?
Es posible que piense que hay que darse de baja de nuestro Observable
, pero de hecho no lo hacemos el async
el pipe lo hace por nosotros.
Y una vez más, convertir nuestra lógica de estilo imperativo a declarativo usando RxJS mejoró nuestro código.
En conclusión
RxJS es una herramienta poderosa no es de extrañar que un marco de trabajo empresarial tan grande como Angular se construya en torno a ella; Tiene un montón de conceptos y trucos que se pueden utilizar para hacer que nuestro código en Angular sea mejor, más legible, más fácil de razonar, más comprensible.