Skip to content

Pattern di elaborazione degli eventi UI ​

La gestione degli eventi dell'interfaccia utente è una delle sfide più frequenti nello sviluppo front-end. Con RxJS è possibile implementare la gestione di eventi complessi in modo dichiarativo e intuitivo.

Questo articolo descrive pattern specifici di gestione degli eventi dell'interfaccia utente necessari nella pratica, come clic, scorrimento, drag & drop e input da tastiera.

Cosa imparerete in questo articolo ​

  • Controllo degli eventi click (throttle, debounce, distinct)
  • Gestione efficiente degli eventi di scroll
  • Implementazione del drag & drop
  • Input da tastiera e completamento automatico
  • Supporto multi-touch
  • Combinazione di eventi compositi

Prerequisiti

Questo articolo presuppone la conoscenza del Capitolo 4: Operatori. In particolare, è importante la comprensione di debounceTime, throttleTime e distinctUntilChanged.

Gestione degli eventi click ​

Problema: esecuzione eccessiva a causa di clic ripetuti ​

I clic consecutivi su un pulsante possono provocare un'elaborazione ripetuta, causando problemi di prestazioni e bug.

Soluzione 1: controllo con throttleTime ​

Elabora solo il primo clic entro un certo periodo di tempo.

ts
import { fromEvent, throttleTime } from 'rxjs';
const button = document.createElement('button');
button.id = 'submit-button';
button.innerText = 'submit';
document.body.appendChild(button);

if (button) {
  fromEvent(button, 'click').pipe(
    throttleTime(1000) // Elabora solo una volta al secondo
  ).subscribe(() => {
    console.log('Esecuzione invio');
    submitForm();
  });
}

function submitForm(): void {
  console.log('Invio form in corso...');
  // Chiamata API, ecc.
}

Flusso di esecuzione ​

Clic dell'utente:    ●    ●●●        ●  ●●
                     |    |          |  |
throttleTime(1000):  ●              ●
                     |              |
                    Elabora       Elabora

Caratteristiche di throttleTime

  • Elabora il primo evento e ignora gli eventi successivi per un certo periodo
  • Adatto quando il tempo reale è importante (scroll, resize, ecc.)

Soluzione 2: controllo con debounceTime ​

Elabora dopo un certo tempo dall'interruzione degli eventi.

ts
import { fromEvent, debounceTime } from 'rxjs';

const searchInput = document.createElement('input');
searchInput.id = 'search';
searchInput.type = 'text';
searchInput.placeholder = 'Inserisci parola chiave...';
searchInput.style.padding = '8px';
searchInput.style.margin = '10px';
searchInput.style.width = '300px';
document.body.appendChild(searchInput);

fromEvent(searchInput, 'input').pipe(
  debounceTime(300) // Attendi 300ms dopo l'interruzione dell'input
).subscribe((event) => {
  const value = (event.target as HTMLInputElement).value;
  console.log('Esegui ricerca:', value);
  performSearch(value);
});

function performSearch(query: string): void {
  console.log('Ricerca in corso...', query);
  // Chiamata API di ricerca
}

Flusso di esecuzione ​

Input dell'utente:   ●●●●●     ●●        ●●●●
                          |            |      |
debounceTime(300):       300ms       300ms  300ms attesa
                          |            |      |
                        Elabora     Elabora  Elabora

Caratteristiche di debounceTime

  • Attende un certo tempo dopo l'ultimo evento prima di elaborare
  • Adatto per ricerca, autocompletamento, validazione in tempo reale

throttleTime vs debounceTime: quando usarli ​

UsoOperatore da usareMotivo
Input di ricercadebounceTimeVoglio cercare dopo che l'input si è fermato
AutocompletamentodebounceTimeMostrare i suggerimenti dopo l'interruzione dell'input
Eventi di scrollthrottleTimeVoglio elaborare periodicamente durante lo scroll
Ridimensionamento finestrathrottleTime o debounceTimeDipende dai requisiti
Prevenire clic ripetutithrottleTime o exhaustMapElaborare il primo clic immediatamente

Soluzione 3: deduplicazione con distinctUntilChanged ​

Confronta con il valore precedente e salta l'elaborazione se i valori consecutivi sono uguali.

