Comprendre le data binding dans Angular, React et Vue

Comparaison du mode de fonctionnement de frameworks MVVM populaires

Les frameworks MVVM sont aujourd'hui une partie centrale du développement front-end d'applications web. Ils occupent souvent l'actualité JavaScript et sont source de grands débats parmi les professionnels.

Derrière cette appellation, on retrouve un principe de base reliant le Modèle (M) à la Vue (V) : le data binding. Pourtant ce mécanisme est souvent mal connu ou mal compris par les utilisateurs de ces frameworks, perdus dans le jargon technique et marketing.

Nous allons tâcher de décrire comment ce mécanisme de data binding est implémenté au sein de trois frameworks populaires : Angular, React et Vue.

11 commentaires Donner une note à l'article (5)

Article lu   fois.

L'auteur

Site personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Principe de base et vocabulaire

Par application web, on désigne un site web proposant davantage que de la simple consultation de contenu : une interaction riche avec l'utilisateur, une notion de service, une expérience proche de celle d'une application native. Le développement de ce genre d'applications a évolué avec toujours plus de logique côté client, ce qui a amené à réfléchir à l'architecture du code JavaScript et à transposer côté client des patterns déjà éprouvés côté serveur, tels que l'architecture multitier ou le patron de conception MVC.

Dans les applications web modernes, les parties client et serveur sont totalement découplées et les échanges sont réduits au minimum en étant constitués presque exclusivement de données sérialisées. Le format le plus populaire actuellement est une API REST exposant les données au format JSON. Une fois récupérées en JavaScript, on retrouve une représentation partielle de ces données métier côté client : c'est la couche Modèle (M).

Ces données sont ensuite manipulées et formatées pour venir s'inscrire dans le HTML de la page et former l'interface utilisateur, ce que l'on appelle communément la couche Vue (V).

Ce qui vient relier le Modèle et la Vue, c'est ce qui nous intéresse ici. Dans les architectures MVC (C pour Contrôleur), les actions de la couche Vue sur la couche Modèle sont entièrement régies et formalisées par des contrôleurs. Les contrôleurs sont des objets qui décrivent précisément la manière dont le modèle est mis à jour suite à un événement dans la vue. Ils peuvent aussi notifier les vues qu'un changement a eu lieu dans un modèle afin de déclencher leur mise à jour. Ainsi, la couche Modèle ne comporte aucun lien direct vers des éléments de la couche Vue. Tout est bien séparé.

Cette architecture très populaire a donné naissance à plusieurs déclinaisons avec le temps, dont la MVVM (Model - View - ViewModel) popularisée par Microsoft et sa technologie Windows Presentation Foundation (WPF). On retrouve le principe de base du MVC, mais l'appellation « Contrôleur » disparaît pour mettre en avant le rapprochement des couches Vue et Modèle, qui gagnent en proximité tout en restant découplées. Ces ViewModel, ou modèles de vue, correspondent aux données consommées par les vues et dédiées à la logique de présentation. Ils sont fortement liés aux modèles métier, si bien qu'ils en sont souvent une transposition directe, à la structure presque identique. Il n'y a donc plus de couche Contrôleur explicite aux yeux du développeur, mais un mécanisme de synchronisation Modèle - ViewModel se voulant le plus transparent et autonome possible. Cela se traduit par un gain de productivité, surtout pour les travaux de prototypage rapide. Mais cela vient également complexifier la couche Vue en introduisant plus de logique et plus de directives spécifiques dans les templates HTML, voire des extensions aux langages comme c'est le cas de React avec JSX.

Dans une architecture MVVM, les vues sont en théorie automatiquement mises à jour lorsque les modèles dont elles dépendent sont modifiés. Cela implique plusieurs mécanismes à étudier séparément : la détection de changements (change detection), la résolution des liaisons associées (change resolution), et la mise à jour du DOM (DOM updating). Ce sont ces trois composantes essentielles qui forment ensemble ce qui est communément appelé data binding. Nous verrons que ces mécanismes sont implémentés de manière différente selon les frameworks.

