Stack Labs Blog moves to Dev.to | Le Blog Stack Labs déménage sur Dev.to 🚀
Gérer l'unsubscription des observables proprement
Temps de lecture estimé : 4 minutes
L’une des erreurs les plus communes avec les observables est la mauvaise gestion de leur cycle de vie. Ne pas faire cela peut entraîner des comportements hasardeux, voir des problèmes de performances.
Prenons comme exemple le composant suivant qui est le plus simple possible :
@Component({...})
export class MyCounterComponent implement OnInit {
constructor(private store: Store<AppState>) {}
ngOnInit() {
this.store.pipe(select(selectCounter))
.subscribe(c => console.log('counter', c));
}
}
Rien de bien sorcier ici, cependant une grosse erreur s’est glissé dans ce code, dans la ligne suivante.
this.store.pipe(select(selectCounter))
.subscribe(c => console.log('counter', c));
En soi, cette définition d’Observable est juste, mais comme this.store
est un observable pouvant émettre une infinité de valeur sans jamais atteindre le signal de terminaison… le callback (qui effectue ici le console.log
) sera appelé durant un temps indéfini.
Hors, si notre composant se voit supprimé par le système (via un changement de route, un *ngIf
ou *ngSwitch
), l’observable ne sera pas pour autant terminé et il continuera donc d’effectuer des console.log
.
Les solutions existantes
La version manuelle
La solution la plus simple serait d'unsubscribe
l’observable créé lors de la destruction de notre composant
@Component({...})
export class MyCounterComponent implement OnInit, OnDestroy {
constructor(private store: Store<AppState>) {}
ngOnInit() {
// On sauvegarde le retour de la méthode subscribe
this.counterSub = this.store.pipe( select(selectCounter), filter(v => v != null))
.subscribe(c => console.log('counter', c));
}
ngOnDestroy() {
// On unsubscribe afin d'arrêter notre observable
this.counterSub.unsubscribe();
}
}
Cependant, cette solution ne scale pas très bien…
@Component({...})
export class MyCounterComponent implement OnInit, OnDestroy {
constructor(private store: Store<AppState>) {}
ngOnInit() {
// On peux le faire 1 fois... mais pas quinze
this.counterSub = this.store.pipe(select(selectCounter))
.subscribe(c => console.log('counter', c));
// On peux le faire 2 fois... mais pas quinze
this.ageSub = this.store.pipe(select(selectAge))
.subscribe(a => console.log('age', a));
// On peux le faire 3 fois... mais pas quinze
this.sizeSub = this.store.pipe(select(selectSize))
.subscribe(s => console.log('size', s));
}
ngOnDestroy() {
// Et pareil ici, autant de fois qu'il y a de subscription...
this.counterSub.unsubscribe();
this.ageSub.unsubscribe();
this.sizeSub.unsubscribe();
}
}
Des hacks peuvent être trouvés afin de diminuer le nombre de lignes de code… mais qui alourdi la syntaxe :
@Component({...})
export class MyCounterComponent implement OnInit, OnDestroy {
constructor(private store: Store<AppState>) {}
ngOnInit() {
// On peux tout mettre dans un tableau...
this.subs = [
this.store.pipe(select(selectCounter))
.subscribe(c => console.log('counter', c)),
this.store.pipe(select(selectAge))
.subscribe(a => console.log('age', a)),
this.store.pipe(select(selectSize))
.subscribe(s => console.log('size', s))
];
}
ngOnDestroy() {
// ... pour ensuite le parcourir et unsubscribe sur chacun
this.subs.forEach(s => s.unsubscribe());
}
}
NPM / Github regorge de solutions diverses et variés. Je vais vous en présenter certaines avec les raisons de leur non adoption.
La solution par annotation - ngx-auto-unsubscribe
La librairie ngx-auto-unsubscribe met à disposition une annotation @AutoUnsubscribe()
qui va faire le travail pour nous…
@AutoUnsubscribe() // <= on ajoute cela sur notre component
@Component({...})
export class MyCounterComponent implement OnInit, OnDestroy {
counterSub, ageSub, sizeSub: Subscription; // On déclare bien nos attributs de subscritpion
constructor(private store: Store<AppState>) {}
ngOnInit() {
this.counterSub = this.store.pipe(select(selectCounter))
.subscribe(c => console.log('counter', c));
this.ageSub = this.store.pipe(select(selectAge))
.subscribe(a => console.log('age', a));
this.sizeSub = this.store.pipe(select(selectSize))
.subscribe(s => console.log('size', s));
}
ngOnDestroy() {
// 😱
}
}
Et oui, vous voyez bien, nous sommes obligés d’écrire la méthode ngOnDestroy
, même si elle doit être vide.
Le pire est que si vous retirez la méthode (par refactor IDE ou inattention), l’auto-unsubscribe ne fonctionnera plus du tout… 🤔
La solution par patching du Destroy - take-until-destroy
Une autre solution avec la lib take-until-destroy un petit peu plus impérative est d’utiliser un opérateur RxJS qui stoppera le flux quand le composant se terminera.
@Component({...})
export class MyCounterComponent implement OnInit, OnDestroy {
constructor(private store: Store<AppState>) {}
ngOnInit() {
this.store.pipe(select(selectCounter), takeUntilDestroy(this))
.subscribe(c => console.log('counter', c));
this.store.pipe(select(selectAge), takeUntilDestroy(this))
.subscribe(a => console.log('age', a));
this.store.pipe(select(selectSize), takeUntilDestroy(this))
.subscribe(s => console.log('size', s));
}
ngOnDestroy() {
// 😱 encore...
}
}
Ici, l’on voit que l’empreinte sur notre code est très faible, car elle se résume à l’ajout d’un opérateur dans un pipe
Rx. Par contre, nous avons encore besoin de mettre cette méthode ngOnDestroy
vide 😢!
La solution du compagnon - Companion Component
Cette dernière solution, @davinkevin/companion-component profite des améliorations mises à disposition par RxJS 5.5+ et les pipeable (anciennement nommé lettable
) operators.
@Component({...})
export class MyCounterComponent implement OnInit, OnDestroy {
c = new CompanionComponent(); // Nous créons le compagnon ❤️
constructor(private store: Store<AppState>) {}
ngOnInit() {
const untilDestroy = this.c.untilDestroy(); // L'on récupère l'operator custom
this.store.pipe(select(selectCounter), untilDestroy()) // On utilise
.subscribe(c => console.log('counter', c));
this.store.pipe(select(selectAge), untilDestroy()) // autant de fois
.subscribe(a => console.log('age', a));
this.store.pipe(select(selectSize), untilDestroy()) // que l'on veut, sans sur-coût
.subscribe(s => console.log('size', s));
}
resengOnDestroy() {
this.c.destroy(); // Notre companion se détruit et 💀
}
}
Cette solution est moins ‘auto-magic’, plus claire, compréhensible, mais un petit peu plus verbeuse. Elle a été adoptée majoritairement par les développeurs dans nos équipes.
Conclusion
Choisissez une solution qui convient à votre style de code et de travail Dans notre cas nous préférons quelque chose qui choisit de ne pas toucher au prototype du component, qui est agnostique du framework et ne requiert pas d’écrire de méthode vide pour passer l’AOT.