I. Retour sur le MVC▲
Flux ayant émergé suite à des soucis d'utilisation du pattern MVC chez Facebook, je vais revenir dessus en tant que base pour poser la problématique.
I-A. Le MVC avec Spring▲
Regardons une des façons de faire du MVC côté Java. Un schéma étant plus parlant que de grands discours, voici le fonctionnement de Spring MVC :
L'important ici n'est pas de se focaliser sur les aspects du traitement des requêtes HTTP (servlet, etc.), mais plutôt de regarder la communication entre les différentes briques.
On voit que, dans ce cas-ci, le Controller principal transmet la requête au Controller concerné. Celui-ci va renvoyer un modèle contenant toutes les informations voulues. Ce modèle va être transmis à la vue correspondante par le Controller principal, et celle-ci va extraire les informations du modèle pour les afficher. Le résultat final est la réponse HTTP envoyée par le Controller principal. On a donc des échanges bidirectionnels entre chaque brique du MVC, orchestrés par un Controller principal.
I-B. AngularJS et son MVW▲
Maintenant, un exemple côté JavaScript. Encore une fois, commençons par quelques illustrations. Dans le cas d'AngularJS, le pattern mis en œuvre est un peu différent du MVC classique, puisqu'il n'y a pas de controller qui s'occupe d'orchestrer les échanges entre les éléments du MVC. On a plutôt un système géré par les événements. On parle très souvent de MVVM pour Model, View, ViewModel. Ce pattern est orienté événements et l'idée générale est la suivante :
En fait, ici on ne voit pas le ViewModel. C'est le composant qui permet la boucle de mise à jour de la vue en fonction du modèle. C'est pour cela que le nom officiel est MVW, pour Whatever, parce qu'il n'est finalement pas important de se concentrer sur le composant qui fait le lien. Ce qui est important, c'est le fameux two-way databinding basé sur les événements. Le détail du fonctionnement de la boucle d'événement est disponible ici.
II. Flux▲
II-A. Pourquoi Flux ?▲
Maintenant que le MVC est frais dans notre tête, on peut se demander pourquoi utiliser un autre pattern. Après tout, si le MVC a du succès, c'est qu'il répond à un grand nombre de problématiques. C'est assez vrai en général, mais on rencontre plus souvent qu'on ne croit des cas où le MVC peut être source de problèmes. Et c'était le cas chez Facebook.
Flux a été présenté lors de la conférence F8 de 2014.
En résumé, Facebook a rencontré un bug au niveau des notifications du chat. Le compteur de notification affichait une valeur 1 alors qu'il n'y avait pas de notification dans la liste. Le bug a été identifié puis corrigé, mais il est réapparu par la suite. La source de ce cercle vicieux venait de l'implémentation du MVC.
Il faut imaginer que, dans une application à taille réelle - j'entends par là une centaine de composants au moins avec chacun sa logique métier et sa vue -, on peut vite arriver à avoir beaucoup de modèles qui sont référencés dans beaucoup de vues, des données calculées les unes à partir des autres, et du code appelé à plusieurs endroits, ce qui nous amène rapidement à du code spaghetti. Et avec le MVC, les données transitent à double sens entre chaque composant. Ajoutez à ça les contraintes opérationnelles qui peuvent pousser à faire l'impasse sur un code de qualité, à tort ou à raison, et on comprend mieux pourquoi ça peut devenir compliqué !
Prenons une application AngularJS qui partage un objet à travers un service, lui-même injecté dans différents contrôleurs pour rendre accessible l'objet en question dans les vues associées. La référence de l'objet se retrouve à peu près partout, ses attributs étant modifiables depuis chaque vue. Il devient très difficile, à la simple lecture du code, de savoir à quel moment l'objet est modifié.
II-B. Le pattern▲
Pour simplifier les problèmes éventuels décrits ci-dessus, Flux propose une transmission de données unidirectionnelle et des composants qui ont chacun une responsabilité clairement identifiée :
II-B-1. Les Actions▲
En rouge sur le schéma.
Dans une architecture Flux, tout passe par les actions. On ne peut pas modifier l'affichage d'un composant ou déclencher un comportement sans action. C'est à partir d'une action qu'on pourra modifier le state d'un composant React par exemple. Si le code de l'action est complexe, on peut séparer sa création et l'instance elle-même (ce qui explique les deux briques du schéma). C'est dans ce composant qu'on s'interfacera avec des API externes à l'architecture Flux. Typiquement, les actions sont déclenchées par un clic dans une GUI, ou bien peuvent provenir du serveur via une websocket, par exemple.
II-B-2. Le dispatcher▲
En noir sur le schéma.
Le dispatcher est le composant unique qui reçoit toutes les actions de l'application. Son rôle est de notifier tous les stores via des callbacks que l'action a eu lieu.
II-B-3. Les stores▲
En marron sur le schéma.
Les stores sont les composants de Flux qui vont contenir et gérer les states de l'application. Ils fournissent au dispatcher les callbaks exécutés lors de la notification d'une action. Il va donc contenir l'implémentation de toutes les règles de gestions du domaine qu'il couvre. Il va également gérer les actions qu'il veut traiter, car comme expliqué ci-dessus, le dispatcher notifie les stores de toutes les actions de l'application. Chaque store s'occupera d'une partie du fonctionnel de l'application et ne voudra donc pas traiter toutes les actions. Pour finir, les stores vont notifier par événement les changements d'état aux vues leur correspondant.
II-B-4. Les vues▲
En bleu sur le schéma.
Les vues sont chargées d'afficher le state contenu dans le store associé. Elles s'enregistrent auprès du store pour être notifiées des changements de state, pour se mettre à jour en conséquence.
II-B-5. Tous ensemble▲
Finalement, si on reformule l'enchaînement de tout ça, il y a deux phases :
- à l'initialisation : le dispatcher est instancié de manière unique. Chaque store s'enregistre auprès de lui via un callback et choisit de traiter une liste d'actions prédéfinies. Chaque vue s'abonne à un événement dans le but de se mettre à jour pour refléter le changement de state du store ;
- suite à une action : une action est déclenchée et est envoyée au dispatcher, qui va exécuter tous les callbacks de tous les stores pour propager l'action en cours. Chaque store qui l'a prévu va exécuter les règles associées à cette action et mettre son state à jour en fonction. Si le state est mis à jour, un événement est déclenché pour dire aux vues associées au store de se rafraîchir pour traduire le changement de state du store.
II-C. Le code▲
Le paragraphe précédent est très théorique. Je propose donc un exemple de code succinct pour illustrer le propos. On ne présentera pas ici les nombreuses implémentations disponibles de Flux. D'ailleurs, Facebook a publié, sur github, un ensemble utilitaire pour modéliser simplement le pattern. Au vu de la simplicité du pattern, l'exemple ci-après va exploiter ces flux-utils. Quitte à utiliser un framework pour mettre en place un pattern de type flux, je recommanderais plutôt la mise en place de redux. Nous y reviendrons dans le paragraphe suivant.
Regardons donc Flux au travers d'un exemple.
Nous allons prendre le cas d'un stock de produits que l'on peut ajouter à un panier. Si on ajoute un produit au panier, cela diminue le stock dans le rayon et l'augmente dans le panier (logique) et inversement.
Le tout est packagé par Webpack.
II-C-1. Les Actions▲
Pour commencer, faisons l'inventaire des actions. On peut ajouter ou supprimer un article du panier, ce qui va entraîner une diminution ou une augmentation du stock. Donc nous aurons ADD_TO_BASKET, REMOVE_FROM_BASKET, FILL_STOCK et EMPTY_STOCK.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
import
FluxAppDispatcher from
'
../dispatcher/fluxAppDispatcher
'
;
import
Constants from
'
../constants
'
;
export
default class
BasketAction {
static
addProduct
(
product) {
const
addBasketActionObject =
{
type
:
Constants.
ADD_TO_BASKET,
payload
:
product,
};
FluxAppDispatcher.dispatch
(
addBasketActionObject);
}
static
removeProduct
(
product) {
const
removeBasketActionObject =
{
type
:
Constants.
REMOVE_FROM_BASKET,
payload
:
product,
};
FluxAppDispatcher.dispatch
(
removeBasketActionObject);
}
}
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
import
FluxAppDispatcher from
'
../dispatcher/fluxAppDispatcher
'
;
import
Constants from
'
../constants
'
;
export
default class
StockAction {
static
increaseStock
(
stock) {
const
increaseStockActionObject =
{
type
:
Constants.
FILL_STOCK,
payload
:
stock,
};
FluxAppDispatcher.dispatch
(
increaseStockActionObject);
}
static
decreaseStock
(
stock) {
const
decreaseStockActionObject =
{
type
:
Constants.
EMPTY_STOCK,
payload
:
stock,
};
FluxAppDispatcher.dispatch
(
decreaseStockActionObject);
}
}
Dans le code, on voit la différence entre les ActionCreator (ici les classes elles-mêmes) et les actions à proprement parler. Les variables appelées actionPayload sont les objets modélisant les actions. Ici, ce sont de simples conteneurs.
II-C-2. Le dispatcher▲
Le dispatcher est le code le plus simple, puisqu'on utilise directement l'objet fourni par la dépendance « flux » de Facebook :
import
Flux from
'
flux
'
;
export
default new
Flux.Dispatcher
(
);
Le dispatcher devant être unique, on exporte une instance du Dispatcher et non le prototype seul. C'est cette instance qui sera utilisée tout au long de la durée de vie de l'application.
II-C-3. Les stores▲
Nous allons construire deux stores : un pour gérer l'état du panier, l'autre pour gérer l'état du rayon. Regardons deux implémentations différentes. La première reposera sur l'EventEmmitter de NodeJS pour gérer les événements envoyés aux vues. La seconde exploitera directement la classe FluxStore exposée en tant que Store par le module flux/utils.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
import
Constants from
'
../constants
'
;
import
Dispatcher from
'
../dispatcher/fluxAppDispatcher
'
;
import
EventEmmiter from
'
events
'
;
import
initProduct from
'
../data.json
'
;
const
products =
initProduct;
class
ShelfStore extends
EventEmmiter {
emitChange
(
) {
this
.emit
(
Constants.
CHANGE_EVENT);
}
addChangeListener
(
callback) {
this
.on
(
Constants.
CHANGE_EVENT,
callback);
}
removeChangeListener
(
callback) {
this
.removeListener
(
Constants.
CHANGE_EVENT,
callback);
}
getState
(
) {
return
products;
}
}
const
shelfStore =
new
ShelfStore
(
);
shelfStore.
token =
Dispatcher.register
((
actionPayload) =>
{
console.log
(
'
ShelfStore
'
,
actionPayload);
const
product =
products.find
(
item =>
item.
id ===
actionPayload.
payload.
id);
if
(
actionPayload.
type ===
Constants.
FILL_STOCK) {
product.
quantity +=
1;
shelfStore.emitChange
(
);
}
else
if
(
actionPayload.
type ===
Constants.
EMPTY_STOCK) {
product.
quantity -=
1;
shelfStore.emitChange
(
);
}
}
);
export
default shelfStore;
On voit ici les différents mécanismes de gestion des événements implémentés à la main. ShelfStore hérite de EventEmmiter. La méthode d'émission de l'événement, l'ajout et la suppression des listeners du store permettent d'encapsuler les appels aux méthodes de la classe EventEmmiter.
Le callback enregistré auprès du dispatcher est ajouté sur l'instance du store créée directement avec la définition de la classe. A priori, on n'a pas besoin de plus d'une instance de ce store. C'est dans ce callback qu'on définit les actions qui seront traitées et le code associé. Ici, on traitera les deux actions permettant de gérer le stock de produits dans le rayon FILL_STOCK et EMPTY_STOCK. L'instruction console.log est ajoutée à des fins pédagogiques, pour illustrer le fonctionnement global du flux de données.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
import
Constants from
'
../constants
'
;
import
{
Store }
from
'
flux/utils
'
;
import
{
remove }
from
'
lodash
'
;
const
products =
[];
export
default class
BasketStore extends
Store {
getState
(
) {
return
products;
}
__onDispatch
(
actionPayload) {
console.log
(
'
BasketStore
'
,
actionPayload);
if
(
actionPayload.
type ===
Constants.
ADD_TO_BASKET) {
const
product =
products.find
(
item =>
item.
id ===
actionPayload.
payload.
id);
if
(
product) {
// already in basket
product.
quantity +=
1;
}
else
{
// new in basket
const
newProduct =
actionPayload.
payload;
newProduct.
quantity =
1;
products.push
(
newProduct);
}
this
.__emitChange
(
);
}
else
if
(
actionPayload.
type ===
Constants.
REMOVE_FROM_BASKET) {
const
product =
products.find
(
item =>
item.
id ===
actionPayload.
payload.
id);
if
(
product.
quantity >
1) {
product.
quantity -=
1;
}
else
{
// delete from basket
remove
(
products,
item =>
item.
id ===
product.
id);
}
this
.__emitChange
(
);
}
}
}
BasketStore, en revanche, est construit en tirant parti des flux utils et hérite de la classe Store. Comme décrit dans la documentation de flux utils, on passe en paramètre l'instance du dispatcher en paramètre du constructeur, ce qui permet de déléguer la partie technique à la classe Store. Il nous suffit simplement de fournir l'implémentation du callback enregistré auprès du Dispatcher, ici en surchargeant la méthode onDispatch et en appelant emitChange pour déclencher l'événement.
Ce store gère les actions liées au panier. On a donc le code qui traite ADD_TO_BASKET et REMOVE_FROM_BASKET.
Si besoin, les flux utils proposent d'autres implémentations de stores en fonction du besoin au travers des classes ReduceStore et MapStore.
II-C-4. Les vues▲
Je décrirai uniquement les vues en interaction directe avec les autres composants de l'architecture flux.
Commençons par les composants React qui déclenchent les actions :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
import
React from
'
react
'
;
import
Product from
'
./product
'
;
import
ItemProduct from
'
./itemProduct
'
;
import
BasketAction from
'
../actions/basketAction
'
;
import
StockAction from
'
../actions/stockAction
'
;
import
{
cloneDeep }
from
'
lodash
'
;
export
default class
Shelf extends
React.
Component {
triggerActions
(
product) {
StockAction.decreaseStock
(
product);
BasketAction.addProduct
(
product);
}
render
(
) {
const
products =
this
.
props.
products.map
(
(
product,
index) =>
<ItemProduct
key
=
{index}
onAdd
=
{this.
triggerActions.bind
(
this,
cloneDeep
(
product))}>
<Product
product
=
{product}
withStock
=
{true} />
</ItemProduct>
);
if
(!
products.
length) {
return
null
;
}
return
(
<div
className
=
"shelf col-lg-6"
>
<h2>
Product list</h2>
{
products}
</div>
);
}
}
Shelf.
propTypes =
{
products
:
React.
PropTypes.arrayOf
(
React.
PropTypes.
object
).
isRequired,
};
La fonction cloneDeep est utilisée pour ne pas simplement ajouter les références des produits dans le panier, mais bien des objets produits en entier. Si on ne fait pas le cloneDeep, toute opération effectuée sur le produit de l'action s'applique partout où il est référencé. Dans notre cas, cela joue sur les quantités et le stock.
Si ce n'est pas très clair, le mieux est de retirer l'appel à la fonction et de tester.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
import
React from
'
react
'
;
import
Product from
'
./product
'
;
import
BasketProduct from
'
./basketProduct
'
;
import
BasketAction from
'
../actions/basketAction
'
;
import
StockAction from
'
../actions/stockAction
'
;
import
{
cloneDeep }
from
'
lodash
'
;
export
default class
Basket extends
React.
Component {
triggerActions
(
product) {
BasketAction.removeProduct
(
product);
StockAction.increaseStock
(
product);
}
render
(
) {
const
products =
this
.
props.
products.map
(
(
product,
index) =>
// cloneDeep to have a separate instance of product in emitted action
<BasketProduct
key
=
{index}
onDelete
=
{this.
triggerActions.bind
(
this,
cloneDeep
(
product))}>
<Product
product
=
{product} />
</BasketProduct>
);
if
(!
products.
length) {
return
null
;
}
return
(
<div
className
=
"basket col-lg-6"
>
<h2>
My basket</h2>
{
products}
</div>
);
}
}
Basket.
propTypes =
{
products
:
React.
PropTypes.arrayOf
(
React.
PropTypes.
object
).
isRequired,
};
La gestion des actions est très simple : on ajoute l'import et on déclenche l'action sur les événements natifs du DOM (onChange, onClick, etc.). Ici, lorsqu'on cliquera sur le bouton « add », on déclenchera les actions ADD_TO_BASKET pour ajouter le produit au panier et EMPTY_STOCK pour enlever un item du stock de produits via les méthodes addProduct et addProduct. On procède exactement de la même manière dans le fichier basket.js pour les actions REMOVE_FROM_BASKET et FILL_STOCK.
Passons aux vues qui sont en interaction avec les stores. En fait, les stores gérant les states au sens flux, il est logique que les interactions se passent avec les vues possédant le state au sens React :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
import
React from
'
react
'
;
import
ShelfStore from
'
../stores/ShelfStore
'
;
import
Shelf from
'
./Shelf
'
;
export
default class
ShelfContainer extends
React.
Component {
constructor
(
props) {
super
(
props);
this
.
state =
{
products
:
ShelfStore.getState
(
),
};
}
componentDidMount
(
) {
ShelfStore.addChangeListener
(
this
.
onChange.bind
(
this
));
}
componentWillUnmount
(
) {
ShelfStore.removeChangeListener
(
this
.
onChange.bind
(
this
));
}
onChange
(
) {
this
.setState
({
products
:
ShelfStore.getState
(
),
}
);
}
render
(
) {
return
<Shelf
products
=
{this.
state.
products} />
;
}
}
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
import
React from
'
react
'
;
import
Dispatcher from
'
../dispatcher/fluxAppDispatcher
'
;
import
BasketStore from
'
../stores/basketStore
'
;
import
Basket from
'
./basket
'
;
export
default class
BasketContainer extends
React.
Component {
constructor
(
props) {
super
(
props);
this
.
store =
new
BasketStore
(
Dispatcher);
this
.
state =
{
products
:
this
.
store.getState
(
),
};
}
componentDidMount
(
) {
this
.
listener =
this
.
store.addListener
(
this
.
onChange.bind
(
this
));
}
componentWillUnmount
(
) {
this
.
listener.remove
(
);
}
onChange
(
) {
this
.setState
({
products
:
this
.
store.getState
(
),
}
);
}
render
(
) {
return
<Basket
products
=
{this.
state.
products} />
;
}
}
On retrouve, dans ces deux composants racines, la différence d'implémentation au niveau des stores, notamment au niveau des méthodes d'ajouts et suppressions en tant que listener.
On propose également ici d'avoir deux modèles : on importe l'instance du store (ShelfStore) ou bien la classe que l'on instancie dans le constructeur de la vue (BasketStore).
Ces composants React sont en fait les fameux controller-views évoqués sur le schéma du paragraphe suivant.
II-C-5. Tous ensemble▲
Voici un autre angle moins détaillé que le premier schéma pour appréhender flux :
Je ne paraphraserai pas la description faite précédemment. J'insisterai juste sur le fait qu'il n'y a qu'une seule instance du dispatcher dans cette architecture et que les stores sont notifiés de toutes les actions. Le code de cet article inclut des logs pour mettre en exergue ce point. Par ailleurs, j'invite le lecteur à manipuler un PoC disponible sur le github prévu à cet effet.
III. Aller plus loin avec Redux▲
Maintenant que nous avons vu un exemple de mise en œuvre de flux, on peut dire que cela peut effectivement faciliter le contrôle du flux de données d'une application complexe. En revanche, on peut voir quelques inconvénients.
Tout d'abord, il n'y a qu'un seul dispatcher dans toute l'application, ce qui peut être problématique si jamais l'application devait gérer des centaines ou des milliers de stores et d'actions. Cela serait surtout pour des problématiques de performance plus que de code. On a vu qu'ici il tient en deux lignes.
On peut voir aussi que les callbacks fournis par les stores doivent traiter une liste d'actions au sein d'une seule fonction. Attention à ne pas tomber dans le travers d'écrire des fonctions de plusieurs centaines de lignes.
Si les actions sont interdépendantes les unes des autres (ce qui est prévu dans l'implémentation du dispatcher), on peut vite avoir du mal à s'y retrouver si elles sont nombreuses.
D'une manière générale, j'attire l'attention du lecteur sur la mise en œuvre de Flux pour des applications qui possèdent de très nombreux composants. Cela peut très bien fonctionner (la preuve, Facebook l'utilise), mais une grande rigueur s'impose.
Suite à la parution de Flux, un autre pattern dérivé a fait son apparition et semble remporter tous les suffrages en termes de popularité : Redux.
Nous n'allons pas le présenter en détail ici, mais étant le résultat d'une réflexion à partir de différentes solutions, dont Flux, il me semble intéressant de l'évoquer.
Globalement, l'idée est de fiabiliser le contrôle de l'évolution des states d'une application front, notamment les single page applications. Redux repose sur trois principes :
- Single source of truth : le state est représenté sous forme de grappes d'objets ;
- On ne modifie JAMAIS un state : on créera toujours une copie de l'objet à modifier ;
- Les données sont traitées à l'aide de fonctions pures.
Par rapport à flux, le dispatcher disparaît et il n'y a plus qu'un seul store en interaction directe avec les reducers. Ce sont eux (les reducers) les fonctions pures du 3e principe. Ces principes sont décrits dans le détail dans la documentation officielle. Il y a également un grand nombre de ressources pour appréhender Redux.
Pour finir, et surtout si tout ça reste encore flou pour vous, je propose d'aller jeter un œil à ce site Internet qui propose une explication de Flux, Redux et autres principes du même écosystème : Code cartoon a été présenté lors de la React.js Conf de ce début d'année. Il propose une explication imagée à base de cartoon pour appréhender les grands principes de ces outils.