À la sortie d'AngularJS en 2009, Google a fortement marketé sur le terme « 2-way data binding » ou « liaison de données à double-sens ». La démonstration la plus courante de ce mécanisme est une zone de saisie de texte suivie d'un paragraphe avec ce même texte saisi. Lorsque l'utilisateur vient modifier la zone de texte, le paragraphe est automatiquement mis à jour à chaque frappe. On illustre bien les deux sens de communication vue-modèle, d'une part en récupérant la modification sur la zone de texte suite à l'événement input, et d'autre part en mettant à jour dans la vue le paragraphe dont le contenu est associé au modèle correspondant.

Toutefois, ce terme est un abus de langage, car le mode de communication d'un sens à l'autre n'est pas du tout le même. Dans le sens modèle vers vue, les éléments de la vue sont manipulés via les API du DOM ou via une modification directe du HTML, selon le framework et le type de changement dans le modèle. Tandis que dans le sens vue vers modèle, ce sont des événements spécifiques, générés par le navigateur suite aux actions de l'utilisateur, qui sont capturés par des écouteurs préalablement initialisés pour les besoins du data binding. On ne passe donc pas par le même canal, mais comme la logique derrière ces échanges est de plus en plus automatisée et dissimulée, le développeur finit par ne plus faire la différence.

Le 2-way data binding pose plusieurs problématiques, dont la principale est celle de provoquer des boucles infinies dans certains cas. Un match de ping-pong peut survenir lorsqu'une mise à jour du modèle entraîne une mise à jour de la vue qui elle-même entraîne une mise à jour du modèle, qui elle-même entraîne une mise à jour de la vue, etc.

Image non disponible

Un exemple simple pour illustrer ce cas est celui d'un convertisseur d'unités Celsius/Fahrenheit, avec deux zones de saisie dont la valeur de l'une dépend de celle de l'autre. On souhaite que tout changement de valeur d'un des champs relance le calcul et mette à jour la valeur de l'autre champ. Si l'on modifie la valeur du champ Celsius, un évènement « change » est déclenché ce qui met à jour les liaisons dépendant de cette valeur. La valeur du champ Fahrenheit est liée à cette valeur par le facteur de conversion, elle est donc mise à jour. Mais cette mise à jour entraîne logiquement un autre événement « change » sur la zone Fahrenheit cette fois, et on répète le même processus dans l'autre sens. Nikita Vasilyev détaille ce problème et compare les implémentations et les résultats obtenus dans cet article (en anglais).

Ce match de ping-pong peut se résoudre de diverses façons, les frameworks s'en sortant généralement bien pour éviter la boucle infinie et résoudre le problème en un certain nombre de passes. Dans tous les cas, cela peut impacter significativement la réactivité de l'application. C'est une des raisons pour laquelle le 2-way data binding a rapidement acquis une réputation de « performance killer ». Cependant, comme on le verra dans la prochaine section, c'est aussi surtout à cause de son implémentation dans Angular 1.x.

On a ensuite fait évoluer à nouveau le vocabulaire en faisant la promotion du « 1-way data binding », qui réduit le data binding à un seul sens, généralement les mises à jour du modèle vers la vue. Cette application à sens unique empêche tout risque de boucle infinie, puisqu'elle offre la garantie que l'état de la vue est déterminé par celui du modèle en un seul cycle. On compte alors sur le développeur pour gérer lui-même les interactions utilisateur et les événements survenant dans la page. Pour faire les choses proprement, celui-ci aura tendance à externaliser ses méthodes d'interaction, et après quelques semaines, il se rendra compte qu'il vient de réinventer la couche Contrôleur. Deux pas en avant, un pas en arrière.

