En mi artículo anterior te explicaba como suscribirte con facilidad al evento scroll en Angular. El mecanismo es muy sencillo, pero en ocasiones puede que se te quede corto.
Imagina que quieres llamar a un método muy costoso cada vez que detectas cambios de scroll en el documento. ¿Y si no necesitas que tu método se llame a cada maldito incremento de un pixel? ¿Y si prefieres hacerlo a intervalos mayores para optimizar el rendimiento?
Para solucionar esta situación, te propongo un servicio de scroll global con un mecanismo tipo debounce. Además, como añadido, te podrás suscribir desde varios componentes sin ningún esfuerzo.
¿Que vas a construir?
El objetivo de este tutorial es que acabes creando un servicio de scroll que filtra parte de los eventos que recibe, con el objetivo de reducir la frecuencia de eventos de scroll. Concretamente, voy a explorar el operador auditTime
de RxJS.
El servicio expondrá un observable, de modo que te podrás suscribir al mismo del siguiente modo:
this.scrollService.scroll$.subscribe(scroll => {
console.log("scroll value is: ", scroll);
})
Servicio básico de evento scroll
Voy a empezar con lo mínimo para crear un servicio de scroll básico, basado en RxJS para usar Observables. Eso si, sin debounce ni nada.
El código del servicio sería este:
// src/app/window-scroll.service.ts
import { Injectable, Inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { fromEvent , Observable } from 'rxjs';
import { map, share } from 'rxjs/operators';
@Injectable()
export class WindowScrollService {
public scroll$:Observable<number>;
constructor(@Inject(DOCUMENT) private document:any){
this.scroll$ = fromEvent(window, 'scroll').pipe(
map(event =>{
return window.scrollY || this.document.documentElement.scrollTop;
}),
share()
);
}
}
El funcionamiento es simple:
- Creo una propiedad pública
scroll$
, de tipoObservable
, que emite números (la posición de scroll). - Necesito acceder a los objetos globales
document
ywindow
, para poder suscribirme al evento scroll y acceder a información relacionada.- Para el objeto
document
, Angular proporciona una referencia que puedo inyectar por DI. Esta es la forma recomendada de acceder a variables globales, que pueden no estar disponibles en determinados contextos (servidor, service-workers, etc) - Para el objeto
window
, como Angular no me da ninguna referencia, utilizo directamente el objeto global. Esto provocaría un error en entorno servidor, por ejemplo. Luego veremos como prevenirlo.
- Para el objeto
- Uso el método
fromEvent
para crear un nuevoObservable
a partir del evento scroll del objetowindow
. De este modo, cuando te suscribas ascroll$
, te estarás suscribiendo a través suyo al evento scroll dewindow
. - Utilizo el operador
map
para devolver la información que me interesa del scroll. En este caso el valorwindow.scrollY
, o en caso de no existir (dependerá del navegador), el valor dedocument.documentElement.scrollTop
. - Finalmente, el operador
share
evita que se dupliquen suscripciones al evento scroll dewindow
cada vez que un elemento se suscribe al servicio.
Compatible con SSR
El código anterior petaría en entorno servidor porque window
no está definido en Node. Para prevenir este inconveniente, voy a añadir un par de cambios:
// src/app/window-scroll.service.ts
import { Injectable, PLATFORM_ID, Inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { isPlatformBrowser } from '@angular/common';
import { fromEvent , Observable, empty } from 'rxjs';
import { map, share } from 'rxjs/operators';
@Injectable()
export class WindowScrollService {
public scroll$:Observable<number>;
constructor(
@Inject(DOCUMENT) private document:any,
@Inject(PLATFORM_ID) private platformId: Object
){
if(isPlatformBrowser(this.platformId)){
this.scroll$ = fromEvent(window, 'scroll').pipe(
map(event =>{
return window.scrollY || this.document.documentElement.scrollTop;
}),
share()
);
}
else{
this.scroll$ = empty();
}
}
}
Fíjate que tienes casi lo mismo, pero le pides a Angular que te inyecte el tipo de plataforma (@Inject(PLATFORM_ID) private platformId: Object
), y de este modo, antes de suscribirte a windows
, compruebas si efectivamente te encuentras en el navegador con isPlatformBrowser(this.platformId)
.
Además, en caso contrario estás asignando un Observable vacío a scroll$
. Así te puedes suscribir tranquilamente a scroll$
en cualquier entorno sin tener que ir usando isPlatformBrowser
.
A continuación tienes un proyecto extremadamente simple, usando StackBlitz, para ver el resultado.
Añadiendo «debounce» al servicio
El concepto debounce
es bastante conocido en el mundo del software, y se refiere a la característica de «descartar valores emitidos previos al último, cuando pasa menos de un cierto umbral de tiempo entre ellos».
La misma libería RxJS proporciona un mecanismo de debounce
en función del tiempo: debounceTime(ms)
, e ilustra su mecanismo con un gráfico de rxmarbles que es bastante fácil de entender.
En realidad no es esto lo que quiero. Con debounceTime
no se emite ningún valor hasta que no pasa un cierto tiempo entre dos muestras consecutivas, así que mientras tengas muestras muy seguidas (si haces scroll de forma continua), no vas a recibir nada.
Lo que yo quiero, en realidad, tiene otro nombre mucho menos conocido y es lo que hace el operador auditTime
de RxJS.
El operador auditTime
crea ventanas temporales dentro de las cuales únicamente emite el último valor recibido. Es decir, si defino un auditTime
de 200ms, solo recibiré eventos de scroll cada 200ms (en el caso de que haya algún evento de scroll que recibir). Si conoces el operador throttleTime
, auditTime
hace lo mismo pero al revés (devuelve el último valor, en lugar del primero).
Tomando cómo referencia el código anterior, sería muy sencillo reducir la frecuencia de los eventos de scroll con auditTime
. Fíjate en como añado auditTime
al stream de observables, antes del método map
.
// src/app/window-scroll.service.ts
import { Injectable, PLATFORM_ID, Inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { isPlatformBrowser } from '@angular/common';
import { fromEvent , Observable, empty } from 'rxjs';
import { map, share, auditTime } from 'rxjs/operators';
@Injectable()
export class WindowScrollService {
public scroll$:Observable<number>;
constructor(
@Inject(DOCUMENT) private document:any,
@Inject(PLATFORM_ID) private platformId: Object
){
if(isPlatformBrowser(this.platformId)){
this.scroll$ = fromEvent(window, 'scroll').pipe(
auditTime(200),
map(event =>{
return window.scrollY || this.document.documentElement.scrollTop;
}),
share());
}
else{
this.scroll$ = empty();
}
}
}
Reflexiones personales
En este artículo se tocan varios conceptos interesantes. Desde la inyección de dependencias de variables globales y como evitar errores en entornos servidor, a algunos métodos y operadores interesantes de RxJS, como fromEvent
, empty
, debounceTime
o auditTime
.
El código de ejemplo es muy sencillo, pero es suficiente para entender una problemática habitual. No dudes en meterle mano a StackBlitz para jugar con los fuentes.
Hay mil formas distintas de solucionar los problemas. Esta es bastante elegante, pero ¡ojo! todo tiene sus pros y contras. En este caso, por ejemplo, auditTime va ligado intrínsecamente a un temporizador, así que como en setTimeout
, auditTime(X)
lo único que garantiza es que el intervalo va a ser, como mínimo, de X ms. Esto significa que puedes encontrarte situaciones en que tu CPU esté ocupada y decida alargar ese intervalo, cosa que quizá no te interesa (podrías percibir un retraso demasiado grande, frente al que esperabas).
En todo caso, todo depende del uso que vayas a darle.
¿Te ha gustado este artículo? No te cortes, déjame un comentario y ayúdame a compartirlo 😉