ts
import { fromEvent, map, debounceTime, distinctUntilChanged } from 'rxjs';
const searchInput = document.createElement('input');
searchInput.id = 'search';
searchInput.type = 'text';
searchInput.placeholder = 'Inserisci parola chiave...';
searchInput.style.padding = '8px';
searchInput.style.margin = '10px';
searchInput.style.width = '300px';
document.body.appendChild(searchInput);

fromEvent(searchInput, 'input').pipe(
  map(event => (event.target as HTMLInputElement).value.trim()),
  debounceTime(300),
  distinctUntilChanged() // Ignora se uguale al precedente
).subscribe(query => {
  console.log('Esegui ricerca:', query);
  performSearch(query);
});

Esempio di esecuzione ​

ts
// Input utente: "RxJS" → Backspace → "RxJS"
// Senza distinctUntilChanged: ricerca eseguita 2 volte
// Con distinctUntilChanged: ricerca eseguita 1 volta (la seconda viene saltata perché è lo stesso valore)

Best Practice

Per l'implementazione di ricerca e autocompletamento, si raccomanda di usare questi 3 operatori insieme:

  1. debounceTime() - Attendere l'interruzione dell'input
  2. distinctUntilChanged() - Deduplicazione
  3. switchMap() - Annullare le vecchie richieste

Elaborazione eventi di scroll ​

Problema: attivazione eccessiva degli eventi di scroll ​

Gli eventi di scroll si attivano molto frequentemente e possono causare problemi di prestazioni se elaborati direttamente.

Soluzione: ridurre con throttleTime ​

ts
import { fromEvent, throttleTime, map } from 'rxjs';
const scrollContainer = document.createElement('div');
scrollContainer.id = 'scroll-container';
scrollContainer.style.width = '400px';
scrollContainer.style.height = '300px';
scrollContainer.style.overflow = 'auto';
scrollContainer.style.border = '1px solid #ccc';
scrollContainer.style.margin = '10px';
scrollContainer.style.padding = '10px';

// Aggiungere contenuto per renderlo scrollabile
scrollContainer.innerHTML = Array.from({ length: 100 }, (_, i) =>
  `<p>Elemento ${i + 1}</p>`
).join('');

document.body.appendChild(scrollContainer);

fromEvent(scrollContainer, 'scroll').pipe(
  throttleTime(100), // Elabora solo una volta ogni 100ms
  map(() => ({
    scrollTop: scrollContainer.scrollTop,
    scrollHeight: scrollContainer.scrollHeight,
    clientHeight: scrollContainer.clientHeight
  }))
).subscribe(({ scrollTop, scrollHeight, clientHeight }) => {
  // Calcolo della posizione di scroll
  const scrollPercentage = (scrollTop / (scrollHeight - clientHeight)) * 100;
  console.log(`Posizione scroll: ${scrollPercentage.toFixed(1)}%`);

  // Scroll infinito: carica la pagina successiva quando lo scroll supera il 90%
  if (scrollPercentage > 90) {
    console.log('Caricamento pagina successiva...');
    loadMoreItems();
  }
});

function loadMoreItems(): void {
  console.log('Recupero dati aggiuntivi');
}

Esempio pratico: rilevamento della direzione di scroll ​

ts
import { fromEvent, BehaviorSubject, throttleTime, map, pairwise, distinctUntilChanged } from 'rxjs';
type ScrollDirection = 'up' | 'down' | 'none';

const scrollDirection$ = new BehaviorSubject<ScrollDirection>('none');

// Creare l'elemento header dinamicamente
const header = document.createElement('div');
header.id = 'header';
header.innerText = 'Header (mostra/nascondi con lo scroll)';
header.style.position = 'fixed';
header.style.top = '0';
header.style.left = '0';
header.style.width = '100%';
header.style.padding = '20px';
header.style.background = '#333';
header.style.color = '#fff';
header.style.transition = 'transform 0.3s';
document.body.appendChild(header);

// Aggiungere contenuto scrollabile
const scrollContent = document.createElement('div');
scrollContent.style.marginTop = '80px';
scrollContent.innerHTML = Array.from({ length: 100 }, (_, i) =>
  `<p>Contenuto ${i + 1}</p>`
).join('');
document.body.appendChild(scrollContent);