Il se peut que vous entendiez également parler de « 3-way data binding ». Cela désigne une extension de cette liaison vue-modèle pour inclure également le modèle persisté côté serveur, et donc les mécanismes de synchronisation de modèle qui en découlent entre client et serveur. L'abus de langage est encore plus grossier ici puisqu'on identifiera non pas trois, mais quatre liaisons différentes. Aussi, la synchronisation de modèle client-serveur est un procédé asynchrone et restrictif : cela signifie qu'il s'accompagne de règles de résolution de conflits, toute l'autorité étant confiée au serveur. On trouve également très souvent dans ces solutions divers procédés d'optimisation réseau comme le debouncing, le data-diffing ou la compression. C'est pourquoi l'idée d'une liaison instantanée et infaillible entre la vue et la base de données, telle qu'elle est vendue par les services tiers utilisant ce terme, est donc très éloignée de la réalité.

II. AngularJS 1.x

Image non disponible

En bref :

  • détection de changements : digest loop et dirty checking ;
  • résolution des liaisons : watchers et dependency tracking à la liaison du template ;
  • mise à jour du DOM : après digestion, via des directives spécialisées.

La version 1.x du framework de Google met en avant le 2-way data binding, et introduit divers concepts spécifiques à Angular. Tout d'abord, les données concernées par le data binding sont placées dans des objets conteneurs particuliers nommés scopes. Les expressions des templates Angular sont exécutées dans le contexte de ces scopes. Les parties dynamiques des templates, comme les boucles ou les conditions, peuvent créer des scopes enfants qui héritent des propriétés de leurs parents, un peu à la manière des scopes en JavaScript.

La détection de changements sur Angular 1.x repose sur le fait que toutes les modifications du modèle soient réalisées dans un contexte d'exécution contrôlé par Angular. C'est pour ça que beaucoup de choses doivent être réalisées « à la façon Angular » pour que le data binding fonctionne bien. On remplace ainsi onclick par ng-click et setTimeout par $timeout : ces attributs et services spécifiques à Angular sont des emballages (on parle de wrappers) autour de fonctionnalités de base en JavaScript.

Le principal intérêt de ces wrappers est d'indiquer à Angular qu'à la fin de l'exécution du code, le scope associé doit rentrer dans une phase de digestion (le digest cycle). Cette phase de digestion consiste à vérifier s'il y a eu des modifications dans le modèle, et quelles sont les expressions associées à mettre à jour. Puisque cette phase intervient une fois toutes les modifications du modèle effectuées, Angular n'a pas d'autre choix que de vérifier une à une les expressions pour voir si quelque chose a changé : c'est ce qu'on nomme le dirty checking.

Ce nom n'est pas très flatteur et pour cause, il travaille beaucoup pour souvent ne rien trouver. Angular lance régulièrement ce mécanisme, notamment lorsqu'il perçoit des évènements du navigateur. Un évènement peut être une interaction utilisateur, une réponse à une requête AJAX, un timer, etc. Malgré ces précautions, il arrive souvent que le développeur ait à indiquer manuellement à Angular de lancer une phase de digestion après une opération asynchrone, avec $scope.$apply :

 
Sélectionnez
socket.on("message", function(data){
  $scope.$apply(function () {
    $scope.messages.push(data.message);
   })
})

Le dirty checking implique également que seul l'état final du modèle est pris en compte, et les modifications intermédiaires qui ont pu avoir lieu ne sont donc pas prises en compte. Donc dans l'exemple suivant, le texte du bouton ne sera jamais mis à jour, même une fraction de seconde, pour afficher les messages bar/baz/qux.

 
Sélectionnez
$scope.message = "foo";
$scope.changerMessage = function(){
  $scope.message = "bar";
  $scope.message = "baz";
  $scope.message = "qux";
  $scope.message = "foo";
}
 
Sélectionnez
<button ng-click="changerMessage()">{{message}}</button>

Cela permet d'optimiser la phase de mise à jour du DOM, mais peut surprendre le développeur. C'est pourquoi on conseille généralement de regrouper au même endroit les modifications du modèle, et d'éviter de requêter/manipuler le DOM avant une phase de digestion.

