Neste artigo especial de natal será demonstrado como o Rxjs pode ajudar o desenvolvedor a lidar elegantemente com eventos do DOM.

Contexto

Temos uma aplicação hipotética construída com HTML, CSS e JavaScript. Ela alerta o usuário que estiver inativo por mais de cinco minutos que sua sessão vai expirar. Neste contexto, a aplicação precisa monitorar o clique, mover do mouse e o pressionar de qualquer tecla, detectando assim atividade por parte do usuário e consequentemente reiniciando o timer de inatividade.

Vamos começar pelo trecho de código responsável por reiniciar o timer de inatividade:

const startWatching = () => {
    document.addEventListener('click', resetTimer);
    document.addEventListener('mousemove', resetTimer);
    document.addEventListener('keypress', resetTimer);
}
const resetTimer = () => { /* reseta o timer da sessão, código omitido */ };
startWatching();

Um olhar atento no código que acabamos de ver verificará que ele possui um problema de performance. Cada coordenada alterada do mouse na tela dispara a chamada de resetTimer, isto é, desligará o timer atual criando um novo logo em seguida. A mesma coisa para cada tecla pressionada pelo usuário, assim como cliques. Uma solução é aplicar o debounce pattern. Porém, em vez de implementarmos nossa própria função debounce, utilizaremos o operador debounceTime do Rxjs.

Lidando com elementos do DOM através do Rxjs

Primeiro, precisamos tratar os eventos que vimos até agora através da função fromEvent do Rxjs. Esta função criará um Observable que emitirá valores toda vez que um evento for disparado:

// importando fromEvent do pacote rxjs
import { fromEvent } from 'rxjs';

const startWatching = () => {
    fromEvent(document, 'click')
        .subscribe(resetTimer);
    fromEvent(document, 'mousemove')
        .subscribe(resetTimer);
    fromEvent(document, 'keypress')
        .subscribe(resetTimer);
}
const resetTimer = () => /* reseta o timer da sessão */
startWatching();

A função fromEvent recebe como primeiro parâmetro o elemento que desejamos associar um handler para um evento e o segundo é o tipo de evento em si. Como o método retorna um Observable, nos inscrevemos nele através do método subscribe, que recebe o handler do evento. Quando cada um dos eventos for disparado, a função resetTimer passada para subscribe será executada. Excelente, mas até agora trocamos seis por meia dúzia. O maior benefício vem com a aplicação do operador debounceTime:

import { fromEvent } from 'rxjs';
import { debounceTime } from 'rxjs/operators';

const startWatching = () => {
    fromEvent(document, 'click')
        .pipe(debounceTime(200))
        .subscribe(resetTimer);
    fromEvent(document, 'mousemove')
        .pipe(debounceTime(200))    
        .subscribe(resetTimer);
    fromEvent(document, 'keypress')
        .pipe(debounceTime(200))    
        .subscribe(resetTimer);
}
const resetTimer = () => /* reseta o timer da sessão */
startWatching();

Perfeito, mas se não quisermos mais responder aos eventos que acabamos de nos inscrever? Precisaremos guardar o retorno de cada chamada à função fromEvent em uma variável. O retorno nada mais é do que um objeto do tipo Subscription, aquele que nos permitirá cancelar nosso registro ao Observable:

import { fromEvent } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
// variáveis que guardaram cada Subscription
let clickSubscription = null;
let mouseMoveSubscription = null;
let keyPressSubscription = null

const startWatching = () => {
    clickSubscription = fromEvent(document, 'click')
        .pipe(debounceTime(200))
        .subscribe(resetTimer);
    mouseMoveSubscription = fromEvent(document, 'mousemove')
        .pipe(debounceTime(200))    
        .subscribe(resetTimer);
    keyPressSubscription = fromEvent(document, 'keypress')
        .pipe(debounceTime(200))    
        .subscribe(resetTimer);
}

// alterou o método stopWatching também!
const stopWatching = () => {
    clickSubscription.unsubscribe();
    mouseMoveSubscription.unsubscribe();
    keyPressSubscription.unsubscribe();
}
const resetTimer = () => /* reseta o timer da sessão */
startWatching();

Nossa solução funciona, mais não é nada elegante. Primeiro, estamos repetindo a mesma chamada de resetTimer em cada uma das inscrições dos observables. Além disso, foi necessário declararmos variáveis mutáveis para guardarem as inscrições de cada Observable. São essas variáveis que serão utilizadas pela função stopWatching. Com certeza podemos melhorar o código escrito até agora.

