Expérimentation de FRP avec Bacon.js
La théorie
Ethymologie
Tout anagramme mérite une petite explication. C’est souvent par là qu’il faut commencer pour mieux comprendre une technologie.
Functional Reactive Programming
Reactive : d’après wordnet “qui réagit à un stimulus”.
Functional Programming: fait référence à la programmation fonctionnelle
Analogie
L’analogie que l’on retrouve le plus souvent sur le net pour expliquer FRP est la feuille de calcul d’un tableur.
Certaines cellules contiennent des valeurs
que l’utilisateur peut changer, et d’autres des formules
.
Par exemple
Appliqué à FRP une feuille de calcul s’explique de la manière suivante :
A1
etB1
sont des streams d’events. Quand un utilisateur change la valeur de la cellule, un event est envoyé à la cellule qui change alors sa valeur couranteC1
est aussi un stream d’events. Seulement, les évênements ne sont plus générés directement par l’utilisateur mais induits par un changement de valeur courante deA1
etB1
.C1
réagit, observe, écoute les évênements des deux autres cellules.
C’est sorte d’implémentation du fameux observateur.
Vocabulaire
Sur le net on trouvera un vocabulaire éparse et confus pour désigner des concepts sémantiquements identiques. Enumérons :
- event : les stimuli de l’application
- event stream, property, behaviour, observable : une variable qui réagit à des events
- observer, listener : généralement une fonction qui prend la valeur courante d’un event stream et réalise un side effect.
- side effect : une action qui modifie l’environnement. Par exemple une sortie sur la console, une affectation de variable ou encore la manipulation du DOM.
Présentation Bacon.js
Bacon.js prend son inspiration en la librairie RxJs de Microsoft.
Bacon.js propose deux types d’ observable:
- event stream
- property : est très similaire à event stream, à l’exception qu’une property a toujours une valeur, dès sa création.
Bacon.js propose des méthodes pour passer d’un type d’observable à l’autre, et des méthodes pour créer et mixer des observables. Le plus simple, est d’aller faire un tour sur le readme du projet pour vous faire un première idée. Visitez le wiki et les quelques exemples.
La pratique
Objectif
Dans ce document d’exploration on va implémenter un formulaire qui permet de choisir son pays. La liste des pays est récupérée d’un service qui cause en JSON. Il faudra gérer un spinner et la gestion des erreurs. Simple ? Cela dépend de la rigueur que l’on met à traiter tous les états …
J’ai écrit un petit serveur tout simple en clojure dont les sources sont ici. Pour le builder, il faut récupérer lein et lancer la commande
lein ring server
Autrement, l’application complète (client et serveur) est déployée sur http://rocky-reef-4045.herokuapp.com/index.html. Attention aux yeux, ça pique un peu comme design :)
Récupérer la liste des pays
Un GET sur /countries permet de récupérer la liste des pays. C’est un objet JSON de cette forme :
Bacon.js s’intègre bien avec jQuery. fromPromise
permet de créer un EventStream
pour un appel ajax renvoyant une promesse.
Si l’appel réussit, le stream ajaxContries
contiendra la réponse du serveur. Sinon l’état du stream est en erreur.
Le cas d’erreur est un peu spécial, il ne produit pas de valeur mais fait basculer l’état du stream à error
. mapError
permet de produire quand même une valeur
en cas d’erreur.
A des fins de test, environ 1 fois sur 4 le crache une erreur 500. D’expérience, la gestion des erreurs dans une application faisant un usage d’AJAX doit être prévue depuis la conception.
Votre application Javascript doit prévoir les cas d’erreur. Si elle ne les prévoit pas vous aurez sûrement des erreurs de déférencement, et un freeze de l’application. True story.
Donc nous allons déjà adresser le cas d’erreur en créant une nouvelle Property
.
map
permet de transformer une valeur en une autre valeur. Ici, si la valeur vaut "ERROR"
on map à true
sinon false
.
map
et mapError
renvoient un nouvel EventStream
et prennent normalement une fonction en paramètre. Cette fonction prend en
paramètre la valeur de l’évènement et retourne la nouvelle valeur.
Ici on passe une constante. C’est un sucre syntaxique pour une fonction qui serait similaire à :
toProperty
transforme un EventStream
en une Property
avec une valeur par défaut, ici false
.
En effet, quelque soit l’état de l’application on doit savoir s’il faut ou non afficher le message d’erreur.
C’est la principale différence entre un EventStream
, qui peut ne pas avoir de valeur (pendant le temps de la requête),
et une Property
qui a toujours une valeur.
skipDuplicate
est une petite optimisation qui empêche que deux valeurs consécutives dans le stream soient égales.
Pour résumé, voici les 2 états possibles des deux streams que nous avons créé :
Pour le moment nous n’avons écrit que des définitions. Ce sont des vérités qui seront toujours vraies, quelque soit l’état de l’application. Nous n’avons que des variables immutables. Aucun side effect.
Attaquons nous maintenant aux side effects.
Le DOM sera produit à partir du fichier HTML suivant :
Un span
contient l’erreur. Le reste du document est de la machinerie.
Maintenant, traitons visuellement le cas d’erreur:
A chaque fois qu’une nouvelle valeur arrive dans la Property
isErrorAjaxCountries
, on appelle la fonction chargée d’exécuter le side effect, ici showOrHideErrorMessage
.
Cette fonction peut faire n’importe quoi. Ici elle cache le span si la valeur est false
, sinon elle l’affiche.
Nous avons géré un appel ajax en traitant le cas d’erreur avec 2 vars et 2 fonction.
Ajout du spinner de chargement
On définit une nouvelle Property
isOngoingAjaxCountries
qui vaut :
false
tant que l’un des 2EventStream
n’a pas été alimenté ou dès queisErrorAjaxCountries
a été alimenté parajaxCountries
true
tant queisErrorAjaxCountries
n’a pas été alimenté parajaxCountries
Visuellement, le spinner n’est qu’un span
que l’on décide d’afficher ou non.
On applique le side effect pour afficher ou non le spinner, de façon similaire au message d’erreur.
Affichage d’une boîte de saisie du pays
On définit un EventStream
qui est un peu l’inverse de isErrorAjaxCountries
.
Visuellement, c’est une combobox éditable.
On applique le side effect pour afficher ou non l’input, de façon similaire au message d’erreur.
Remplissage de la boîte de saisie
On définit un EventStream
qui nous renseigne sur la disponibilité
de la liste des pays.
On remplit la datalist avec la liste des pays.
Récapitulons
Le code javascript complet est accessible ici, je vous conseille de le parcourir rapidement. Le code est divisé en deux parties.
D’une part, une partie que j’appelle définitions. Cette partie est agnostique à la technologie. Elle décrit le flux de données de notre application.
Chaque Observable
défini dans cette partie a une sémantique propre et vraie quelque soit l’état de l’application. L’ensemble forme un graphe de dépendances.
ajaxCountries
: le résultat de la requête ajax. C’est l’observable tout en haut de la pyramide des dépendances.isErrorAjaxCountries
: contienttrue
si la requête s’est mal passée.isDoneAjaxCountries
: contienttrue
si la requête s’est bien passée.isOngoingAjaxCountries
: Contienttrue
si la requête ajax est en cours.countriesList
: contient la liste des pays.
Je trouve tout cela claire : on a la requête, ses 3 états et sa valeur finale.
Je trouve aussi que c’est extensible. Par exemple on pourrait retravailler facilement les valeurs de countriesList
avec un méthode map
pour les passer en majuscule. Ou alors on aurait pu conditionner ajaxCountries
par un autre EventStream
pour ne faire la requête que sous certaines conditions …
D’autre part, une partie appelée side effects qui ne contient que de la logique d’affichage :
isErrorAjaxCountries.onValue
: on affiche ou pas un message d’erreurisOngoingAjaxCountries.onValue
: on affiche ou pas un spinnerisDoneAjaxCountries.onValue
: on affiche ou pas la boîte de saisie à l’utilisateurcountriesList.onValue
: on traite le résultat de la requête
J’ai un bon sentiment sur le code que j’ai écrit. J’ai pas l’impression d’avoir laissé trainer des bugs. On a presque l’impression d’avoir écrit une preuve mathématique. On ne flippe pas sur un problème de déférencement non plus car toutes les variables sont immutables.
Difficultés
EventStream ou Property ?
Bacon.js fait une distinction entre EventStream
et Property
. Je trouve que ce n’est pas très pratique, et on est souvent entrain de se demander quel est le type de notre Observable. Aussi, je trouve la barrière entre les deux concepts un peu floue. La seule différence semble être que la Property
peut avoir une valeur par défaut.
Un graphe de dépendance
Le debuggage est assez compliqué, il n’est parfois pas évident de trouver pourquoi tel événement a eu lieu.
Avec un exemple assez conséquent, on oublie qui dépend de qui. Il faut reparcourir les définitions. Pouvoir extraire un arbre des dépendances et le visualiser synthétiserait et aiderait beaucoup le développeur. J’ai trop souvent recours à des console.log()
pour debugger.
Verbosité ?
D’aucun me rétorquera qu’avec jQuery, on fait tout ça de manière moins verbeuse (complete
, error
, success
, …). J’ai pris une requête ajax en exemple, car cela reste conçis mais j’aurais pu trouver un exemple où jQuery n’offre pas autant de raccourcis.
J’ai également volontairement variabilisé les différentes étapes et les fonctions au lieu de les inliner. Je trouve ça plus claire, la syntaxe de Javascript étant très lourde.
Aller plus loin ?
J’ai à peine effleuré les capacité de Bacon. Il y a des tas de façon de travailler les streams (merge
, flatMap
, …). Notons aussi qu’il existe un petit event bus
qui peut se révéler pratique sur des pages de plus grande envergure.
Mais imaginons un scénario plus complexe où après avoir choisi son pays on l’invite à saisir son code postal. Dès que les 2 premiers chiffres sont saisis, on l’invite à choisir sa ville dans une liste récupérée en ajax, tout en lui laissant le choix de compléter son code postal en mettant à jour la liste des villes au fur et à mesure. On ne rend le bouton envoyer cliquable que si tout est saisi sans erreur. Le tout avec spinners et message d’erreur…
Ce que je viens de décrire, je l’ai vraiment implémenté une fois avec un jQuery 1.4 (pas de complete
, error
, success
) et sans plugins (je ne vous dirai pas où, j’ai trop honte). Et c’était l’enfer. Il faudrait que j’essaye de réimplémenter ça …