fromEvent(window, 'scroll').pipe(
  throttleTime(100),
  map(() => window.scrollY),
  pairwise(), // Ottieni i valori precedente e attuale come coppia
  map(([prev, curr]) => {
    if (curr > prev) return 'down';
    if (curr < prev) return 'up';
    return 'none';
  }),
  distinctUntilChanged() // Notifica solo quando la direzione cambia
).subscribe(direction => {
  scrollDirection$.next(direction);
  console.log('Direzione scroll:', direction);

  // Mostra/nascondi header
  if (direction === 'down') {
    header.style.transform = 'translateY(-100%)';
  } else if (direction === 'up') {
    header.style.transform = 'translateY(0)';
  }
});

Utilizzo di pairwise

pairwise() è un operatore conveniente che permette di ottenere i valori precedente e attuale come coppia. Può essere usato per determinare la direzione di scroll, l'aumento/diminuzione dei valori, calcolare differenze, ecc.

Implementazione drag & drop ​

Problema: combinazioni complesse di eventi del mouse ​

Il drag & drop è una combinazione complessa di eventi mousedown → mousemove → mouseup.

Soluzione: combinare più Observable ​

ts
import { fromEvent, merge, map, switchMap, takeUntil, tap } from 'rxjs';
interface Position {
  x: number;
  y: number;
}

const draggableElement = document.createElement('div');
draggableElement.id = 'draggable';
draggableElement.innerText = 'Trascinami';
draggableElement.style.position = 'absolute';
draggableElement.style.left = '100px';
draggableElement.style.top = '100px';
draggableElement.style.width = '150px';
draggableElement.style.height = '150px';
draggableElement.style.padding = '20px';
draggableElement.style.background = '#4CAF50';
draggableElement.style.color = '#fff';
draggableElement.style.cursor = 'move';
draggableElement.style.userSelect = 'none';
draggableElement.style.display = 'flex';
draggableElement.style.alignItems = 'center';
draggableElement.style.justifyContent = 'center';
document.body.appendChild(draggableElement);

const mouseDown$ = fromEvent<MouseEvent>(draggableElement, 'mousedown');
const mouseMove$ = fromEvent<MouseEvent>(document, 'mousemove');
const mouseUp$ = fromEvent<MouseEvent>(document, 'mouseup');

// Ottieni la posizione dell'elemento all'inizio del drag
let initialX = 0;
let initialY = 0;

mouseDown$.pipe(
  tap((event: MouseEvent) => {
    event.preventDefault();
    // Registra la posizione attuale dell'elemento
    const rect = draggableElement.getBoundingClientRect();
    initialX = rect.left;
    initialY = rect.top;

    // Differenza con la posizione del mouse all'inizio del drag
    initialX = rect.left - event.clientX;
    initialY = rect.top - event.clientY;

    draggableElement.style.opacity = '0.7';
  }),
  switchMap(() =>
    // Quando si verifica mousedown, inizia a monitorare mousemove
    mouseMove$.pipe(
      map((event: MouseEvent): Position => ({
        x: event.clientX + initialX,
        y: event.clientY + initialY
      })),
      // Termina il monitoraggio con mouseup o mouseleave
      takeUntil(
        merge(
          mouseUp$,
          fromEvent(document, 'mouseleave')
        ).pipe(
          tap(() => {
            draggableElement.style.opacity = '1';
          })
        )
      )
    )
  )
).subscribe((position: Position) => {
  // Sposta l'elemento
  draggableElement.style.left = `${position.x}px`;
  draggableElement.style.top = `${position.y}px`;
});

Flusso degli eventi ​

Punti importanti per il drag & drop

  • switchMap per iniziare il monitoraggio mousedown → mousemove
  • takeUntil per terminare il monitoraggio al mouseup
  • preventDefault() per disabilitare il comportamento di drag predefinito
  • classList.add/remove per feedback visivo

Supporto dispositivi touch ​

ts
import { fromEvent, merge, map, switchMap, takeUntil, tap } from 'rxjs';
const draggableElement = document.createElement('div');
draggableElement.id = 'draggable';
draggableElement.innerText = 'Trascinami\n(mouse e touch)';
draggableElement.style.position = 'absolute';
draggableElement.style.left = '100px';
draggableElement.style.top = '100px';
draggableElement.style.width = '150px';
draggableElement.style.height = '150px';
draggableElement.style.padding = '20px';
draggableElement.style.background = '#2196F3';
draggableElement.style.color = '#fff';
draggableElement.style.cursor = 'move';
draggableElement.style.userSelect = 'none';
draggableElement.style.display = 'flex';
draggableElement.style.alignItems = 'center';
draggableElement.style.justifyContent = 'center';
draggableElement.style.textAlign = 'center';
draggableElement.style.whiteSpace = 'pre-line';
document.body.appendChild(draggableElement);

