Stack Labs Blog moves to Dev.to | Le Blog Stack Labs déménage sur Dev.to 🚀

26 février 2018 | Angular | Kevin Davin

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.