Accueil / Blog / Métier / 2017 / Mise en pratique de RxJS dans Angular

Mise en pratique de RxJS dans Angular

Par Yann Fouillat — publié 24/07/2017
Contributeurs : Thomas Desvenain
Les quelques bases suffisantes pour bien utiliser RxJS dans Angular 2 et 4.
Mise en pratique de RxJS dans Angular

C'est pas forcément plus simple avec des images...

Tout comme les promesses avaient modifié nos habitudes, la programmation réactive nous oblige à apprendre un nouveau paradigme. Les documentations sont dures à comprendre, les schémas font peur et on ne sait pas par où commencer.

Et pourtant, il suffit de connaitre quelques bases pour s'en sortir dans presque tous les cas. Nous vous présenterons donc dans cet article ces quelques classes et méthodes qui vous suivront tout au long de vos développements d'applications Angular.

Qu'est ce que la programmation réactive ?

La programmation réactive se base sur le concept d'observateur. Si vous n'êtes pas familier avec ce principe, le principe est tout simplement que l'on définit des observables et des observateurs. Les observables vont émettre des événements qui seront interceptés par les observateurs.

La programmation réactive va étendre ce concept en permettant de combiner les observables, modifier les événements à la volée, les filtrer, etc.

Observable

Observable est l'objet de base de la programmation réactive. C'est lui qui va nous permettre de créer des (vous l'aurez surement deviné) observables.

const my_observable = Observable.of(42);

of est la méthode la plus simple et permet de créer un observable n'envoyant qu'une seule valeur (42 dans notre exemple, mais cela peut tout aussi bien être un objet ou une chaîne). Nous avons donc un observable mais personne pour le surveiller. C'est ce que la méthode subscribe va nous permettre de faire en créant nos observateurs.

const my_observable = Observable.of(42);

my_observable.subscribe((value) => { console.log(value); });

subscribe prend en paramètre l'observateur, qui est une simple fonction qui recevra les valeurs émises par l'observable. Notre console affichera donc 42 dans notre exemple.

subscribe peut également prendre deux autres arguments : une fonction appelée en cas d'erreur, et une autre appelée une fois l'observable fini (un observable fini n'enverra plus de données, il est possible de finir un observable avec la méthode complete).

const my_observable = Observable.of(42);

my_observable.subscribe((value) => {
    console.log(value);
}, (error) => {
    console.log(error);
}, () => {
    console.log('Finis !');
});

my_observable.complete();

C'est bien beau tout ça, mais ça n'est toujours que de la théorie... Un cas concret donc ! Et ça tombe bien, car Angular nous propose justement des services exploitant à fond la programmation réactive, tel que HTTP.

Les différentes méthodes du service http retournent des Observable<Response>. Notez que le type des variables émises par l'observable est précisé entre chevrons, les observables sont en effet génériques.

Maintenant que vous avez compris tout ça, rien de plus simple que de faire des requêtes. Et parce qu'on aime bien coder proprement, nos requêtes doivent être dans un service particulier. Imaginons qu'on veuille récupérer une liste d'articles présents sur notre site.

Les données seront en JSON sous la forme suivante :

[
    {
        "author": "John Doe",
        "title": "first article",
        "body": "A very interesting article",
        "published": "2017-07-09T09:16:35Z"
    },
    {
        "author": "John Doe",
        "title": "second article",
        "body": "Another very interesting article",
        "published": "2017-07-12T13:45:27Z"
    }
]

Et le service ainsi que le composant :

export interface Article {
  author: string;
  title: string;
  body: string;
  published: Date;
}


@Injectable()
export class DataService {
    constructor(protected http: Http) {}

    public getArticles(): Observable<Response> {
        return this.http.get('https://example.com/mon/api/');
    }
}


@Component({
  selector: 'app-articles-list',
  templateUrl: './articles-list.component.html',
  styleUrls: ['./articles-list.component.scss']
})
export class ArticlesListComponent {

    public articles: Article[];

    constructor(private data: DataService) {
        const component = this;

        component.data.getArticles().subscribe((response: Response) => {
            component.articles = response.json().map((json: Object) => <Article>({
                author: json['author'],
                title: json['title'],
                body: json['body'],
                published: new Date(json['published'])
            }))
        });
    }
}

Et voilà. Mais cela ne me plaît pas, le service ne devrait pas renvoyer la réponse mais des articles directement. Si seulement il y avait un moyen de le faire, je me demande...

Modifier et filtrer les données à la volée

La méthode map d'Observable fonctionne de la même manière que la méthode du même nom sur les tableaux : elle prend des valeurs en entrée, les transforme et les renvoie en sortie. Exemple :

const my_observable = Observable.of("foobar");

my_observable.map((value: string) => value.length).subscribe((value: number) => {
    console.log(value);  // 6
});

Ou pour reprendre nos composants et services (notez au passage que la création des articles est passée dans la classe, ce qui nous permet de facilement créer des articles depuis d'autres requêtes) :

export class Article {
    public static fromJson(json: Object): Article {
        return new Article(
            json['author'],
            json['title'],
            json['body'],
            new Date(json['published'])
        );
    }

    constructor(public author: string,
                public title: string,
                public body: string,
                public published: Date) {
    }
}


@Injectable()
export class DataService {
    constructor(protected http: Http) {}

    public getArticles(): Observables<Response> {
        return this.http.get('https://example.com/mon/api/')
            .map((response: Response): Article[] => {
                return response.json().map((json: Object) => Article.fromJson(json));
            });
    }
}


@Component({
  selector: 'app-articles-list',
  templateUrl: './articles-list.component.html',
  styleUrls: ['./articles-list.component.scss']
})
export class ArticleListComponent {

    public articles: Article[];

    constructor(private data: DataService) {
        const component = this;

        component.data.getArticles().subscribe((articles: Article[]) => {
            component.articles = articles;
        });
    }
}

C'est déjà beaucoup mieux ! Le service s'occupe de la création des Article, et le composant n'a presque rien à faire. Et cela nous permet surtout de réutiliser facilement cet observable, c'est le grand avantage de la programmation réactive. Par exemple si en fait on ne veut que les articles de John Doe (grâce à filter, assez explicite par lui-même) :

component.data.getArticles().filter((article: Article) => {
    return article.author === 'John Doe';
}).subscribe((articles: Article[]) => {
    component.articles = articles;
});

Les méthodes comme map et filter, qui permettent de créer un observable à partir d'un autre observable, sont appelées en RxJs, opérateurs. Nous allons voir maintenant que certains permettent de combiner des observables entre-eux.

Combiner les observables

Il existe des opérateurs qui permettent de créer un observable à partir de plusieurs observables. Ils ont des noms particulièrement barbares, mais sont pour certains d'entre eux fréquemment utiles. Nous allons vous présenter les trois plus courants dans la programmation d'interfaces web.

forkJoin: combiner la sortie de plusieurs observables

Imaginons que vous souhaitiez afficher la liste des auteurs filtrée suivant un critère qui provient d'une autre requête http: celle qui renvoie l'auteur du mois par exemple. Ce que nous aimerions pouvoir faire, c'est :

  • lancer la requête permettant de récupérer l'auteur du mois,
  • en même temps lancer la requête permettant d'avoir la liste des articles,
  • et une fois qu'on a les deux retours, combiner les résultats pour sortir la bonne valeur.

C'est rendu facile avec l'opérateur forkJoin. Regardons l'implémentation suivante :

// Dans notre DataService

getAuthorOfTheMonth(): Observable<Object> {
    this.http.get('https://example.com/mon/api/authorofthemonth')
        .map(response => response.json())
}

getAuthorOfTheMonthArticles(): Observable<Article[]> {
    return Observable.forkJoin(
        this.getArticles(),         // la requête http qui récupère la liste des articles
        this.getAuthorOfTheMonth()  // celle qui récupère l'auteur du mois
    ).map(([articles, authorOfTheMonth]) =>
        // le filtre qui exclut les articles qui ne sont pas de cet auteur
        articles.filter(article => article['author'] === authorOfTheMonth['name'])
    )
}

forkJoin prend en paramètre plusieurs observables, et résulte en un observable qui prend en entrée la sortie de tous les autres, sous la forme d'une liste. Dans notre exemple, nous faisons suivre le forkJoin d'une opération map qui utilise les informations provenant des deux sorties. Dans votre composant, vous pouvez souscrire à cet observable et appelant la méthode getAuthorOfTheMonthArticles()

combineLatest: écouter un groupe d'observables

Supposons maintenant que nous souhaitions afficher la liste des articles de l'utilisateur connecté, sachant que cet utilisateur peut être anonyme, se connecter, se déconnecter... C'est le type de use case qui est très facile à implémenter en programmation web classique sans ajax, mais qui peut devenir rapidement infernal en single page application (SPA).

RxJs simplifie la résolution de ce genre de choses, grâce notamment à l'opérateur combineLatest.

L'opérateur combineLatest est similaire au forkJoin à une distinction près. Il ne se termine pas une fois qu'il a reçu les résultats de chaque observable, mais continue d'écouter, et émet de nouveau à chaque fois qu'un des observables source a émis de nouveau. Il réémet alors la liste de toutes les dernières valeurs émises par chaque observable.

Supposons qu'on a une méthode getAuthenticatedUser() qui renvoit un observable qui émet un objet représentant l'utilisateur à chaque fois qu'il change. Nous pourrons écrire la méthode suivante pour récupérer une liste toujours à jour :

// Dans notre DataService

getAuthenticatedUserArticles(): Observable<Article[]> {
    return Observable.combineLatest(
        this.getAuthenticatedUser(),  // l'observable qui émet l'utilisateur connecté
        this.getArticles()   // l'observable qui émet la liste des articles
    ).map([user, articles] => {
        if (user.isAnonymous) {
            return [];
        }
        return articles.filter(article => article['author'] === user['name'])
    })
}

À chaque fois que l'utilisateur authentifié va changer, l'observable renvoyé par getAuthenticatedUser() va émettre une nouvelle valeur. Si vous avez subscribe à cet observable dans votre composant pour mettre à jour le contenu de la liste à l'écran, celle-ci va se mettre à jour à chaque fois que l'utilisateur se connecte ou se déconnecte !

Notez que :

  • la requête qui récupère la liste des articles ne sera exécutée ici qu'une seule fois
  • l'observable getAuthenticatedUser() capable d'émettre plusieurs fois sera sans doute un BehaviorSubject dont nous parlons dans la suite de l'article.

mergeMap: utiliser la sortie d'un observable en entrée d'un autre

Supposons que notre api a un point d'entrée pour renvoyer la liste des articles d'un seul auteur. On pourrait alors améliorer la façon de récupérer les articles de l'auteur du mois :

  • d'abord on récupère l'auteur du mois,
  • puis on demande la liste des articles pour cet auteur.

On aura toujours besoin de deux observables, mais cette fois les traitements seront successifs au lieu d'être simultanés. L'opérateur mergeMap nous permet de faire cela facilement.

// Dans notre DataService

getAuthorOfTheMonthArticles(): Observable<Article[]> {
    const service = this;
    return service.getAuthorOfTheMonth().mergeMap((authorOfTheMonth) => {
            return service.http.get('https://example.com/articles/?name=' + authorOfTheMonth['name'])
        })
}

mergeMap crée un observable à partir d'un observable source (ici, la requête de l'auteur du mois) qui envoie sa sortie en entrée d'un autre observable (ici, la requête des articles pour cet auteur).

Note: si certains observables de la chaîne sont répétables et que l'ordre de sortie compte, alors intéressez-vous à l'opérateur concatMap qui pourrait mieux correspondre à votre besoin : https://fernandocejas.com/2015/01/11/rxjava-observable-tranformation-concatmap-vs-flatmap/

Avec ces trois opérateurs, vous maîtrisez les trois manières les plus utiles de combiner des observables dans une application Angular.

Subject

Un Subject est à la fois un observable ET un observateur. On peut donc subscribe dessus, mais également lui envoyer des valeurs :

const subject = new Subject<number>();

subject.subscribe((number) => {
    console.log(1, number);
});


subject.subscribe((number) => {
    console.log(2, number);
});


subject.next(1);  // On envoie une donnée
subject.next(2);  // On envoie une autre donnée
subject.complete();  // On indique que l'observable n'enverra plus de données

Subject à lui seul ne sert pas forcément à grand chose, mais ses classes spécialisées sont, elles, bien utiles.

BehaviorSubject

Un BehaviorSubject a obligatoirement une valeur par défaut. Il sauvegarde la dernière valeur qu'il a émis et l'envoie aux observateurs lors de leur subscribe (si vous ne l'aviez pas remarqué, un observateur ne récupère pas les événements passés mais uniquement les nouveaux). Exemple d'utilisation avec un système permettant de gérer l'utilisateur courant :

/* A user of the app */
export interface User {
  userName: string;
  isAnonymous: boolean;
}

const ANONYMOUS_USER = <User>{
  isAnonymous: true,
  userName: 'Anonymous User'
};

@Injectable()
export class AuthService {

    protected authenticatedUser: BehaviorSubject<User> = new BehaviorSubject<User>(ANONYMOUS_USER);

    constructor(private api: HTTPService) {
        // api est un service permettant de faire des appels à notre API REST
    }

    public login(userName: string, password: string): Observable<User> {
        const service = this;
        return service.api.post('/accounts/login/', {
            'username': userName,
            'password': password
        }).map((response: Response): User => {
            const json = response.json();
            const newUser = <User>({
                userName: json.username,
                isAnonymous: false,
            });
            service.authenticatedUser.next(newUser);
            return user;
        });
    }

    public logout(): Observable<Response> {
        const service = this;
        return service.api.post('/accounts/logout/', {})
            .map((response: Object) => {
                service.authenticatedUser.next(ANONYMOUS_USER);
                return response;
            });
    }

    public getAuthenticatedUser(): Observable<User> {
        return this.authenticatedUser;
    }

}

L'utilisateur est donc anonyme tant qu'il ne se connecte pas. Si on subscribe à getAuthenticatedUser(), on pourra exécuter du code avec cette valeur par défaut, puis à chaque fois que l'utilisateur courant va changer. On est ainsi assurés de toujours travailler avec des valeurs à jour.

AsyncSubject

AsyncSubject ne retient que sa dernière valeur, et ne l'envoie que lorsqu'il est fini. Il peut être pratique pour mettre en cache une opération qui ne sera effectuée qu'une fois :

protected vocabulary: AsyncSubject = new AsyncSubject();

constructor(private api: HTTPService) {
    this.api.get('/vocabulary/')
        .map((response: Response) => {
            this.vocabulary.next(response.json());
            this.vocabulary.complete();
        });
}

public getVocabulary(): Observable<object> {
    return this.vocabulary;
}

Cette présentation de RxJS n'était bien sûr pas exhaustive, mais elle vous permettra déjà d'aller assez loin dans vos applications Angular sans vous prendre la tête.

Pour aller plus loin

ABONNEZ-VOUS À LA NEWSLETTER !
Voir aussi
How to setup Angular Universal How to setup Angular Universal 29/06/2017

Step by step explanation. All the pitfalls to avoid.

Comment mettre en place Angular Universal Comment mettre en place Angular Universal 29/06/2017

Toutes les étapes détaillées et expliquées. Les pièges à éviter.

SEO : indexing a JavaScript application SEO : indexing a JavaScript application 29/06/2017

How to produce a server-side rendered version of your web application.

SEO : indexer une application Javascript SEO : indexer une application Javascript 29/06/2017

Comment utiliser le code de votre application pour un rendu server-side.

How to create an Angular library How to create an Angular library 08/02/2017

Having a light and clean distribution AND running tests on Travis, showing demo on Github Pages, ...