Primeiro, vamos tentar evitar a repetição da chamada de subscribe:

import { fromEvent } from 'rxjs';
import { debounceTime } from 'rxjs/operators';

const startWatching = () => {
    ['click', 'mousemove', 'keypress']
        .forEach(event => 
            fromEvent(document, event)
                .pipe(debounceTime(200))
                .subscribe(resetTimer)
        );
}
const stopWatching = () => {
    /* como implementar ? */;
}
const resetTimer = () => /* reseta o timer da sessão */
startWatching();

Criamos um array com três elementos no qual cada um de seus elementos é o tipo do evento que desejamos escutar. Através de forEach, criamos um Observable para cada evento, aplicando o operador debounceTime e a chamada de subscribe uma única vez. Porém, temos um problema. Precisamos ter a capacidade de remover a inscrição de cada um dos três observables criados e da maneira que organizamos nosso código, isso não será possível. Como proceder?

A função merge

Uma solução para o problema é continuar criando um Observable para cada um dos eventos, mas tratando-os como um único Observable. Em outras palavras, o que queremos realizar é o merge de três observables. Conseguimos isso através da função merge do Rxjs:

import { fromEvent, merge } from 'rxjs';
// importou merge 
import { debounceTime } from 'rxjs/operators';

const startWatching = () => {
    const subscription = merge(
            // através do spread operador, passamos três 
            // observales para o métdo merge, em vez do array
            ...['click', 'mousemove', 'keypress']
                .map(event => fromEvent(document, event))
         )
         // neste ponto, temos três Observables mergeados
        .pipe(debounceTime(200))
        // o debounceTime será aplicado na emissão de cada Observable
        .subscribe(resetTimer);
    return subscription;
}
const resetTimer = () => { /* reseta o timer da sessão */ };
// guarda o retorno, a inscricão
const eventsSubscription = startWatching();
// utiliza a inscrição para cancelar a própria inscrição
const stopWatching = () => eventsSubscription.unsubscribe();

A função merge recebe um ou mais observables que no final serão tratados como um apenas. Isto é, independente de qual Observable emita um valor, seu resultado será disponibilizado para quem se inscrever no Observable gerado por merge. Através do spread operador garantimos que a função merge receberá três parâmetros em vez de um array de observables. Reparem teremos apenas uma Subscription que nos permitirá cancelar a inscrição dos três observables de uma só vez:

Podemos simplicar nosso código um pouco mais omitindo a declaração da variável subscription retornando diretamente o resultado de merge:

import { fromEvent, merge } from 'rxjs';
import { debounceTime,} from 'rxjs/operators';

const startWatching = () => 
    merge(
        ...['click', 'mousemove', 'keypress']
        .map(event => fromEvent(document, event))
    )
    .pipe(debounceTime(200))
    .subscribe(resetTimer);

const resetTimer = () => { /* reseta o timer da sessão */ };
const eventSubscriptions = startWatching();
const stopWatching = () => eventSubscriptions.unsubscribe();

Mas o que acontecerá se alguém chamar a função stopWatching mais de uma vez? Um erro, com certeza, pois não podemos chamar o método unsubscribe de uma Subscription mais de uma vez.

Podemos solucionar o problema citado no parágrafo anterior retornando uma função no lugar da Subscription. Esta função ao ser chamada garantirá que a inscrição será cancelada apenas uma vez:

import { fromEvent, merge } from 'rxjs';
import { debounceTime } from 'rxjs/operators';

const startWatching = () => {
    let subscription = merge(
        ...['click', 'mousemove', 'keypress']
            .map(event => fromEvent(document, event))
    )
    .pipe(debounceTime(200))
    .subscribe(resetTimer);

    return () => {
        // evita erro caso seja chamado duas vezes
        if(subscription) {
            subscription.unsubscribe();
            subscription = null;
        }
    }
}
const resetTimer = () => { /* reseta o timer da sessão */ }
const stopWatching = startWatching();
/* ... */
stopWatching(); // cancela as inscrições

Agora não temos mais variáveis mutáveis fora do escopo da função startWatching e garantimos que o cancelamento da inscrição não será realizado mais de uma vez.

Conclusão

A biblioteca Rxjs é muito poderosa, não é à toa que as extensões reativas estão presentes em diversas linguagens, não apenas em JavaScript.