J'ai revu du code où un développeur avait utilisé debounceTime sur un gestionnaire de scroll et ne comprenait pas pourquoi la mise à jour du header sticky semblait cassée. Le header ne se mettait à jour qu'après que l'utilisateur ait arrêté de scroller. La correction tenait en un mot : throttleTime. Les deux opérateurs ne sont pas interchangeables. Ils résolvent des problèmes différents, et choisir le mauvais produit des bogues qui ressemblent à des problèmes de timing mais qui sont en réalité des erreurs sémantiques.
Debounce : n'émettre qu'après une période de calme
debounceTime(ms) retarde l'émission jusqu'à ce que la source soit silencieuse pendant la durée spécifiée. Chaque nouvel événement réinitialise le minuteur. Rien n'est émis tant que les événements continuent d'arriver.
L'exemple le plus clair : la recherche au fil de la frappe. Un utilisateur tape 'angular signals'. C'est 15 frappes de touches, chacune déclenchant une émission valueChanges. Avec debounceTime(350), une seule requête HTTP est envoyée, après que l'utilisateur ait fait une pause. Sans ça, on envoie 15 requêtes dont les 14 premières sont inutiles.
Throttle : émettre régulièrement, mais pas trop souvent
throttleTime(ms) autorise une émission par intervalle, quel que soit le nombre d'événements qui arrivent. Les événements continuent d'affluer, mais un seul passe par fenêtre de temps.
L'exemple le plus clair : le suivi de la position de scroll. L'événement scroll peut se déclencher à chaque image rendue pendant un défilement, 60 fois par seconde ou plus. On veut que l'interface se mette à jour pendant que l'utilisateur scrolle, pas après qu'il se soit arrêté. throttleTime(150) donne environ 6 mises à jour par seconde, ce qui est suffisamment fluide pour tout retour visuel sans surcharger le CPU.
Le modèle mental en une ligne
- Debounce : on s'intéresse à la valeur FINALE après l'arrêt de l'activité
- Throttle : on s'intéresse aux mises à jour RÉGULIÈRES pendant que l'activité est en cours
- Utiliser debounce pour : les champs de recherche, les déclencheurs de validation de formulaire, le redimensionnement de fenêtre quand les dimensions finales comptent
- Utiliser throttle pour : la position de scroll, le suivi de la position de la souris, les limites de rafraîchissement de tableau de bord en temps réel
Composant de recherche : debounceTime + distinctUntilChanged + switchMap
Ces trois opérateurs vont ensemble sur chaque champ de recherche. debounceTime réduit les appels API. distinctUntilChanged empêche le déclenchement quand la valeur n'a pas réellement changé (l'utilisateur fait tabulation puis revient sans modifier). switchMap annule la requête en cours si une nouvelle commence.
import { Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Product, ProductService } from './product.service';
@Component({
selector: 'app-product-search',
standalone: true,
imports: [ReactiveFormsModule],
template: `<input [formControl]="searchControl" placeholder="Search products" />`
})
export class ProductSearchComponent {
private productService = inject(ProductService);
searchControl = new FormControl('');
results: Product[] = [];
constructor() {
this.searchControl.valueChanges.pipe(
debounceTime(350),
distinctUntilChanged(),
switchMap(query => this.productService.search(query ?? '')),
takeUntilDestroyed()
).subscribe(products => this.results = products);
}
}Suivi de scroll : throttleTime
Sur les pages de formulaires longs, suivre la profondeur de scroll pour comprendre l'abandon des utilisateurs est un besoin courant. throttleTime maintient le taux d'événements raisonnable sans retarder les mises à jour jusqu'à l'arrêt du défilement.
import { Component, inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { fromEvent, map, throttleTime } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({ selector: 'app-scroll-tracker', standalone: true, template: '' })
export class ScrollTrackerComponent {
private document = inject(DOCUMENT);
scrollPercent = 0;
constructor() {
const view = this.document.defaultView;
if (!view) {
return;
}
fromEvent(view, 'scroll').pipe(
throttleTime(150),
map(() => {
const scrollTop = view.scrollY;
const docHeight = this.document.documentElement.scrollHeight - view.innerHeight;
return Math.round((scrollTop / docHeight) * 100);
}),
takeUntilDestroyed()
).subscribe(percent => this.scrollPercent = percent);
}
}Tout passe par le document injecté et son defaultView, jamais par les globales window ou document. C'est tout l'intérêt d'injecter DOCUMENT : sur le serveur il n'y a pas de window, defaultView est null, et le composant sort tôt au lieu de lever une erreur pendant le rendu côté serveur.
Erreurs que j'ai vues en production
- debounce sur le scroll : le header sticky ou la barre de progression ne se met à jour qu'après l'arrêt de l'utilisateur. Ça semble cassé.
- throttle sur la recherche : les requêtes continuent de partir en pleine frappe. On réduit le nombre mais on n'élimine pas le gaspillage.
- debounceTime sans switchMap : on réduit les requêtes, mais des réponses obsolètes peuvent encore arriver dans le désordre et écraser des résultats plus récents.
- debounceTime réglé au-dessus de 500ms : les utilisateurs remarquent le délai et pensent que l'application est lente. 300-400ms est le point idéal pour la recherche.
- throttleTime réglé au-dessus de 300ms sur le scroll : saccades visibles sur les éléments d'interface qui suivent la position.
auditTime : celui que vous n'avez probablement pas utilisé
auditTime(ms) est comme throttleTime mais émet à la FIN de l'intervalle avec la valeur la plus récente, plutôt que la PREMIÈRE valeur arrivée. C'est utile quand on veut le comportement de throttle mais qu'on a besoin de la dernière valeur dans la fenêtre, pas la première. Je l'utilise pour la sauvegarde automatique de formulaires en temps réel : émettre toutes les 2 secondes, mais avec l'état le plus actuel du formulaire, pas l'état d'il y a 2 secondes.
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { auditTime, switchMap } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DraftService } from './draft.service';
@Component({
selector: 'app-application-draft',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form">
<input formControlName="amount" />
</form>
`
})
export class ApplicationDraftComponent {
private fb = inject(FormBuilder);
private draftService = inject(DraftService);
form = this.fb.group({ amount: [0] });
constructor() {
this.form.valueChanges.pipe(
auditTime(2000),
switchMap(value => this.draftService.save(value)),
takeUntilDestroyed()
).subscribe();
}
}Le debounce concerne la réponse finale. Le throttle concerne la progression régulière. Comprenez ça et le choix est toujours évident.
Références
- RxJS debounceTime. https://rxjs.dev/api/operators/debounceTime
- RxJS throttleTime. https://rxjs.dev/api/operators/throttleTime
- RxJS auditTime. https://rxjs.dev/api/operators/auditTime
- RxJS Marbles interactive. https://rxmarbles.com/