// Integrare eventi mouse e touch
const start$ = merge(
  fromEvent<MouseEvent>(draggableElement, 'mousedown').pipe(
    map(e => ({ x: e.clientX, y: e.clientY, event: e }))
  ),
  fromEvent<TouchEvent>(draggableElement, 'touchstart').pipe(
    map(e => ({
      x: e.touches[0].clientX,
      y: e.touches[0].clientY,
      event: e
    }))
  )
);

const move$ = merge(
  fromEvent<MouseEvent>(document, 'mousemove').pipe(
    map(e => ({ x: e.clientX, y: e.clientY }))
  ),
  fromEvent<TouchEvent>(document, 'touchmove').pipe(
    map(e => ({
      x: e.touches[0].clientX,
      y: e.touches[0].clientY
    }))
  )
);

const end$ = merge(
  fromEvent(document, 'mouseup'),
  fromEvent(document, 'touchend')
);

let initialOffsetX = 0;
let initialOffsetY = 0;

start$.pipe(
  tap(({ x, y, event }) => {
    event.preventDefault();
    const rect = draggableElement.getBoundingClientRect();
    initialOffsetX = rect.left - x;
    initialOffsetY = rect.top - y;
    draggableElement.style.opacity = '0.7';
  }),
  switchMap(() =>
    move$.pipe(
      map(({ x, y }) => ({
        x: x + initialOffsetX,
        y: y + initialOffsetY
      })),
      takeUntil(
        end$.pipe(
          tap(() => {
            draggableElement.style.opacity = '1';
          })
        )
      )
    )
  )
).subscribe(({ x, y }) => {
  draggableElement.style.left = `${x}px`;
  draggableElement.style.top = `${y}px`;
});

Supporto multi-dispositivo

Usando merge per integrare eventi mouse e touch, puoi implementare un drag & drop che funziona su PC/tablet/smartphone.

Input da tastiera e autocompletamento ​

Problema: chiamate API eccessive durante l'input ​

Quando le chiamate API vengono effettuate in risposta all'input da tastiera, come nella casella di ricerca, chiamarle ogni volta può causare problemi di prestazioni.

Ad esempio, quando l'utente digita "RxJS":

  • R → Chiamata API
  • Rx → Chiamata API
  • RxJ → Chiamata API
  • RxJS → Chiamata API

4 caratteri di input causano 4 chiamate API. Questo è uno spreco e carica anche il server.

Soluzione: debounceTime + switchMap ​

Per implementare l'autocompletamento in modo efficiente, combina questi 3 operatori:

  1. debounceTime(300) - Attendi 300ms dopo che l'utente ha smesso di digitare
  2. distinctUntilChanged() - Ignora se il valore è uguale al precedente (previene richieste inutili)
  3. switchMap() - Annulla la vecchia richiesta quando arriva un nuovo input

Con questa combinazione, l'API viene chiamata solo una volta dopo l'interruzione dell'input, anche se l'utente digita "RxJS" velocemente.

ts
import { fromEvent, of, map, debounceTime, distinctUntilChanged, switchMap, catchError } from 'rxjs';
interface SearchResult {
  id: number;
  title: string;
  description: string;
}

const searchInput = document.createElement('input');
searchInput.id = 'search';
searchInput.type = 'text';
searchInput.placeholder = 'Ricerca con autocompletamento...';
searchInput.style.padding = '10px';
searchInput.style.margin = '10px';
searchInput.style.width = '400px';
searchInput.style.fontSize = '16px';
document.body.appendChild(searchInput);

const resultsContainer = document.createElement('div');
resultsContainer.id = 'results';
resultsContainer.style.margin = '10px';
resultsContainer.style.padding = '10px';
resultsContainer.style.border = '1px solid #ddd';
resultsContainer.style.width = '400px';
resultsContainer.style.minHeight = '100px';
document.body.appendChild(resultsContainer);

fromEvent(searchInput, 'input').pipe(
  map(event => (event.target as HTMLInputElement).value.trim()),
  debounceTime(300),           // Attendi 300ms dopo l'interruzione
  distinctUntilChanged(),      // Ignora se uguale al precedente
  switchMap(query => {
    if (query.length < 2) {
      return of([]); // Array vuoto se meno di 2 caratteri
    }

    console.log('Esegui ricerca:', query);
    return searchAPI(query).pipe(
      catchError(err => {
        console.error('Errore ricerca:', err);
        return of([]);
      })
    );
  })
).subscribe(results => {
  displayResults(results);
});

