Créons notre propre librairie RxJS à partir de rien

Créons notre propre librairie RxJS à partir de rien
Photo by Frederick Marschall / Unsplash

Un observable est un patron de conception permettant de simplifier la gestion d'état asynchrone.

Un état asynchrone est une donnée qui peut être accessible immédiatement, ou plus tard, voir jamais (lorsqu'il y a une erreur à la récupération de cet état).

C'est le cas par exemple d'une requête HTTP : il est possible de récupérer des données depuis un serveur distant, sauf qu'il n'y a aucune garantie sur la récupération de ces données, ni dans combien de temps, car cela dépend de facteurs externes comme la stabilité de notre connexion internet, ou de la rapidité du serveur distant.

Un observable est une structure de données qui permet de simplifier les données asynchrones.

RxJS rend la manipulation des observables plus simple en s'appuyant sur ce patron de conception, et en agrémentant ce dernier d'autres structures de données similaires, ainsi que d'opérateurs.

Si vous n'y connaissez rien sur le patron de conception Observable, ni sur la librairie RxJS, cet article est fait pour vous puisque nous allons implémenter de zéro cette librairie en partie afin d'expliquer son fonctionnement.

Promesse vs Observable

Si vous n'avez jamais rencontré la structure de données Observable au cours de votre carrière, vous vous posez sûrement la question suivante.

Mais n'est-ce pas justement le but d'une Promesse de simplifier la gestion d'état asynchrone ?

Effectivement, une promesse permet bien de simplifier la gestion d'état asynchrone, que ce soit le résultat d'une requête HTTP, la récupération du contenu du presse-papier, la sauvegarde d'un fichier, etc.

Néanmoins, dans tous ces cas-là, nous ne recevons qu'une seule réponse, et cela marque la fin de notre promesse.

Notre promesse a donc une fin. Tandis qu'un observable peut lui à l'inverse être infini. Si vous ne comprenez pas l'idée, nous allons essayer d'imager cela par des choses que vous connaissez déjà : un objet et un tableau.

Un objet et un tableau permettent tous deux de stocker de nombreuses données au sein d'une seule et même structure.

Néanmoins, une des grandes différences entre les deux est qu'un objet ne permet de contenir qu'un nombre fini de données. Par exemple, un utilisateur aura un nom, un prénom, une adresse email, et un téléphone.

Tandis qu'un tableau, lui, pourra contenir un nombre infini de données (en supposant que notre mémoire-vive soit elle également infinie). Donc, nous pourrions avoir un utilisateur, trente utilisateurs, un million d'utilisateurs, etc.

Pour les promesses et les observables, la comparaison est similaire : une promesse ne peut résoudre qu'une seule valeur, alors qu'un observable pourra en résoudre une infinité.

Une requête HTTP, par définition par rapport au protocole HTTP, ne résout qu'une seule réponse HTTP, c'est pour cela qu'il est possible de la représenter sous la forme d'une promesse.

Néanmoins, cela n'est pas possible avec le protocole WebSocket par exemple. En effet, avec ce protocole, il est possible d'envoyer zéro ou une infinité d'événements, du client vers le serveur et du serveur vers le client. Mais si vous avez déjà essayé d'implémenter un tunnel WebSocket en utilisant une promesse, vous avez dû vous rendre compte que cela n'était pas possible.

C'est parce que, par nature, un tunnel WebSocket résout une infinité d'événements, et donc une promesse dans ce cas n'est pas approprié.

C'est là qu'intervient un observable : il serait tout à fait possible de concevoir un tunnel WebSocket sous la forme d'un observable qui renverrait une infinité d'événements tant que le tunnel entre le client et le serveur est conservé.

Et il serait également possible de représenter une promesse sous la forme d'un observable ! C'est là tout l'intérêt de pouvoir utiliser un observable qui permettrait de représenter très facilement des données qui arriveraient dans le futur, même s'il n'y a qu'une seule donnée qui serait attendue, comme dans le cas d'une requête HTTP qui n'attendrait qu'une seule réponse HTTP.

Et de la même façon, bien que moins intéressant, il serait aussi possible de représenter des types de données scalaires (String, Object, Number, ...) sous la forme d'un observable.

Observable

Un observable est une structure de données sur laquelle nous allons pouvoir écouter les changements sur ce dernier.

Encore une fois, pour une structure de données scalaire, cela représente peu d'intérêt, néanmoins pour quelque chose qui devient asynchrone et émet des données à travers le temps, comme pour un intervalle par exemple, cela devient bien plus intéressant d'utiliser un Observable pour cela.

Nous utiliserons le langage TypeScript afin de nous aider à produire les types qui nous permettront de comprendre son fonctionnement.

type SubscriberNextCallback<Value> = (value: Value) => void;

interface Subscriber<Value> {
  next: SubscriberNextCallback<Value>
}

class Observable<Value> {
  public subscribe(subscriber: Subscriber<Value>): void {
    console.log("Subscribed to observable changes");
  }
}

const observable = new Observable<number>();

observable.subscribe({
  next: value => {
    console.log(`Here is a value: ${value}`);
  }
});

Voici le code initial de notre implémentation. Essayons de comprendre pas-à-pas comment il fonctionne.

const observable = new Observable<number>();

Cette ligne nous permet d'initialiser notre observable.

Comme vous le constatez, cette classe est générique, c'est-à-dire qu'elle s'adaptera à n'importe quel type d'observable. Cela peut être un observable qui permet de stocker des objets, des tableaux, des chaînes de caractères ou bien dans ce cas, des nombres.

observable.subscribe({
  next: value => {
    console.log(`Here is a value: ${value}`);
  }
});

Cette ligne nous permet de nous abonner à chaque changement de cet observable.

Cela signifie que si sa valeur change, la méthode subscribe sera appelée avec l'objet qui sera passé.

En l'occurrence, dans cet objet, nous avons prévu une propriété next qui permet d'indiquer que faire lorsqu'une valeur est reçue.

Nous parlons ici d'un abonnement, puisque nous nous sommes abonnés aux changements de cet observable. Mais nous pourrions tout à fait avoir plusieurs abonnés à ces changements, tout comme le magazine AutoMoto a plusieurs abonnés.

// Premier abonné
observable.subscribe({
  next: value => {
    console.log(`Here is a value: ${value}`);
  }
});

// Deuxième abonné
observable.subscribe({
  next: value => {
    console.log(`Here is a value: ${value}`);
  }
});

// Troisième abonné
observable.subscribe({
  next: value => {
    console.log(`Here is a value: ${value}`);
  }
});

Ici, chaque abonné recevra les changements, et pourra faire quelque chose d'autre en fonction de son cas d'utilisation (afficher la donnée dans la console, envoyer la donnée sur un serveur, etc.).

class Observable<Value>

Dans cette partie, nous initialisons la définition de notre classe. Comme vous pouvez le constater, il accepte un paramètre générique.

Normalement, une classe ne peut pas accepter d'argument directement dans la définition, contrairement à une fonction par exemple.

Cependant, avec TypeScript, il est possible de lui indiquer un type qu'elle pourra utiliser pour réduire la portée d'utilisation de cette classe. Par exemple, une classe qui ne s'utiliserait que sur des nombres.

Ici, nous lui indiquons simplement que cette classe s'utilise sur n'importe quel type. Alors en quoi est-ce utile ? Cela permettra d'aider plus tard TypeScript à s'adapter à n'importe quelle donnée, puisque celle-ci sera encapsulée dans notre classe, elle ne sera pas pour autant perdue dans les types TypeScript au moment de son utilisation.

  public subscribe(subscriber: Subscriber<Value>): void {
    console.log("Subscribed to observable changes");
  }

Cette méthode nous permet de nous abonner à des changements sur l'observable. Pour l'instant, elle ne fait rien, et n'est pas très utile en soi, mais nous verrons plus tard comment faire pour pouvoir envoyer les nouveaux changements.

Observable et initialisation

Pour l'instant, nous avons facilement créé la coquille de notre observable, mais ce dernier ne fait absolument rien.

D'ailleurs, si vous essayez de vous abonner aux changements, rien n'est exécuté. C'est normal, car vous n'avez pas indiqué à l'abonné quelles sont les valeurs qui seront renvoyées au cours du temps.

Pour cela, essayons de prendre l'exemple suivant : nous souhaitons envoyer toutes les secondes le nombre de secondes qu'il s'est écoulé depuis la création de notre observable.

Pour cela, nous aurons besoin d'un peu plus de code, et nous allons introduire un constructeur à notre classe Observable.

type SubscriberNextCallback<Value> = (value: Value) => void;

type Notify<Value> = (subscriber: Subscriber<Value>) => void;

interface Subscriber<Value> {
  next: SubscriberNextCallback<Value>
}

class Observable<Value> {
  public constructor(private readonly notify: Notify<Value>) { }

  public subscribe(subscriber: Subscriber<Value>): void {
    this.notify(subscriber);
  }
}

const observable = new Observable<number>((subscriber) => {
  let counter = 0;

  setInterval(() => {
    subscriber.next(counter);

    counter++;
  }, 1000);
});

observable.subscribe({
  next: value => {
    console.log(`Here is a value: ${value}`);
  }
});

Désormais, notre observable à un constructeur.

  public constructor(private readonly notify: Notify<Value>) { }

Dans ce constructeur, nous ne faisons rien de spécial. En réalité, nous avons un champ privé de la classe qui contient dorénavant notre méthode propriété notify qui est une fonction.

Si vous ne connaissez pas cette syntaxe raccourcie TypeScript, cela est le strict équivalent à faire ceci.

  private readonly notify: Notify<Value>;

  public constructor(notify: Notify<Value>) {
    this.notify = notify;
  }

Comme vous pouvez le voir, nous stockons ici bien notre propriété notify. C'est le strict équivalent, simplement, nous avons utilisé une syntaxe spécialement conçue pour TypeScript ici.

Ensuite, nous avons évidemment pensé à changer le comportement de la méthode subscribe pour qu'elle prenne en compte notre changement, et pouvoir notifier correctement notre abonné des changements.

  public subscribe(subscriber: Subscriber<Value>): void {
    this.notify(subscriber);
  }

Il n'y a rien de compliqué dans ce code, mais le cheminement est très important à bien comprendre.

Tout d'abord, nous enregistrons une fonction qui accepte un abonné, et qui appelle les méthodes de cet abonné quand on en a envie (donc quand il faut renvoyer des données).

C'est la fonction qui est donnée au constructeur (notify) qui sera utilisée à chaque fois qu'un abonné a souscrit aux abonnements de cet Observable pour le notifier des changements.

Et comme nous avons décidé d'utiliser un intervalle pour pouvoir envoyer des données, l'abonné ne sera notifié que lorsque l'intervalle exécutera notre fonction.

Nous aurions tout à fait pu décider d'envoyer la donnée après une minute, voire de ne rien renvoyer, et cela revient au même : l'abonné n'est notifié que lorsque nous le souhaitons (dans le constructeur).

Et dans notre constructeur, puisque nous recevons un à un les abonnés, nous pouvons appeler leur méthode next qu'ils définissent.

L'avantage est que nous pouvons nous abonner aux changements, et décider que faire en cas de changement sur cet observable, mais c'est bien le constructeur de l'observable qui décidera ce qu'il faut renvoyer comme données, mais pas comment l'utiliser.

Exemples

Essayons d'utiliser notre nouvel observable afin de représenter une requête HTTP.

Si vous le souhaitez, avant de regarder le code-source plus bas, vous pouvez essayer de votre côté pour vous assurer que vous ayez bien compris comment il serait possible de passer d'une promesse à un observable.

Voici le code qui serait nécessaire afin de pouvoir faire cela.

type SubscriberNextCallback<Value> = (value: Value) => void;

type Notify<Value> = (subscriber: Subscriber<Value>) => void;

interface Subscriber<Value> {
  next: SubscriberNextCallback<Value>
}

class Observable<Value> {
  private readonly notify: Notify<Value>;

  public constructor(notify: Notify<Value>) {
    this.notify = notify;
  }

  public subscribe(subscriber: Subscriber<Value>): void {
    this.notify(subscriber);
  }
}

const response$ = new Observable<Response>((subscriber) => {
  fetch("https://ipapi.co/json").then(response => {
    subscriber.next(response);
  });
});

response$.subscribe({
  next: value => {
    console.log("HTTP Response");
    console.log(value);
  }
});

Comme vous pouvez le voir, nous avons créé une variable response$ qui nous permet de stocker notre observable.

Petite information utile : par convention, nous finissons toujours les noms de variables et constantes qui contiennent des observables par un $. Cela permet de s'y retrouver, et de savoir à n'importe quel endroit de notre code, bien que cela ne soit pas obligatoire.

À l'intérieur du constructeur de notre observable, nous avons appelé l'API Web Fetch. Lorsque la réponse est arrivé, nous appelons simplement l'abonné, et spécialement sa méthode next pour pouvoir lui indiquer qu'une réponse est arrivée, ce sera ensuite à lui de décider quoi faire avec cette valeur (en l'occurrence ici, un objet de la classe Response).

Enfin, côté abonné, nous avons simplement écrit le code que nous souhaitons exécuter lorsqu'une réponse HTTP est arrivée, en l'occurrence ici, un simple affichage dans la console, mais nous aurions pu transformer la réponse au format JSON par exemple et récupérer son contenu aussi !

Si vous avez déjà utilisé le framework Angular, c'est à peu près ce qui est créé lorsque vous utilisez le client HTTP. Et nous pourrions nous amuser à créé de nouveau ce dernier nous-même.

const http = {
  get<Value>(url: string) {
    return new Observable<Value>((subscriber) => {
      fetch(url).then(response => {
        response.json().then(json => {
          subscriber.next(json as Value);
        });
      });
    });
  }
};

interface IpapiResponse {
  ip: string
}

http.get<IpapiResponse>("https://ipapi.co/json").subscribe({
  next: value => {
    console.log(`IP is ${value.ip}`)
  }
});

Attention, l'implémentation ici n'est pas fidèle au code-source original mais permet de mieux comprendre ce qu'il se passe derrière les rideaux.

Comme vous pouvez le voir ici, n'importe quelle structure de données, synchrone ou asynchrone, en JavaScript peut être retournée sous la forme d'un observable.

À partir d'ici, vous savez déjà comment fonctionne un observable, et vous pouvez désormais commencer à importer la librairie rxjs dans votre propre code (pas seulement sur Angular ou Nest.js) afin d'utiliser des structures de données asynchrones !

Opérateurs

Maintenant que nous savons créer et récupérer des valeurs d'un observable, il serait intéressant de pouvoir les utiliser dans des cas un peu plus complexes, comme par exemple créér une fonction nous permettant de factoriser le code utilisé pour effectuer des requêtes HTTP.

Nous allons repartir de l'exemple ci-dessus, mais à notre propre sauce, ce qui nous permettra également d'avoir une excuse pour voir qu'est-ce qu'un opérateur dans la librairie RxJS.

const getJson = (url: string) => {
  return new Observable<Response>(subscriber => {
    fetch(url).then(response => {
      subscriber.next(response);
    });
  });
};

getJson("https://ipapi.co/json").subscribe({
  next: response => {
    response.json().then(json => {
      console.log(json.ip);
    });
  }
});

getJson("https://ipapi.co/json").subscribe({
  next: response => {
    response.json().then(json => {
      console.log(json.country);
    });
  }
});

Comme pour tout à l'heure, nous avons créé une fonction nous permettant d'exécuter des requêtes HTTP, qui sont encapsulées dans un observable, et qui nous permet de récupérer la réponse HTTP, pour pouvoir ensuite la transformer en JSON et afficher son contenu.

Comme vous pouvez le voir, nous mélangeons structure de données observable et promesses. Il n'y a rien qui empêche d'effectuer cela, surtout si la fonction ne renvoie pas directement le contenu JSON, mais nous pourrions faire un peu mieux, en passant par des opérateurs qui vont nous permettre de transformer nos données.

Par contre nous avons un problème : ici nous répétons le code, afin d'accéder à la propriété ip et country pour deux parties de notre application qui en ont besoin.

Le problème ici est que vis-à-vis de notre propre implémentation, il est pour l'instant assez difficile de factoriser de la logique, c'est-à-dire ici la logique nous permettant de passer d'un objet Response à un objet JSON.

C'est l'objectif d'un opérateur : factoriser de la logique permettant de manipuler un observable, et pouvoir réutiliser cette logique plus tard à différents endroits de notre code.

Map : transformer des données

map est probablement l'un des opérateurs les plus utilisés, notamment du fait qu'il est similaire à la méthode Array.prototype.map, et pourtant les deux ne font absolument pas la même chose.

Il sera intéressant de voir quel est l'implémentation de cette méthode afin d'en comprendre les différences.

Voici un exemple d'implémentation, que nous commenterons juste en dessous.

const map = <Value, NewValue>(update: (value: Value) => NewValue) => {
  return (observable: Observable<Value>): Observable<NewValue> => {
    const newObservable = new Observable<NewValue>(subscriber => {
      observable.subscribe({
        next: value => {
          subscriber.next(update(value));
        }
      });
    });

    return newObservable;
  };
};

La fonction est assez dense, donc allons-y petit à petit.

const map = <Value, NewValue>(update: (value: Value) => NewValue) => {

Dans la définition de notre fonction, nous acceptons une fonction update.

Cette fonction sera responsable de mettre à jour un type de données de type Value vers un autre type de données de type NewValue.

Cela ne vous rappelle rien ? Oui, effectivement, c'est assez similaire à ce que nous faisons avec la méthode Array.prototype.map qui prend une fonction nous permettant de mettre à jour un élément, et va appliquer cette fonction à tous les éléments du tableau.

Dans le cas d'un observable, cette fonction sera appliquée à toutes les valeurs de notre observable. Souvenez-vous qu'un observable peut être infini, donc si l'observable émet plusieurs valeurs, elle seront donc toutes transformées.

  return (observable: Observable<Value>): Observable<NewValue> => {

Notre fonction map retourne quant à elle un nouvel observable. Comme nous ne pouvons plus modifier notre constructeur, une fois que l'observable est créé, c'est la seule façon pour nous de pouvoir continuer à s'abonner à des changements, en prenant en compte notre fonction de mise à jour.

De cette façon, on continue à utiliser une méthode subscribe, mais cette-fois ci sur un second observable.

    const newObservable = new Observable<NewValue>(subscriber => {

Ceci est la ligne qui constitue notre second observable.

Cet observable sera retourné plus tard dans notre fonction. C'est la cible qui sera utilisé pour tous les futurs abonnements.

      observable.subscribe({
        next: value => {
          subscriber.next(update(value));
        }
      });

Comme nous le voyons ici, le second observable va s'abonner au premier. Et lorsqu'une donnée est reçue, il renverra à l'abonné du second observable la même donnée, mais transformée avec la fonction qui avait été passé en premier argument.

    return newObservable;

Enfin, nous renvoyons le second observable, et la boucle est bouclée : chaque fois qu'une valeur est renvoyée, elle est renvoyée par le second observable qui va notifier l'abonné qu'une nouvelle donnée est reçue, sauf qu'elle aura au préalable été transformée.

Si vous avez bien compris, ici l'opérateur map renvoie au final un nouvel observable, il faudra donc bien faire attention à souscrire au deuxième observable, et non pas au premier.

Voyons un exemple maintenant, cette fois-ci avec un observable très simple qui renverra un nombre après un certains nombre de seconde.

const map = <Value, NewValue>(update: (value: Value) => NewValue) => {
  return (observable: Observable<Value>): Observable<NewValue> => {
    const newObservable = new Observable<NewValue>(subscriber => {
      observable.subscribe({
        next: value => {
          subscriber.next(update(value));
        }
      });
    });

    return newObservable;
  };
};

const doubleNumber = map<number, number>(value => {
  return value * 2;
});

const number$ = new Observable<number>(subscriber => {
  setTimeout(() => {
    subscriber.next(7);
  }, 3000);
});

const doubledNumber$ = doubleNumber(number$);

doubledNumber$.subscribe({
  next: value => {
    console.log(`Doubled number is: ${value}`);
  }
});

Si vous essayez de lancer ce code, vous obtiendrez la valeur de 14 après trois secondes.

Comme vous pouvez le constater, nous avons souscrit à un nouvel observable nommé doubledNumber$. C'est cet observable qui contiendra la valeur doublée.

Si nous avions essayé de souscrire à number$ seulement, nous aurions récupéré la valeur de 7.

Ceci s'explique car RxJS s'inspire de la programmation fonctionnelle, qui est un style de programmation qui privilégie l'immutabilité au lieu de modifier directement nos valeurs (comme c'est le cas pour une classe avec des propriétés non readonly).

Si jamais nous avions souhaité, en plus de doubler la valeur, de tripler celle-ci et d'obtenir un nombre doublé et triplé. Nous aurions pu faire comme cela.

const map = <Value, NewValue>(update: (value: Value) => NewValue) => {
  return (observable: Observable<Value>): Observable<NewValue> => {
    const newObservable = new Observable<NewValue>(subscriber => {
      observable.subscribe({
        next: value => {
          subscriber.next(update(value));
        }
      });
    });

    return newObservable;
  };
};

const doubleNumber = map<number, number>(value => {
  return value * 2;
});

const tripleNumber = map<number, number>(value => {
  return value * 3;
});

const number$ = new Observable<number>(subscriber => {
  setTimeout(() => {
    subscriber.next(7);
  }, 3000);
});

const doubledNumber$ = doubleNumber(number$);
const tripledNumber$ = tripleNumber(doubledNumber$);

tripledNumber$.subscribe({
  next: value => {
    console.log(`Doubled & tripled number is: ${value}`);
  }
});

Et comme précédemment, si vous lancez ce code, vous obtiendrez la valeur 42.

Néanmoins, le code qui a du être ajouté afin de générer quelque chose d'aussi simple ne nous permet pas de faire évoluer notre code rapidement si nous avons d'autres pré-requis sur ce nombre à l'avenir.

Pour cela, il nous faut une manière de pouvoir combiner toutes ces informations, surtout qu'ici, les observables précédemment créé ne nous sont d'aucune utilité après une transformation.

Pipe : combiner plusieurs opérateurs

Maintenant que nous avons vu comment créer et utiliser un opérateur, voyons comment combiner plusieurs d'entre-eux.

Pour cela, nous allons dévier légèrement de l'implémentation originale de la librairie rxjs et proposer une implémentation plus simple, mais qui ne change fondamentalement rien à la compréhension de la librairie.

Voici un exemple d'implémentation d'une méthode nous permettant de combiner plusieurs opérateurs.

type SubscriberNextCallback<Value> = (value: Value) => void;

type Notify<Value> = (subscriber: Subscriber<Value>) => void;

type OperatorTransform<Value, NewValue> = (observable: Observable<Value>) => Observable<NewValue>;

type Operator<Value, NewValue> = (update: (value: Value) => NewValue) => OperatorTransform<Value, NewValue>

interface Subscriber<Value> {
  next: SubscriberNextCallback<Value>
}

class Observable<Value> {
  private readonly notify: Notify<Value>;

  public constructor(notify: Notify<Value>) {
    this.notify = notify;
  }

  public subscribe(subscriber: Subscriber<Value>): void {
    this.notify(subscriber);
  }

  public pipe<NewValue>(transform: OperatorTransform<Value, NewValue>): Observable<NewValue> {
    return transform(this);
  }
}

const map = <Value, NewValue>(update: (value: Value) => NewValue) => {
  return (observable: Observable<Value>): Observable<NewValue> => {
    const newObservable = new Observable<NewValue>(subscriber => {
      observable.subscribe({
        next: value => {
          subscriber.next(update(value));
        }
      });
    });

    return newObservable;
  };
};

Dans notre exemple, la seule partie de code qui a changé est l'ajout d'une nouvelle méthode pipe.

  public pipe<NewValue>(transform: OperatorTransform<Value, NewValue>): Observable<NewValue> {
    return transform(this);
  }

Cette méthode nous permet de récupérer une transformation. Ici, une transformation est une fonction qui a été appelée par un opérateur comme map en l'occurrence.

Il suffit simplement d'appliquer la transformation à un observable. Cela tombe bien, nous en avons déjà puisque la méthode pipe s'utilise sur un observable, c'est l'instance actuelle sur laquelle la méthode pipe est appelée ! Donc il suffit tout simplement de passer this à la transformation afin de récupérer un nouvel observable.

Et désormais, le code précédent peut s'écrire bien plus simplement comme cela.

const number$ = new Observable<number>(subscriber => {
  setTimeout(() => {
    subscriber.next(7);
  }, 3000);
});


number$
  .pipe(map(value => value * 2))
  .pipe(map(value => value * 3))
  .subscribe({
    next: value => {
      console.log(`Doubled & tripled number is: ${value}`);
    }
  });

On voit clairement que la taille du code a diminué, mais pas le résultat attendu, et on pourrait très facilement rajouter plusieurs autres transformations.

D'ailleurs si vous êtes curieux, voici ce qui est utilisé par rxjs afin de combiner des opérateurs.

const number$ = new Observable<number>(subscriber => {
  setTimeout(() => {
    subscriber.next(7);
  }, 3000);
});


number$.pipe(
  map(value => value * 2),
  map(value => value * 3)
).subscribe({
    next: value => {
      console.log(`Doubled & tripled number is: ${value}`);
    }
  });

La seule différence est que la méthode pipe définie par rxjs prends en réalité plusieurs opérateurs à la fois dans la définition de la méthode. Je vous laisse le soin de l'implémenter de cette façon si vous souhaitez vous ajouter du challenge supplémentaire.

from : transformer des données en observable

Reprenons notre exemple initial, celui qui consistait à récupérer des données depuis un serveur HTTP.

const getJson = (url: string) => {
  return new Observable<Response>(subscriber => {
    fetch(url).then(response => {
      subscriber.next(response);
    });
  });
};

getJson("https://ipapi.co/json").subscribe({
  next: response => {
    response.json().then(json => {
      console.log(json.ip);
    });
  }
});

Dans cet exemple, nous avons créé une fonction nous permettant d'exécuter une requête en GET, que se passerait-il si nous avions besoin d'exécuter une requête en POST ?

Il faudrait pour cela créer une nouvelle fonction nous permettant de le faire bien entendu.

const postJson = (url: string, data: any): Observable<Response> => {
  return new Observable<Response>(subscriber => {
    fetch(url).then(response => {
      subscriber.next(response);
    });
  });
};

postJson("https://jsonplaceholder.typicode.com/users", {
  id: 11,
  username: "aminnairi",
  email: "amin@nairi.cloud"
}).subscribe({
  next: response => {
    console.log(response.status);
  }
});

Cela serait bien pénible de devoir à chaque fois englober chacune de nos opérations manuellement dans une fonction qui retourne un observable, il serait intéressant de pouvoir le faire plus rapidement.

Pour cela, créons un opérateur de création.

const fromPromise = <Result>(promise: Promise<Result>): Observable<Result> => {
  return new Observable<Result>(subscriber => {
    promise.then(result => {
      subscriber.next(result);
    });
  });
};

const ipapiUrl = "https://ipapi.co/json"
const userUrl = "https://jsonplaceholder.typicode.com/users/1";

fromPromise(fetch(ipapiUrl).then(response => response.json())).subscribe({
  next: value => {
    console.log(`IP is ${value["ip"]}`)
  }
});

fromPromise(fetch(userUrl).then(response => response.json())).subscribe({
  next: value => {
    console.log(`User name is ${value["name"]}`)
  }
});

Désormais, toutes les promesses pourront servir d'observable puisqu'on sait comment les transformer, il est donc trivial de partir d'une promesse, et d'arriver à un observable.

Et nous pouvons les combiner avec les opérateurs que nous avons déjà vu auparavant afin d'obtenir un code plus déclaratif comme celui-ci.

const fromPromise = <Result>(promise: Promise<Result>): Observable<Result> => {
  return new Observable<Result>(subscriber => {
    promise.then(result => {
      subscriber.next(result);
    });
  });
};

const ipapiUrl = "https://ipapi.co/json"
const userUrl = "https://jsonplaceholder.typicode.com/users/1";

fromPromise(fetch(ipapiUrl).then(response => response.json()))
  .pipe(map(value => `IP is ${value["ip"]}`))
  .subscribe({ next: console.log });

fromPromise(fetch(userUrl).then(response => response.json()))
  .pipe(map(value => `User name is ${value["name"]}`))
  .subscribe({ next: console.log });

Il existe différents types d'opérateurs pour différentes opérations : créations d'observable, transformations d'observables, filtrage de données, combinaison d'observables, etc.

Conclusion

Cet article est déjà très long, et pourtant nous n'avons grâté que la surface de ce qui représente un observable du point de vue de la librairie RxJS, et il existe des tas d'autres opérateurs que vous pourrez découvrir sur la page de documentation officielle RxJS.

Le patron de conception Observable est un modèle de programmation parmi d'autres permettant de manipuler des structures de données asynchrones plus simplement.

C'est un modèle qui est également à la base d'un concept très populaire qui sont les signaux et qui sont repris par beaucoup de framework JavaScript populaire comme Angular, Vue et Solid. Les concepts sont strictement identiques et seul les cas d'usage et leur implémentation varie par rapport à ce que nous avons découvert ensemble ici.

J'espère que désormais ce concept d'observable vous sera plus familier et que vous serez bien plus à l'aise lorsque vous aurez à utiliser la librairie RxJS, n'hésitez-pas à aggrémenter tous ces exemples de vos propres opérateurs, voir d'en inventer de nouveaux !