En las buenas prácticas de Angular se recomienda liberar a los componentes de cualquier lógica no relacionada con la vista. Debes mover toda esa lógica a un servicio.
Cuando trabajas con servicios que ya existen (por ejemplo el servicio Http
) todo es muy bonito. Pero cuando creas tus propios servicios… eso es diferente.
De entrada parece sencillo, pero pronto te darás cuenta de que para pasar datos del servicio hacia el componente, necesitas entender bien RxJS. Y es que la programación reactiva está fuertemente recomendada por Angular: Los Observables
son el compañero de viaje habitual de los servicios.
La cuestión es esta: Es probable que la lógica que quieres mover al servicio, emita algún evento de salida (EventEmitter
).
El problema es que EventEmitter
no está pensado para servicios. Lo ideal es que la API del servicio disponga de un Observable
donde se transmita el evento. Así te podrás suscribir desde el componente.
Entendiendo el problema
Para transmitirte mejor esta problemática te voy a poner un ejemplo muy simple (en Angular 4): Tengo un contador que realiza una cuenta atrás, es extremadamente sencillo.
El componente en sí tiene una entrada init
con los segundos iniciales y una salida onComplete
para avisar que ha completado la cuenta atrás.
Se usa así:
<app-timer init="5" (onComplete)="logCompleted()"></app-timer>
He movido la lógica del temporizador a un servicio, pero me falta emitir ese evento onComplete
. Mi componente ahora mismo tiene esta pinta:
//src/app/timer/timer.component.ts
//...some imports...
@Component({
selector: 'app-timer',
templateUrl: './timer.component.html',
providers: [TimerService]
})
export class TimerComponent implements OnInit, OnDestroy {
@Input() init:number = 0;
@Output() onComplete = new EventEmitter<void>();
//TODO: emit event when count ends with this.onComplete.emit();
constructor(public timer:TimerService){}
ngOnInit(){
this.timer.restartCountdown(this.init);
}
ngOnDestroy(){
this.timer.destroy();
}
}
El servicio por su parte es también muy simple y solo contiene la lógica del temporizador. Te muestro su estructura para que te sitúes:
// src/app/timer/timer.service.ts
import { Injectable } from '@angular/core';
@Injectable()
export class TimerService {
private countdownTimerRef:any = null;
private init:number = 0;
public countdown:number = 0;
constructor() { }
public restartCountdown(init?){
//restart the countdown
}
public destroy(){
//clean timeout reference
}
private doCountdown(){
//call process countdown after 1 second
}
private processCountdown(){
//check if countdown has finished
//HERE I SHOULD EMIT THE EVENT
}
private clearTimeout(){
//remove countdown reference
}
}
Puedes ver que hay un método que comprueba si la cuenta atrás ha acabado (processCountdown
). Ahí es donde necesito emitir el evento.
Parece bastante claro que si el servicio tiene un objeto Observable
, podrás suscribirte en el componente. Así, al acabar la cuenta atrás puedes emitir un evento desde el servicio, detectarlo en el componente y usar ahí el EventEmitter
.
¿Como generar eventos en un Observable?
La clave aquí es la clase Subject
.
Los Subjects
son Observables
que además pueden manejar múltiples suscripciones a un único flujo y son capaces de emitir eventos.
Como los eventos solo los quieres generar a nivel interno, lo que debes hacer es crear un Subject
privado, y exponer un Observable
público con el flujo del primero.
// src/app/timer/timer.service.ts
import { Injectable } from '@angular/core';
import { Subject } from "rxjs/Subject";
@Injectable()
export class TimerService {
//...other properties...
private countdownEndSource = new Subject<void>();
public countdownEnd$ = this.countdownEndSource.asObservable();
//...methods...
}
Para emitir un nuevo valor en el flujo de datos que maneja el Observable
, tienes que usar el método next
del Subject
.
Esto es justo lo que hago en el método processCountdown
de mi servicio, cuando la cuenta atrás llega a cero:
// src/app/timer/timer.service.ts
//...imports...
@Injectable()
export class TimerService {
//...other stuff...
private processCountdown(){
if(this.countdown == 0){
this.countdownEndSource.next();
}
else{
this.doCountdown();
}
}
Suscribirse a un observable
El resto es coser y cantar y seguro que ya lo has hecho alguna vez.
Desde el componente lo que tienes que hacer es suscribirte a ese observable y actuar cuando recibas el evento.
No olvides cancelar la suscripción al destruir el componente. Para eso, deberás obtener una referencia a la suscripción con un objeto del tipo
Subscription
.
Así es como queda mi componente:
// src/app/timer/timer.component.ts
import { Component, OnInit, OnDestroy, Input, Output, EventEmitter } from '@angular/core';
import { TimerService } from "app/timer/timer.service";
import { Subscription } from "rxjs/Subscription";
@Component({
selector: 'app-timer',
templateUrl: './timer.component.html',
providers: [TimerService]
})
export class TimerComponent implements OnInit, OnDestroy {
@Output() onComplete = new EventEmitter<void>();
@Input() init:number = 20;
private countdownEndRef: Subscription = null;
constructor(public timer:TimerService){}
ngOnInit(){
this.timer.restartCountdown(this.init);
this.countdownEndRef = this.timer.countdownEnd$.subscribe(()=>{
this.onComplete.emit();
})
}
ngOnDestroy(){
this.timer.destroy();
this.countdownEndRef.unsubscribe();
}
}
Programación reactiva
Hasta ahora la cuenta atrás la cojo directamente en el template del componente, accediendo a timer.countdown
.
Esto, no es muy eficiente, ya que es angular quien todo el rato tiene que comprobar si el valor de countdown
ha cambiado. Sería mejor que el propio servicio me avisara cuando el valor ha cambiado. Esto es lo que se conoce como Programación Reactiva.
Los observables combinan especialmente bien con una estrategia de ChangeDetectionPush para reducir el número de comprobaciones que hace Angular.
Esto, si eso, lo explico en detalle otro día 😉
Puedo seguir la misma estructura que antes para que el servicio exponga un Observable
con el flujo de la cuenta atrás. El componente a su vez, solo tendrá que suscribirse a este objeto.
Componentes Angular Nivel PRO
Aquí aparece un nuevo problema, claro. Antes tenía un estado permanente. Podía consultar el valor de la cuenta en cualquier momento. En cambio, usando un Subject
solo sabría el valor en el momento en que recibo el evento.
Ese problema tiene solución. RxJS proporciona una variante de Subject
que justamente sirve a este objetivo, el BehaviorSubject
.
Subject VS BehaviorSubject
Un BehaviorSubject
es como un Subject
, salvo que tiene noción de su estado.
Básicamente se diferencian en que el BehaviorSubject
:
- Siempre tiene un valor (por eso, al crearlo lo tendrás que inicializar).
- En el momento de la suscripción, recibes el último valor disponible.
- Puedes obtener su valor en cualquier momento con el método
getValue()
Usando BehaviourSubject
en un servicio
Voy a enseñarte como reemplazar la propiedad countdown
del servicio (que mantiene el estado de cuenta atrás), por un BehaviorSubject
.
Lo primero es crear el BehaviorSubject
y su Observable
.
// src/app/timer/timer.service.ts
//...other imports...
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
@Injectable()
export class TimerService {
private countdownSource = new BehaviorSubject<number>(0);
public countdown$ = this.countdownSource.asObservable();
//...more stuff..
}
Como ves, al crear el BehaviorSubject
le tengo que pasar un valor para inicializarlo.
Vamos con el método restartCountdown
. Donde antes actualizaba el valor inicial haciendo this.countdown = this.init
, ahora lo hago con this.countdownSource.next(this.init)
.
// src/app/timer/timer.service.ts
//...
restartCountdown(init?){
if(init)
this.init = init;
if(this.init && this.init >0){
this.clearTimeout();
this.countdownSource.next(this.init);
this.doCountdown();
}
}
//...
También actualizo el método doCountdown
para obtener y decrementar la cuenta con getValue
:
// src/app/timer/timer.service.ts
//...
private doCountdown(){
if(this.countdownSource.getValue() > 0){
this.countdownTimerRef = setTimeout(()=>{
this.countdownSource.next(this.countdownSource.getValue() -1);
this.processCountdown();
}, 1000);
}
}
//...
Hago lo propio en el método processCountdown
para obtener el valor de la cuenta:
// src/app/timer/timer.service.ts
//...
private processCountdown(){
if(this.countdownSource.getValue() <= 0){
this.countdownEndSource.next();
}
else{
this.doCountdown();
}
}
//...
¡Listo! Ya solo falta actualizar el componente.
Podría suscribirme como antes al Observable
y asignar los resultados a una nueva propiedad que leería desde el template.
Voy a hacer algo mejor: Le voy a ceder todo ese trabajo a una pipe.
AsyncPipe
La AsyncPipe
de Angular es una herramienta muy potente cuando trabajas de forma reactiva.
Esta pipe lo que hace es suscribirse a un Observable
y devolver el último valor emitido. Además, cuando el componente se destruye, la pipe cancela la suscripción por ti.
Así, el único cambio que necesita mi componente para funcionar con este contador reactivo, es a nivel de template.
Fíjate en como uso la pipe en el binding al input time
de <app-display>
:
<!-- src/app/timer/timer.component.html -->
<div class="timer">
<app-display [time]="timer.countdown$ | async"></app-display>
<button (click)="timer.restartCountdown()">RESTART</button>
</div>
¡Y eso es todo!
Esto y otras muchas buenas prácticas las encontrarás en mi curso Componentes en Angular – nivel PRO
Con estos cambios, he conseguido que el componente pase a usar la cuenta atrás de forma totalmente reactiva, sin necesidad de que Angular compruebe continuamente la variable countdown
.
¿Quieres jugar con el código fuente del ejemplo?
Lo tienes en este repositorio.
Conclusiones
Sacar la lógica de negocio del componente, para meterla en un servicio es algo recomendable. Te facilitará la lectura de código y simplificará su testabilidad. Además, como has visto, Angular utiliza herramientas muy potentes como RxJS que te facilitan la programación reactiva, lo que en sí mismo, también es una buena práctica.
Créeme, cuando crezca tu aplicación, agradecerás haber seguido esta aproximación. Tener un buen rendimiento o no, puede depender de ello.
Enorme post!! Muchas gracias.
¡Gracias!
Inmejorable post!!! mis felicitaciones!
Que tal Enrique, Excelente post! estoy haciendo un crud, aplicaria lo mismo para recargar los datos cuando hago un post?
Mmmm, explícate, ¿qué es lo que quieres saber si aplica, y a qué situación concretamente?
¡Saludos!
Excelente articulo, mil gracias Enrique
Buensimo Enrique, me fue de mucha ayuda.
¡Me alegro, gracias!
Genial!
Seria excelente tenerlo en github o el codigo para descargar
en que articulo puedo conseguir la lista de las buenas practicas en angular5?
EXCELENTE EXPLICACIÓN GRACIAS POR BRINDARNOS INFORMACIÓN
Que quieres decir cuando pones que no queremos emitir eventos desde el componente por eso el subject es privado y creamos el observable desde el.
Que tipo de eventos podriamos emitir desde el componente en el caso de que fuera publico?
Hola, no es posible compartir mejor un servicio entre todos los componentes pasando las variables por referencia? sin utilizar rxjs? tan solo con cambiar un valor en un componente se cambiaría en el otro. Sin utilizar BehaviourSubject.
Muy buen árticulo!! Te felicito! Me dejó un panorama más claro sobre la implementación de la programación reactiva en Angular. Construí mi propia versión del temporizador, basandome en tus sugerencias. Sería un gran aporte para mi si pudieras mirar mi código y decirme si he implementado bien los conceptos que aquí tratas. El link de mi repositorio es: https://gitlab.com/ac-angular/reactive-timer . Saludos!
buenas, yo tengo una pregunta, cuando yo ejecuto un observable dentro del metodo ngOnInit, mediante una subscripcion, porque se ejecuta las veces que sea necesario ignorando la regla de dicho metodo (solo se ejecuta una vez, y es cuando se inicializa el componente)
Aún en 2021 (mayo), tu explicación no pierde vigencia, es muy clara. Muchas gracias por ponerla en común!