// API di ricerca (mock)
function searchAPI(query: string) {
  return of([
    { id: 1, title: `Risultato 1: ${query}`, description: 'Descrizione 1' },
    { id: 2, title: `Risultato 2: ${query}`, description: 'Descrizione 2' },
    { id: 3, title: `Risultato 3: ${query}`, description: 'Descrizione 3' }
  ]);
}

function displayResults(results: SearchResult[]): void {
  if (results.length === 0) {
    resultsContainer.innerHTML = '<p>Nessun risultato trovato</p>';
    return;
  }

  resultsContainer.innerHTML = results
    .map(
      r => `
      <div class="result-item" style="padding: 10px; border-bottom: 1px solid #eee;">
        <h3 style="margin: 0 0 5px 0;">${r.title}</h3>
        <p style="margin: 0; color: #666;">${r.description}</p>
      </div>
    `
    )
    .join('');
}

Importanza di switchMap

Se usi mergeMap invece di switchMap, le vecchie richieste continuano a essere eseguite. Di conseguenza, i risultati di richieste lente potrebbero essere visualizzati dopo, causando un comportamento UI innaturale.

  • ❌ mergeMap: "Rx"(lento) → "RxJS"(veloce) → Risultato "RxJS" → Risultato "Rx" (sovrascrive con vecchio risultato)
  • ✅ switchMap: "Rx"(annullato) → "RxJS"(eseguito) → Solo risultato "RxJS" visualizzato

Esempio pratico: scorciatoie da tastiera ​

ts
import { fromEvent, filter, map } from 'rxjs';
// Ctrl+S per salvare
fromEvent<KeyboardEvent>(document, 'keydown').pipe(
  filter(event => event.ctrlKey && event.key === 's'),
  map(event => {
    event.preventDefault();
    return event;
  })
).subscribe(() => {
  console.log('Esecuzione salvataggio');
  saveDocument();
});

// Ctrl+K per mostrare palette comandi
fromEvent<KeyboardEvent>(document, 'keydown').pipe(
  filter(event => event.ctrlKey && event.key === 'k'),
  map(event => {
    event.preventDefault();
    return event;
  })
).subscribe(() => {
  console.log('Mostra palette comandi');
  showCommandPalette();
});

function saveDocument(): void {
  console.log('Salvataggio documento...');
}

function showCommandPalette(): void {
  console.log('Visualizzazione palette comandi');
}

Combinazione di tasti multipli ​

ts
import { fromEvent, buffer, debounceTime, map, filter } from 'rxjs';
// Doppio Escape per chiudere il modal
const keydown$ = fromEvent<KeyboardEvent>(document, 'keydown');

keydown$.pipe(
  filter(event => event.key === 'Escape'),
  buffer(keydown$.pipe(debounceTime(300))), // Raggruppa input consecutivi entro 300ms
  filter(events => events.length >= 2), // Premuto 2 o più volte
  map(() => true)
).subscribe(() => {
  console.log('Chiudi modal (doppio Escape)');
  closeAllModals();
});

function closeAllModals(): void {
  console.log('Chiudi tutti i modal');
}

Best practice per scorciatoie da tastiera

  • preventDefault() per prevenire il comportamento predefinito
  • event.ctrlKey, event.shiftKey, event.altKey per determinare i tasti modificatori
  • filter per elaborare solo tasti specifici
  • Dare priorità a scorciatoie familiari all'utente (Ctrl+S, ecc.)

Supporto multi-touch ​

Problema: pinch zoom e gesti multi-touch ​

Vogliamo implementare pinch zoom e gesti multi-touch su tablet e smartphone.

Soluzione: monitorare gli eventi touch ​

ts
import { fromEvent, map, pairwise } from 'rxjs';
const imageElement = document.createElement('img');
imageElement.id = 'zoomable-image';
imageElement.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="300" height="300"%3E%3Crect width="300" height="300" fill="%234CAF50"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="white" font-size="20"%3EPinch Zoom%3C/text%3E%3C/svg%3E';
imageElement.style.width = '300px';
imageElement.style.height = '300px';
imageElement.style.margin = '20px';
imageElement.style.touchAction = 'none';
imageElement.style.userSelect = 'none';
imageElement.style.transition = 'transform 0.1s';
document.body.appendChild(imageElement);