Si le dirty checking a détecté des changements, il est exécuté une seconde fois au cas où un watcher viendrait changer à nouveau le modèle suite à ces changements (comme dans l'exemple ping-pong). Le dirty checking est au minimum exécuté deux fois pour essayer d'assurer une certaine stabilité des modèles. L'implémentation de ce mécanisme dans Angular est donc particulièrement coûteuse en performances, d'une part à cause du principe même du dirty checking, et d'autre part car il est impossible de prédire le nombre de passes requises lors du cycle de digestion.

Concernant la résolution des liaisons, Angular s'occupe lors de la lecture du template d'inscrire des watchers sur les propriétés du scope. Par exemple, quand vous écrivez {{message}} dans un template, Angular va inscrire un watcher sur la propriété message pour mettre à jour ce nœud texte lorsque cette propriété change. On peut également ajouter manuellement ces watchers avec $watch. Le fonctionnement de ces watchers est très proche d'un publisher/suscriber. Enfin, la mise à jour du DOM se fait classiquement selon le type de directive utilisé dans le template (nœud texte, modification des attributs, modification de la liste de classes, etc.).

Avec Angular 2, Angular s'améliore nettement en matière de détection de changements, notamment en catégorisant les propriétés du modèle et leurs liaisons. En ayant connaissance des propriétés immutables et observables, le cycle de digestion devient plus « intelligent » et se trouve largement optimisé. Vous trouverez plus d'explications dans cet article (en anglais) de Viktor Savkin.

III. React

Image non disponible

En bref :

  • détection de changements : API de changement d'état ;
  • résolution des liaisons : rendu complet d'un DOM virtuel ;
  • mise à jour du DOM : virtual DOM diffing.

La stratégie de data binding de React est très différente, voire opposée à celle d'Angular. Angular concentre ses efforts sur la détection de changements et la résolution précise des liaisons impactées dans le but de simplifier l'étape de mise à jour du DOM. Tandis que React opte pour réinterpréter tous les templates et toute l'UI à chaque fois qu'un évènement survient. Mais il dispose d'un atout de taille pour optimiser ce processus : le DOM virtuel (virtual DOM), une représentation abstraite et légère du DOM en purs objets JavaScript bien plus rapides à manipuler que les éléments du DOM eux-mêmes. Une seconde phase, le diffing, consiste à comparer la nouvelle version du DOM virtuel avec le véritable DOM, afin d'en calculer un différentiel. C'est ce différentiel qui sera finalement appliqué pour mettre à jour le DOM, avec le minimum d'opérations requises et donc le maximum de performances.

Ainsi le développeur n'a plus besoin de se préoccuper de l'état de son DOM puisque toute l'UI est redessinée à chaque fois. On retrouve en quelque sorte la facilité dont on disposait à l'époque du rendu du HTML côté serveur : la page et le contexte JavaScript étaient alors systématiquement réinitialisés à chaque action.

React ne s'occupe pas trop de la couche Modèle, mais l'équipe de Facebook conseille de l'associer avec Flux, qui est plus un choix d'architecture qu'un framework. Sans entrer dans les détails, car ce n'est pas l'objet de cet article, le principe est d'établir un flux unidirectionnel pour la propagation des actions depuis la vue jusqu'aux modifications sur les modèles (appelés stores pour Flux). Il n'y a donc aucun risque de boucle infinie puisque toutes les actions passent par un unique dispatcher, permettant un contrôle total sur l'état applicatif. Ce choix d'architecture s'associe donc très bien avec React puisque le rendu différentiel du DOM est la dernière étape requise pour mettre à jour l'interface utilisateur une fois ce nouvel état applicatif établi.

Le DOM virtuel est ainsi un moyen très simpliste, voire naïf, de mettre à jour efficacement le DOM à partir du moment où l'on sait que le modèle a été modifié, peu importe comment. Il faut toutefois indiquer à React que le modèle a changé, et il n'y a pas d'intelligence particulière pour cette détection de changements. C'est au développeur de l'indiquer explicitement en appelant des méthodes telles que setState, ou en utilisant une architecture de type Flux qui conditionne là aussi la manière dont les modèles sont modifiés.

IV. Vue

Image non disponible

En bref :

  • détection de changements : getter/setters ES5 ;
  • résolution des liaisons : watchers et dependency tracking à la liaison du template ;
  • mise à jour du DOM : au prochain tick, via des directives spécialisées.

Vue.js s'est fait connaître pour sa simplicité et son approche composants qui le rapproche de React en matière d'expérience de développement. Mais sur le plan du data binding, ses mécaniques ressemblent davantage à AngularJS. Il dispose tout comme Angular d'une syntaxe spécifique dans les templates pour différencier les types de liaison (interpolation de texte, attributs, conditions, boucles…), et des watchers sont initialisés lors de la lecture du template. Les expressions du template sont alors exécutées une première fois dans un mode « tracking de dépendances », qui recense toutes les propriétés qui ont été touchées (grâce aux getters ES5) et permet de construire un arbre de dépendances.

La mise à jour du DOM se fait classiquement d'une manière similaire à Angular : les watchers déclenchent la mise à jour des liaisons dépendant du modèle modifié, et ces liaisons sont conçues pour modifier le DOM de la manière la plus efficace possible selon leur fonction. Une petite nuance pour Vue est que le framework attend la fin de la boucle d'exécution courante pour lancer cette mise à jour du DOM ; l'équivalent d'un setTimeout(render, 0) en somme.

C'est sur la détection de changements que Vue se distingue des autres frameworks. Alors qu'Angular utilise des wrappers et du dirty checking, et que React ou Backbone ont recours à des API spécifiques pour modifier les modèles, Vue utilise quant à lui les getters/setters. Cette fonctionnalité assez peu connue des développeurs JavaScript est arrivée avec la norme EcmaScript 5, qui est aujourd'hui très bien supportée sur la majorité des navigateurs (si on oublie IE<9).

Pour rappel, les getters/setters permettent d'intercepter et d'exécuter du code quand une propriété d'un objet est accédée en lecture ou en écriture :

 
Sélectionnez
var object = (function(){
  var hiddenProperty;
  return {
    get property(){
      console.log("accès en lecture à la propriété")
      return hiddenProperty
    },

    set property(value){
      console.log("accès en écriture à la propriété")
      hiddenProperty = value
    }
  }
})();
 
Sélectionnez
> object.property = 42;
accès en écriture à la propriété
42
> ++object.property
accès en lecture à la propriété
accès en écriture à la propriété
43

Lorsque vous instanciez un objet Vue, vous devez passer en argument un objet contenant toutes les données qui seront utilisées par cette vue. Vue va ensuite produire une version « proxy » de cet objet, qui présente exactement la même structure mais dont les propriétés ont été redéfinies avec Object.defineProperty et des getters et setters qui permettent à Vue de savoir quand une propriété est lue ou modifiée. C'est cet objet proxy qui constituera donc le modèle de vue (qui n'a jamais aussi bien porté son nom).

