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 tipo Observable, que emite números (la posición de scroll).
  • Necesito acceder a los objetos globales document y window, 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.
  • Uso el método fromEvent para crear un nuevo Observable a partir del evento scroll del objeto window. De este modo, cuando te suscribas a scroll$, te estarás suscribiendo a través suyo al evento scroll de window.
  • Utilizo el operador map para devolver la información que me interesa del scroll. En este caso el valor window.scrollY, o en caso de no existir (dependerá del navegador), el valor de document.documentElement.scrollTop.
  • Finalmente, el operador share evita que se dupliquen suscripciones al evento scroll de window 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.

debounceTime

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 😉