let initialDistance = 0;
let currentScale = 1;

fromEvent<TouchEvent>(imageElement, 'touchstart').pipe(
  map(event => {
    if (event.touches.length === 2) {
      // Calcola la distanza tra 2 punti
      const touch1 = event.touches[0];
      const touch2 = event.touches[1];
      return getDistance(touch1, touch2);
    }
    return 0;
  })
).subscribe(distance => {
  initialDistance = distance;
});

fromEvent<TouchEvent>(imageElement, 'touchmove').pipe(
  map(event => {
    event.preventDefault();
    if (event.touches.length === 2) {
      const touch1 = event.touches[0];
      const touch2 = event.touches[1];
      return getDistance(touch1, touch2);
    }
    return 0;
  }),
  pairwise()
).subscribe(([prev, curr]) => {
  if (initialDistance > 0 && curr > 0) {
    // Cambia la scala in base alla quantità di pinch
    const scaleDelta = curr / initialDistance;
    const newScale = currentScale * scaleDelta;

    // Limita la scala (0.5x ~ 3x)
    const clampedScale = Math.max(0.5, Math.min(3, newScale));

    imageElement.style.transform = `scale(${clampedScale})`;
  }
});

fromEvent<TouchEvent>(imageElement, 'touchend').subscribe(() => {
  // Registra la scala corrente
  const transform = imageElement.style.transform;
  const match = transform.match(/scale\(([^)]+)\)/);
  if (match) {
    currentScale = parseFloat(match[1]);
  }
});

// Calcola la distanza tra 2 punti
function getDistance(touch1: Touch, touch2: Touch): number {
  const dx = touch2.clientX - touch1.clientX;
  const dy = touch2.clientY - touch1.clientY;
  return Math.sqrt(dx * dx + dy * dy);
}

Punti di implementazione del pinch zoom

  • touches.length === 2 per determinare il tocco a due dita
  • touchstart per registrare la distanza iniziale
  • touchmove per calcolare la distanza corrente e aggiornare la scala
  • pairwise() per calcolare la differenza rispetto al precedente
  • Limitare l'intervallo della scala per migliorare l'usabilità

Pattern di eventi compositi ​

Esempio pratico: rilevamento pressione prolungata ​

ts
import { fromEvent, race, timer, switchMap, takeUntil, tap } from 'rxjs';
const button = document.createElement('button');
button.id = 'long-press-button';
button.innerText = 'Tieni premuto';
button.style.padding = '15px 30px';
button.style.margin = '10px';
button.style.fontSize = '16px';
button.style.cursor = 'pointer';
document.body.appendChild(button);

const mouseDown$ = fromEvent(button, 'mousedown');
const mouseUp$ = fromEvent(document, 'mouseup');

mouseDown$.pipe(
  switchMap(() =>
    // Gara tra attendere 500ms e mouseup
    race(
      timer(500).pipe(
        tap(() => console.log('Pressione prolungata rilevata!'))
      ),
      mouseUp$.pipe(
        tap(() => console.log('Clic normale'))
      )
    ).pipe(
      takeUntil(mouseUp$)
    )
  )
).subscribe(() => {
  console.log('Evento completato');
});

Esempio pratico: rilevamento doppio clic ​

ts
import { fromEvent, buffer, debounceTime, map, filter } from 'rxjs';
const element = document.createElement('div');
element.id = 'double-click-target';
element.innerText = 'Doppio clic qui';
element.style.padding = '40px';
element.style.margin = '10px';
element.style.background = '#FF9800';
element.style.color = '#fff';
element.style.cursor = 'pointer';
element.style.userSelect = 'none';
element.style.display = 'inline-block';
document.body.appendChild(element);

const click$ = fromEvent(element, 'click');

click$.pipe(
  buffer(click$.pipe(debounceTime(250))), // Raggruppa clic entro 250ms
  map(clicks => clicks.length),
  filter(count => count === 2) // Solo doppio clic
).subscribe(() => {
  console.log('Doppio clic rilevato!');
  handleDoubleClick();
});

function handleDoubleClick(): void {
  console.log('Elaborazione doppio clic');
}

Esempio pratico: visualizzazione ritardata su hover ​