L'avantage est que ce modèle « proxyfié » s'utilise exactement de la même façon que le modèle original que vous avez passé au constructeur. Vous n'avez rien à faire de particulier pour que la détection de changements fonctionne : tout se met à jour tout seul. La documentation parle de modèle « réactif ». Pour le développeur, c'est donc beaucoup plus simple comparé au principe de wrapper et de contexte d'exécution d'AngularJS. Vue est le seul ici à savoir immédiatement et précisément ce qui a changé dans vos modèles, contrairement à Angular qui doit vérifier tous les modèles et React qui doit réinterpréter tous les templates.

Cependant, comme rien n'est parfait, ce mécanisme présente quelques limitations propres aux getters/setters ES5. Il faut en effet que toutes les propriétés à surveiller soient initialement présentes dans le modèle lors de l'initialisation de la vue ; par défaut, ces modèles ne peuvent donc pas comporter de propriétés dynamiques qui viennent s'ajouter ou se retirer pendant l'exécution. Certaines opérations peuvent aussi ne pas être détectées, comme lorsque l'on vient agrandir la taille d'un Array en manipulant directement les index. Le framework compense ces lacunes en proposant une méthode $set pour venir effectuer ces modifications tout en notifiant le ViewModel.

Les mécaniques de Vue sont donc moins ambitieuses et révolutionnaires qu'un contexte d'exécution d'Angular ou un virtual DOM de React, mais fonctionnent tout aussi bien. Le framework se contente d'utiliser les parties de JavaScript qui semblent les plus adéquates pour les différents besoins : setters pour la détection de changements et getters pour le tracking de dépendances.