ts
import { fromEvent, timer, switchMap, takeUntil, mapTo } from 'rxjs'; } from 'rxjs';

const target = document.createElement('div');
target.id = 'hover-target';
target.innerText = 'Passa il mouse qui';
target.style.padding = '20px';
target.style.margin = '10px';
target.style.background = '#9C27B0';
target.style.color = '#fff';
target.style.display = 'inline-block';
target.style.cursor = 'pointer';
target.style.userSelect = 'none';
document.body.appendChild(target);

const tooltip = document.createElement('div');
tooltip.id = 'tooltip';
tooltip.innerText = 'Tooltip';
tooltip.style.position = 'absolute';
tooltip.style.padding = '10px';
tooltip.style.background = '#333';
tooltip.style.color = '#fff';
tooltip.style.borderRadius = '4px';
tooltip.style.display = 'none';
tooltip.style.pointerEvents = 'none';
tooltip.style.marginTop = '50px';
tooltip.style.marginLeft = '10px';
document.body.appendChild(tooltip);

const mouseEnter$ = fromEvent(target, 'mouseenter');
const mouseLeave$ = fromEvent(target, 'mouseleave');

mouseEnter$.pipe(
  switchMap(() =>
    // Attendi 500ms prima di mostrare il tooltip
    timer(500).pipe(
      map(() => true),
      takeUntil(mouseLeave$) // Annulla se il mouse esce
    )
  )
).subscribe(() => {
  tooltip.style.display = 'block';
  console.log('Mostra tooltip');
});

mouseLeave$.subscribe(() => {
  tooltip.style.display = 'none';
  console.log('Nascondi tooltip');
});

Pulizia degli eventi ​

Problema: prevenire i memory leak ​

La mancata cancellazione appropriata delle sottoscrizioni agli eventi può causare memory leak.

Soluzione: pulizia con takeUntil ​

ts
import { fromEvent, Subject, throttleTime, takeUntil } from 'rxjs';
class ScrollTracker {
  private destroy$ = new Subject<void>();

  init(): void {
    fromEvent(window, 'scroll').pipe(
      throttleTime(100),
      takeUntil(this.destroy$) // Cancellazione automatica alla distruzione del componente
    ).subscribe(() => {
      console.log('Posizione scroll:', window.scrollY);
    });

    fromEvent(window, 'resize').pipe(
      throttleTime(100),
      takeUntil(this.destroy$)
    ).subscribe(() => {
      console.log('Dimensione finestra:', window.innerWidth, window.innerHeight);
    });
  }

  destroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
    console.log('Tutti i listener di eventi sono stati rimossi');
  }
}

// Esempio di utilizzo
const tracker = new ScrollTracker();
tracker.init();

// Alla navigazione di pagina o distruzione del componente
// tracker.destroy();

Prevenzione dei memory leak

  • Applica takeUntil a tutte le sottoscrizioni di eventi
  • Emetti destroy$ alla distruzione del componente
  • Gli eventi globali (window, document) richiedono particolare attenzione
  • Non dimenticare unsubscribe() quando gestisci le Subscription esplicitamente

Esempi pratici di componenti UI ​

Implementazione scroll infinito ​

ts
import { fromEvent, of, throttleTime, map, filter, exhaustMap, catchError } from 'rxjs';
interface Item {
  id: number;
  title: string;
  content: string;
}

class InfiniteScroll {
  private page = 1;
  private loading = false;
  private hasMore = true;

  init(container: HTMLElement, itemsContainer: HTMLElement): void {
    fromEvent(container, 'scroll').pipe(
      throttleTime(200),
      map(() => {
        const scrollTop = container.scrollTop;
        const scrollHeight = container.scrollHeight;
        const clientHeight = container.clientHeight;
        return (scrollTop + clientHeight) / scrollHeight;
      }),
      filter(ratio => ratio > 0.9 && !this.loading && this.hasMore),
      exhaustMap(() => {
        this.loading = true;
        console.log(`Caricamento pagina ${this.page}...`);

        return this.loadMoreItems(this.page).pipe(
          catchError(err => {
            console.error('Errore caricamento:', err);
            return of([]);
          })
        );
      })
    ).subscribe(items => {
      this.loading = false;

      if (items.length === 0) {
        this.hasMore = false;
        console.log('Caricamento di tutti gli elementi completato');
        return;
      }

      this.page++;
      this.appendItems(itemsContainer, items);
    });
  }