V. Conclusion

La tendance générale est à la simplification et à l'automatisation des échanges entre vue et modèle. C'est précisément l'objectif du data binding, un mécanisme implémenté de diverses manières selon les frameworks. Pour y parvenir, on peut mettre en place des solutions très audacieuses. Le DOM virtuel, par exemple, est une idée que l'on aurait pu techniquement mettre en place il y a des années, mais qui ne s'est démocratisée que tout récemment. Il faut dire que ce genre d'idées amène son lot de bouleversements et brise beaucoup de conventions, comme les choix de séparation des couches et des langages.

Le langage JavaScript évolue également pour tenter d'offrir nativement des mécaniques utiles au data binding. Mais le paysage évolue rapidement. Alors qu'en 2014, on nous promettait monts et merveilles avec Object.observe(), ce brouillon de spécification a finalement été abandonné par son auteur quand il s'est rendu compte qu'il ne collait plus au fonctionnement des nouveaux frameworks MVVM. Il reste cependant l'API Proxy apparue avec la norme ES6 et qui est supportée depuis peu par le trio Chrome/Firefox/Edge sur desktop. Proxy est un bon candidat pour révolutionner une fois de plus les mécaniques de data binding. Les proxies permettent en effet de pallier les lacunes des getters/setters ES5 tout en simplifiant le code côté framework. Une fois qu'ils seront plus largement supportés, on verra sans doute des solutions de data binding minimalistes avec les mêmes capacités que nos mastodontes actuels.

Autour des mécaniques de data binding pur, d'autres sujets sont venus s'ajouter aux débats. L'approche composants et les Web Components ont changé la façon dont on modularisait traditionnellement notre code front-end. Alors qu'il était préconisé il y a quelque temps de séparer nos fichiers selon leur fonction (templates, services, modèles, contrôleurs), on observe aujourd'hui une tendance à privilégier une séparation par fonctionnalité et par blocs identifiables de l'interface utilisateur. Chaque vue a maintenant son modèle de vue, et on regroupe sans complexe templates et logique de présentation dans des éléments qu'on pensait enterrés et oubliés : c'est le retour des <script> et <style> inline ! Les template strings d'ES6 permettent également de stocker les templates HTML sous forme de strings JavaScript, mettant fin à l'époque des <script type="text/template">. Cette décomposition reflète davantage le fonctionnel de l'application et moins les engrenages qui se cachent derrière.

Image non disponible
Exemple de fichier composant Vue

D'autres thématiques comme la programmation réactive fonctionnelle ou l'immutabilité des modèles ont surfé sur la vague React pour revenir sur le devant de la scène JS. Tout ceci n'est pas encore bien démocratisé, mais leur introduction dans des frameworks populaires (comme Angular 2 avec RxJS) devrait peut-être changer la donne. Avec ces nouveaux paradigmes, on cherche à centraliser l'état applicatif (voir cet excellent talk de Andre Medeiros pour en savoir plus). Ce serait alors un gros coup de pouce aux solutions de DOM virtuel et incrémental , qui excellent dans les mises à jour totales et agnostiques du DOM.

En conclusion, derrière le terme relativement simple de « data binding » se cache une multitude de concepts et de mécaniques qui n'ont pas cessé d'évoluer depuis le début de ce siècle. Ces évolutions sont parfois radicalement différentes entre elles et arrivent pourtant à coexister sur la scène JavaScript. On mélange une fois de plus les paradigmes, ce qui fait de ce langage un cas très particulier dans le monde de la programmation, avec une communauté très diversifiée dans ses compétences et appétences.

VI. Remerciements

Je tiens à remercier mon ami Quentin Focheux pour la relecture technique de cet article, ainsi que Damien Genthial et f-leb pour leur relecture orthographique de qualité.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Copyright © 2016 Sylvain Pollet-Villard. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.