  private loadMoreItems(page: number) {
    // Simula chiamata API
    return of(
      Array.from({ length: 10 }, (_, i) => ({
        id: (page - 1) * 10 + i + 1,
        title: `Elemento ${(page - 1) * 10 + i + 1}`,
        content: `Questo è il contenuto dell'elemento ${(page - 1) * 10 + i + 1}`
      }))
    );
  }

  private appendItems(container: HTMLElement, items: Item[]): void {
    const html = items
      .map(
        item => `
        <div class="item">
          <h3>${item.title}</h3>
          <p>${item.content}</p>
        </div>
      `
      )
      .join('');

    container.insertAdjacentHTML('beforeend', html);
  }
}

// Esempio di utilizzo
const scrollContainer = document.createElement('div');
scrollContainer.id = 'scroll-container';
scrollContainer.style.width = '500px';
scrollContainer.style.height = '400px';
scrollContainer.style.overflow = 'auto';
scrollContainer.style.border = '2px solid #333';
scrollContainer.style.margin = '10px';
scrollContainer.style.padding = '10px';
document.body.appendChild(scrollContainer);

const itemsContainer = document.createElement('div');
itemsContainer.id = 'items';
scrollContainer.appendChild(itemsContainer);

// Aggiungi elementi iniziali
itemsContainer.innerHTML = Array.from({ length: 10 }, (_, i) => `
  <div class="item" style="padding: 15px; border-bottom: 1px solid #eee;">
    <h3 style="margin: 0 0 5px 0;">Elemento ${i + 1}</h3>
    <p style="margin: 0; color: #666;">Questo è il contenuto dell'elemento ${i + 1}</p>
  </div>
`).join('');

const infiniteScroll = new InfiniteScroll();
infiniteScroll.init(scrollContainer, itemsContainer);

Utilizzo di exhaustMap

Usando exhaustMap, le nuove richieste vengono ignorate fino al completamento della richiesta precedente. Questo previene richieste duplicate causate da scroll ripetuti.

Codice di test ​

Esempio di test per l'elaborazione degli eventi UI.

ts
import { debounceTime, map } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';

describe('Elaborazione eventi UI', () => {
  let testScheduler: TestScheduler;

  beforeEach(() => {
    testScheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });
  });

  it('debounceTime should delay events', () => {
    testScheduler.run(({ cold, expectObservable }) => {
      const input$ = cold('a-b-c----|', {
        a: 'A',
        b: 'B',
        c: 'C'
      });

      const result$ = input$.pipe(debounceTime(20, testScheduler));

      expectObservable(result$).toBe('-----c----|', { c: 'C' });
    });
  });

  it('should handle search input with debounce', () => {
    testScheduler.run(({ cold, expectObservable }) => {
      const input$ = cold('a-bc---d--|', {
        a: 'R',
        b: 'Rx',
        c: 'RxJ',
        d: 'RxJS'
      });

      const result$ = input$.pipe(
        debounceTime(20, testScheduler),
        map(query => `Ricerca: ${query}`)
      );

      expectObservable(result$).toBe('------c---(d|)', {
        c: 'Ricerca: RxJ',
        d: 'Ricerca: RxJS'
      });
    });
  });
});

Padroneggiare i pattern di elaborazione degli eventi UI permette di fornire un'esperienza utente interattiva e piacevole.

Punti chiave

  • throttleTime: elabora solo una volta in un certo periodo (scroll, resize)
  • debounceTime: elabora dopo l'interruzione dell'evento (ricerca, autocompletamento)
  • distinctUntilChanged: deduplicazione (ignora valori identici)
  • switchMap: catena di eventi complessa (drag & drop)
  • takeUntil: pulizia affidabile (prevenzione memory leak)

Best Practice

  • Prestazioni: throttle/debounce per prevenire elaborazione eccessiva
  • Usabilità: impostare tempi di ritardo appropriati (300ms, ecc.)
  • Accessibilità: supportare anche l'uso da tastiera
  • Multi-dispositivo: supportare sia touch che mouse
  • Pulizia: takeUntil per rilascio memoria affidabile

Prossimi passi ​

Dopo aver padroneggiato i pattern di elaborazione eventi UI, passa ai seguenti pattern:

Sezioni correlate ​

Risorse di riferimento ​

Pubblicato sotto licenza CC-BY-4.0.