J’ai découvert récemment l’architecture prônée par Oracle pour le développement d’applications web et hybrides mobiles. Cette architecture s’appelle JET [1]. Les concepteurs de JET ont fait le pari d’utiliser un framework auquel je m’étais peu intéressé jusqu’ici, j’ai nommé Knockout (appelé aussi quelquefois Knockout.js). Pour la suite de l’article, je l’appellerai tout simplement KO.
Je m’étais un peu intéressé à KO, il y a quelques années, à l’époque où je me formais à AngularJS. J’avais à l’époque considéré qu’AngularJS et KO étaient relativement proches, mais je m’étais surtout concentré sur AngularJS parce que ce framework avait beaucoup de succès et offrait plus d’opportunités professionnelles que KO. C’était avant que Google n’annonce l’arrêt du projet AngularJS (en 2014) et le lancement du projet Angular, qui comme chacun sait, n’a rien à avoir avec son prédécesseur.
KO est un projet free, open source, sous licence MIT. Il n’est pas édité par un des acteurs du GAFA, mais par un développeur Microsoft, Steve Sanderson, qui travaille au développement du .NET Core. Attention, KO n’est pas un projet Microsoft, c’est bien un projet indépendant. Je dois souligner que, si le framework évolue au fil du temps, il n’y a pas dans son historique de rupture brutale entre certaines versions. Steve Sanderson et son équipe semblent respectueux des développeurs qui utilisent KO.
KO est plus complet que Stimulus, mais il conserve une courbe d’apprentissage très raisonnable. Il faudrait néanmoins plus que cet article pour faire le tour de toutes ses possibilités, aussi, après une brève présentation des bases de KO, je me focaliserai sur certaines fonctionnalités que je trouve particulièrement attractives dans ce framework.
À noter que le source de KO ne pèse que 60 Kio, soit un peu plus que les 27 Kio de Stimulus, mais cela reste un poids plume. Et vous allez voir que, en termes de fonctionnalités, ce framework a du répondant.
1. Découverte de Knockout
1.1 Principes généraux
KO est suffisamment polyvalent pour pouvoir être utilisé sur des applications web classiques, et sur des applications web de type SPA (Single Page Application).
Comme un certain nombre d’autres solutions SPA, KO fonctionne sur la base du pattern MVVM (Modèle Vue Vue-Modèle), qui lui-même est dérivé du pattern MVC (Modèle Vue Contrôleur).
Le pattern MVVM a été conçu par des architectes Microsoft, et appliqué en premier lieu sur des projets .NET (WPF et Silverlight). Ce devait être aux alentours de 2005, avant que le concept ne soit repris sur différents frameworks JavaScript vers 2009-2010 (notamment sur les frameworks AngularJS, Knockout et quelques autres...).
Pour découvrir comment KO implémente MVVM, commençons par étudier un exemple très simple.
Dans ce premier exemple, nous allons créer un formulaire avec deux champs de saisie et appliquer à ces champs de saisie des transformations basiques, telles que :
- mise en majuscule du premier caractère du prénom saisi (et tout le reste en minuscules) ;
- mise en majuscule de l’intégralité du nom saisi ;
- concaténation du contenu des prénom et nom transformés, et affichage dans une balise texte spécifique.
C’est un exemple très simple, mais qui va nous permettre d’introduire plusieurs notions importantes de KO. Sur le plan visuel, ça va donner un équivalent de la figure 1 (rien de transcendant).
Fig. 1 : Premier exemple de formulaire avec KO.
Avant de démarrer, téléchargez la dernière version du framework (qui est la 3.4.2 au moment où j’écris ces lignes). Pour démarrer rapidement cette initiation à KO, je vous propose d’utiliser le squelette de page HTML suivant :
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Knockout.js - Test xx</title>
</head>
<body>
<div>
<!-- cette div contiendra le code HTML relatif à chaque test -->
</div>
<!-- Placer le chargement de KO vers la fin du "body" -->
<script src="knockout-min.js"></script>
<!-- Placer le code JS spécifique dans le fichier app.js -->
<script src="app.js"></script>
</body>
</html>
Pour ce premier test, placez le code HTML qui suit à l’intérieur de la balise div :
<form data-bind="submit: capitalizeNames">
<p><label>Prénom : <input data-bind="value: firstName" /></label></p>
<p><label>Nom: <input data-bind="value: lastName" /></label></p>
<p>Nom complet: <strong data-bind="text: fullName" ></strong></p>
<button type="submit">Valider</button>
</form>
Vous remarquez sans doute que plusieurs éléments de ce formulaire contiennent un attribut data-bind. Chaque fois que nous faisons appel à ce mécanisme, nous utilisons le pattern suivant :
data-bind="type: data"
Sur la balise form, nous avons un attribut data-bind qui est lié à un événement de type submit. Le déclenchement de cet événement (par un clic sur le bouton de validation du formulaire) va avoir pour effet de lancer la fonction JS capitalizeNames que nous étudierons dans un instant.
Les balises input ont un attribut data-bind de type value, vous devinez donc que nous allons être en mesure de manipuler la valeur de ces champs de saisie au niveau du modèle JavaScript.
La balise strong a un attribut data-bind de type text, cela signifie que nous allons être en mesure de manipuler le contenu textuel de cette balise, également au niveau du modèle JavaScript.
Voici le squelette du code source que vous allez placer dans le fichier app.js :
(function () {
'use strict';
// Exemple de "VueModèle" simple définissant le comportement de
// l'UI (User Interface)
function AppViewModel() {
var self = this;
//=> emplacement du code que nous ajouterons dans un instant
}
// Démarrage du data binding dans knockout.js
ko.applyBindings(new AppViewModel());
}());
Je ne voulais pas vous donner tout de suite le code complet, je préfère procéder par étapes.
Vous voyez que nous avons créé une fonction AppViewModel. Le nom de cette fonction importe peu, vous pouvez l’appeler comme vous voulez, du moment que vous utilisez le même nom lors de l’appel de la fonction ko.applyBindings. C’est cette fonction qui démarre le mécanisme de data binding propre au pattern MVVM. Vous remarquez aussi que la fonction AppViewModel est en réalité une classe, puisque nous l’instancions au moment de l’invoquer via la fonction ko.applyBindings.
À l’intérieur de la classe AppViewModel, vous remarquerez que la première ligne consiste à affecter l’objet this à la variable self. C’est une précaution que je vous recommande d’appliquer systématiquement, car elle nous permettra d’éviter certaines erreurs courantes relatives aux problématiques de « scope » avec l’objet this. À chaque fois que nous ferons appel à l’objet self, nous saurons que nous faisons référence à l’instance principale de notre objet AppViewModel, sans risque de collision avec l’objet this d’un sous-objet quelconque.
À l’intérieur de notre classe AppViewModel, de préférence au tout début de son code, nous allons déclarer les éléments HTML qui sont observables, du point de vue de KO. Sachant que nous souhaitons observer, par le mécanisme du « data-binding », les balises input relatives aux champs « nom » et « prénom », nous allons écrire ceci :
var self = this;
// Data Binding dans les 2 sens (en lecture et mise à jour)
self.firstName = ko.observable("kid");
self.lastName = ko.observable("paddle");
Pour rigoler, j’ai envie que les champs « nom » et « prénom » contiennent respectivement les valeurs « kid » et « paddle », donc je passe ces infos à la fonction ko.observable au moment de l’initialisation. Mais j’aurais pu ne rien mettre, cela aurait pour effet d’initialiser ces données à blanc.
Le plus important, c’est que nous venons de créer 2 objets observables, au sens KO du terme, c’est-à-dire 2 objets faisant référence à des nœuds du DOM, via un mécanisme de type « data binding bidirectionnel ». Cela signifie que nous pouvons manipuler ces éléments à la fois en lecture et en écriture.
Grâce à ce mécanisme, si nous souhaitons ultérieurement récupérer le contenu du champ de saisie firstname, nous écrirons ceci :
var test = self.firstName();
Et si nous souhaitons modifier dynamiquement le contenu de ce même champ de saisie, nous écrirons cela :
self.firstName("titi");
Voilà, ce n’est pas plus compliqué que ça, pas besoin de se préoccuper du DOM, pas besoin non plus de préciser quel attribut du DOM est modifié, puisque je rappelle que nous l’avons défini au niveau de la vue (donc du HTML) via le code suivant :
<input data-bind="value: firstName" />
Dans le code HTML, nous avons prévu une balise strong liée par data-binding à une donnée qui ne provient pas du formulaire et qui s’appelle fullName. Vous devinez qu’il s’agit d’une donnée calculée, ou en l’occurrence concaténée, contenant les derniers champs « nom » et « prénom » saisis. Pour rappel, le code HTML correspondant est le suivant :
<strong data-bind="text: fullName" ></strong>
Voici le code JS correspondant à la génération de cette donnée fullName :
// Data binding à sens unique (en lecture seule)
self.fullName = ko.computed(function () {
return self.firstName() + " " + self.lastName();
});
Quelle est la nature de l’objet fullName généré par la fonction ko.computed ? C’est du data binding monodirectionnel, c’est-à-dire une donnée en lecture seule.
Vous verrez quelquefois des exemples dans lesquels un second paramètre est transmis à la fonction ko.computed, comme dans l’exemple suivant :
// Data binding à sens unique (en lecture seule)
self.fullName = ko.computed(function () {
return self.firstName() + " " + self.lastName();
}, this);
En fait, ce second paramètre this est superflu si vous avez pris la précaution de déclarer une variable self (référençant this) au début de la classe principale « Vue-Modèle ».
Dans le code HTML, nous avions prévu également une fonction JS appelée capitalizeNames ; nous ne l’avons pas encore codée, il est temps de le faire. Je vous donne son code ci-dessous, avec l’intégralité du code de la classe AppViewModel, pour vous aider à vous y retrouver si vous avez quelques doutes :
function AppViewModel() {
var self = this;
// Data Binding dans les 2 sens (en lecture et mise à jour)
self.firstName = ko.observable("kid");
self.lastName = ko.observable("paddle");
// Data binding à sens unique (en lecture seule)
self.fullName = ko.computed(function () {
return self.firstName() + " " + self.lastName();
});
// Fonction associée à un événement (en l'occurrence le "submit"
// du formulaire)
self.capitalizeNames = function() {
var firstName = self.firstName();
firstName = firstName.trim();
if (firstName.length > 0) {
var tmp_name = firstName;
firstName = tmp_name[0].toUpperCase();
if (tmp_name.length > 1) {
firstName += tmp_name.substring(1).toLowerCase();
}
}
self.firstName(firstName);
var lastName = self.lastName();
self.lastName(lastName.trim().toUpperCase());
};
}
1.2 Gestion de liste simplifiée
Je vous propose maintenant la modification suivante : au fur et à mesure que nous validons des noms et des prénoms dans notre formulaire, je souhaite alimenter un tableau JavaScript, et surtout je souhaite afficher dynamiquement le contenu de ce tableau dans une simple liste HTML (balise ul) placée sous le formulaire. Vous allez voir, ça va être vite réglé.
Commençons cette fois-ci par le code JS. Juste sous la déclaration des 2 premiers « observables », ajoutez la ligne en gras suivante :
// Data Binding dans les 2 sens (en lecture et mise à jour)
self.firstName = ko.observable("kid");
self.lastName = ko.observable("paddle");
self.names = ko.observableArray();
Nous venons de créer un objet self.names déclaré via la fonction ko.observableArray. Cet objet se manipule comme un simple tableau JavaScript, et toute modification de ce simili tableau sera automatiquement exploitée par KO pour rafraîchir la liste correspondante côté HTML. Au fait, elle ressemble à quoi cette liste ? Eh bien, voici le code HTML que je vous invite à ajouter sous votre formulaire :
<ul data-bind="foreach: names">
<li data-bind="text: $data"></li>
</ul>
Nous n’avions pas encore vu cette structure foreach pointant sur notre tableau names. La variable $data utilisée au niveau de la balise li est fournie par KO, elle nous permet d’adresser chacun des éléments du tableau names, et comme il s’agit de simples posts contenant du texte, nous n’avons rien d’autre à préciser.
Nous ne devons pas oublier de modifier notre fonction capitalizeNames de manière à stocker chaque nouvelle entrée dans le tableau. Pour ce faire, je vous propose d’ajouter la ligne ci-dessous à la fin de cette fonction :
self.names.push(self.firstName() + ' ' + self.lastName());
Bon, il y aurait à redire concernant la qualité de cette fonction capitalizeNames, qui fait désormais du formatage et du stockage de données. Il conviendrait de refactoriser tout ça, en séparant mieux les fonctionnalités, mais ce code est suffisant pour notre exemple.
Au fait... c’est tout ! Vous pouvez d’ores et déjà tester le formulaire, et vous devriez voir une liste se remplir au fur et à mesure de vos validations.
Vous vous attendiez à quelque chose de plus compliqué ? Je rappelle que ce mécanisme de répétition n’existe pas dans Stimulus, en revanche il est très proche dans le principe de la directive ng-repeat d’AngularJS.
1.3 Liste améliorée (via un tableau d’objets)
Dans la section précédente, nous avions vu un tableau simple, nous pouvons aussi travailler avec un tableau d’objets, ce qui offre plus de souplesse.
Par exemple, plutôt que de concaténer les champs « noms » et « prénom » et de stocker le résultat de cette concaténation dans un tableau, nous pouvons stocker ces deux informations dans les propriétés d’un objet, et stocker cet objet dans le tableau. La modification à la fin de la fonction capitalizeNames est minime et se traduit par ceci :
self.names.push({prenom:self.firstName(), nom: self.lastName()});
Au niveau de la liste HTML, les modifications sont également mineures :
<ul data-bind="foreach: names">
<li data-bind="text: $data.nom+' '+$data.prenom"></li>
</ul>
À noter que l’on peut afficher un numéro d’index grâce à la fonction $index fournie par KO. Cette fonction renvoie l’indice de chaque ligne du tableau sous-jacent, mais comme le premier post démarre au numéro zéro, il est préférable d’ajouter la valeur 1. Cela nous donne :
<ul data-bind="foreach: names">
<li data-bind="text: ($index() + 1)+ ' '+ $data.nom+' '+$data.prenom"></li>
</ul>
À noter que, si vous avez besoin d’initialiser le tableau names avec des valeurs de départ, vous pouvez le faire avec un code de ce genre :
var liste = [];
liste.push({nom:"LAMPION", prenom:"Séraphin"});
liste.push({nom:"LOUSTIC", prenom:"François"});
self.names = ko.observableArray(liste);
1.4 Approfondissement sur les tableaux
Nous avons vu précédemment que pour instancier un « tableau observable », et l’alimenter, nous pouvions écrire ceci :
this.myObservableArray = ko.observableArray();
this.myObservableArray.push('Some value');
… ou éventuellement ceci :
this.myObservableArray = ko.observableArray([1, 2, 3]);
Pour récupérer la longueur du tableau ou extraire un élément, rien de plus facile (il faut simplement faire attention à placer judicieusement les parenthèses :
console.log(this.myObservableArray().length);
console.log(this.myObservableArray()[0]);
Mais ce que je trouve intéressant de souligner c’est que nous disposons de toutes les fonctions JS associées aux objets de type tableau, en particulier les fonctions : slice, indexOf, pop, push, shift, unshift, reverse, sort, splice.
De plus, KO nous fournit un jeu de fonctions supplémentaires facilitant la mise à jour du tableau, telles que : replace, remove, removeAll.
Tout cela est très bien expliqué dans la documentation officielle [3] alors je ne m’attarde pas sur le sujet (d’ailleurs nous y reviendrons dans la section suivante avec l’ajout d’une option de suppression).
1.5 Les listes et les événements
Nous avons une liste alimentée par un formulaire, nous allons voir qu’il est facile d’ajouter une option de suppression sur chaque élément de cette liste.
Pour que notre liste ait un aspect plus sympathique, et surtout qu’il soit plus facile d’intégrer un bouton de suppression sur chaque ligne, je vous propose de remplacer notre liste ul par une liste sous forme de tableau HTML. Voici le code source :
<table>
<thead><tr>
<th>Id</th><th>Nom et prénom</th><th>Action</th>
</tr></thead>
<tbody data-bind="foreach: names">
<tr>
<td data-bind="text: ($index() + 1)"></td>
<td data-bind="text: $data.nom+' '+$data.prenom"></td>
<td><a href="#" data-bind="click: $parent.removeName">Supprimer</a></td>
</tr>
</tbody>
</table>
<p>Nombre d'inscrits : <span data-bind="text: names().length"</span></p>
On retrouve dans ce code HTML l’ensemble des éléments de l’ancienne liste, dont l’index de chaque ligne placée dans une première colonne, le nom et le prénom placé dans une seconde colonne, mais nous avons une petite nouveauté avec une balise a qui pointe en mode data binding sur une fonction removeName, fonction que nous allons devoir créer au niveau de notre classe « vue-modèle ». Il ne vous aura pas échappé que j’ai ajouté le mot clé $parent devant cette fonction. Je suis obligé, car l’appel de la fonction est embarqué dans le scope (périmètre) du foreach, je suis donc obligé de « remonter » d’un étage dans le scope pour pouvoir invoquer la fonction.
Une alternative à $parent consisterait à utiliser $root, pour remonter à coup sûr au niveau de la classe « vue-modèle ». Si je n’ai pas trop d’éléments imbriqués et que je suis sûr de pouvoir remonter sur le « parent » en une seule fois, autant utiliser $parent, sinon il est plus simple d’utiliser $root.
L’écriture de la fonction removeName au niveau de la classe AppViewModel est facile, voici son code :
self.removeName = function(item) {
self.names.remove(item)
}
J’avais écrit précédemment que l’on reverrait les fonctions de manipulation de tableaux spécifiques à KO, je ne vous avais pas menti.
Vous aurez remarqué également que j’ai ajouté une balise span affichant par data binding le nombre d’éléments du tableau names. Attention, il ne faut pas oublier les parenthèses avant l’utilisation de la propriété length :
<span data-bind="text: names().length"</span>
Après ces derniers changements, jetons un coup d’œil en figure 2 à notre formulaire et à sa liste intégrée.
Fig. 2 : Second exemple de formulaire avec liste sous forme de tableau HTML.
1.6 Le templating
KO intègre un système de templating simple et efficace. Pour le tester, nous allons transférer notre formulaire dans un template, et notre tableau HTML dans un second template.
Vous allez voir, c’est facile à faire, et cela ne nécessite aucun changement du côté du JavaScript.
Voici le code HTML modifié avec ses deux templates transférés dans deux scripts différents :
<div>
<!-- cette div contiendra le code HTML relatif à chaque test -->
<h2>Avant validation</h2>
<div id="form-item"
data-bind="template: { name: 'form-tmpl' }">
</div>
<div id="list-item"
data-bind="template: { name: 'list-tmpl' }">
</div>
</div>
<script id="form-tmpl" type="text/html">
<form data-bind="submit: capitalizeNames">
<p><label>Prénom : <input data-bind="value: firstName" /></label></p>
<p><label>Nom: <input data-bind="value: lastName" /></label></p>
<p>Nom complet: <strong data-bind="text: fullName" ></strong></p>
<button type="submit">Valider</button>
</form>
</script>
<script id="list-tmpl" type="text/html">
<table data-bind="if: names().length > 0" >
<thead><tr>
<th>Id</th><th>Nom et prénom</th><th>Action</th>
</tr></thead>
<tbody data-bind="foreach: names">
<tr>
<td data-bind="text: ($index() + 1)"></td>
<td data-bind="text: $data.nom+' '+$data.prenom"></td>
<td><a href="#" data-bind="click: $root.removeName">Supprimer</a></td>
</tr>
</tbody>
</table>
<p>Nombre d'inscrits : <span data-bind="text: names().length"</span></p>
</script>
J’ai donc transféré le code du formulaire dans une balise script, et le code du tableau HTML dans une seconde balise script. Vous noterez que chacun de ces scripts a un attribut type fixé à text/html, ainsi qu’un identifiant spécifique. C’est cet identifiant que nous utilisons pour charger ces templates à l’endroit qui nous convient dans la page, via un code de ce genre :
<div id="form-item" data-bind="template: { name: 'form-tmpl' }"></div>
Je vous invite à tester votre code avec cette nouvelle version du HTML, vous allez voir que la modification est transparente. Pour autant, nous avons maintenant deux templates différents, nous pouvons les remplacer par d’autres templates, si nécessaire.
Nous n’avons fait que survoler ce mécanisme dit de « template binding ». Pour une présentation plus exhaustive, je vous invite à lire la documentation officielle [4].
1.7 Les éléments virtuels
KO propose un mécanisme permettant de créer des éléments virtuels via des balises de commentaire HTML spécifiques qui sont les suivantes :
<!-- ko --> mon code HTML <!-- /ko -->
Dans mon exemple précédent, si je veux faire disparaître le nombre d’inscrits contenu dans le tableau names quand il est à zéro, je peux mettre une condition dessus en écrivant ceci :
<!-- ko if : names().length > 0 -->
<p>Nombre d'inscrits : <span data-bind="text: names().length"</span></p>
<!-- /ko -->
On peut aussi utiliser cette technique avec des boucles, comme dans cet exemple que j’ai emprunté à la documentation officielle :
<ul>
<li class="heading">My heading</li>
<!-- ko foreach: items -->
<li data-bind="text: $data"></li>
<!-- /ko -->
</ul>
Mais vous n’avez pas tout vu, le meilleur est encore à venir.
1.8 Components et Custom Elements
Les composants (en anglais « components ») et les éléments personnalisés (en anglais « custom elements ») constituent un des points forts de KO, comme nous allons le voir au travers de quelques exemples.
Si l’on en croit la documentation officielle, les composants :
- peuvent représenter des contrôles ou widgets, mais aussi des sections entières d'une application ;
- contiennent leur propre vue et éventuellement leur propre « vue-modèle » (mais c'est optionnel) ;
- peuvent être préchargés, ou chargés de manière asynchrone ;
- peuvent recevoir des paramètres en entrée, mais aussi en sortie ;
- peuvent être composés d'autres composants ;
- peuvent être facilement packagés pour une réutilisation sur différents projets.
Nous allons commencer par définir un composant, et nous verrons ensuite comme en faire un élément « custom ».
Dans KO, les composants obéissent au modèle suivant :
ko.components.register('mon-composant', {
viewModel: 'ma vue-modèle (optionnelle) ici',
template: 'mon-template HTML ici'
});
Prenons tout de suite un exemple concret. Dans l’exemple d’une précédente section, nous avions un formulaire de saisie (d’un nom et d’un prénom) et une liste d’inscrits avec une option de suppression.
Pour créer un composant my-form-list embarquant le code HTML du formulaire et de la liste, il me suffit d’écrire ceci :
ko.components.register('my-form-list', {
viewModel: AppViewModel,
template: `
<form data-bind="submit: capitalizeNames">
<p><label>Prénom : <input data-bind="value: firstName" /></label></p>
<p><label>Nom: <input data-bind="value: lastName" /></label></p>
<p>Nom complet: <strong data-bind="text: fullName" ></strong></p>
<button type="submit">Valider</button>
</form>
<table data-bind="if: names().length > 0" >
<thead><tr>
<th>Id</th><th>Nom et prénom</th><th>Action</th>
</tr></thead>
<tbody data-bind="foreach: names">
<tr>
<td data-bind="text: ($index() + 1)"></td>
<td data-bind="text: $data.nom+' '+$data.prenom"></td>
<td><a href="#"
data-bind="click: $parent.removeName">Supprimer</a></td>
</tr>
</tbody>
</table>
<!-- ko if : names().length > 0 -->
<p>Nombre d'inscrits : <span data-bind="text: names().length"</span></p>
<!-- /ko -->`
});
Je n’ai fait aucune modification au niveau du code de la classe AppViewModel, j’ai juste eu à modifier la fonction de démarrage du data binding. Les composants sont pris en charge automatiquement et leurs classes « vue-modèles » respectives sont instanciées automatiquement par KO :
ko.applyBindings();
Côté HTML, c’est encore très simple, si ce n’est que vous avez 3 options à votre disposition :
- Solution numéro 1 : créer une div et utiliser l’attribut data-bind, mais cette fois pour encapsuler un composant :
<div id="form-item" data-bind='component: "my-form-list"'></div>
N’oubliez pas de supprimer les scripts qui contenaient les éléments virtuels correspondant au formulaire et à la liste HTML. En effet, le code HTML s’est considérablement simplifié, étant donné que tout le code HTML est embarqué dans le template du composant JavaScript.
- Solution numéro 2 : créer un « custom element », soit une balise portant tout simplement le nom du composant :
<my-form-list></my-form-list>
- Solution numéro 3 : créer un commentaire à la manière de KO, en indiquant qu’il s’agit d’un composant :
<!-- ko component: "my-form-list" --><!-- /ko -->
J’ai testé les 3 solutions, elles fonctionnent très bien.
Nous allons voir qu’il est possible d’imbriquer des composants.
Dans l’exemple qui précède, j’ai regroupé le formulaire et la liste HTML dans un même composant. Franchement, on a vu mieux en termes de modularité. Je vous propose de rectifier le tir en créant :
- un composant pour le formulaire, nous l’appellerons my-form et il contiendra un template, mais pas de « vue-modèle » ;
- un composant pour la liste, nous l’appellerons my-list. Lui aussi contiendra un template et pas de « vue-modèle » ;
- un composant qui embarque les 2 composants précédents, sans surprise il s’appelle my-form-list et c’est lui qui contient la « vue-modèle ».
Voici tout d’abord le code des deux premiers composants :
ko.components.register('my-form', {
template: `
<form data-bind="submit: capitalizeNames">
<p><label>Prénom : <input data-bind="value: firstName" /></label></p>
<p><label>Nom: <input data-bind="value: lastName" /></label></p>
<p>Nom complet: <strong data-bind="text: fullName" ></strong></p>
<button type="submit">Valider</button>
</form>`
});
ko.components.register('my-list', {
template: `
<table data-bind="if: names().length > 0" >
<thead><tr>
<th>Id</th><th>Nom et prénom</th><th>Action</th>
</tr></thead>
<tbody data-bind="foreach: names">
<tr>
<td data-bind="text: ($index() + 1)"></td>
<td data-bind="text: $data.nom+' '+$data.prenom"></td>
<td><a href="#"
data-bind="click: $parent.removeName">Supprimer</a></td>
</tr>
</tbody>
</table>
<!-- ko if : names().length > 0 -->
<p>Nombre d'inscrits : <span data-bind="text: names().length"</span></p>
<!-- /ko -->
`
});
Et voici le code du dernier composant :
ko.components.register('my-form-list', {
viewModel: AppViewModel,
template: `
<my-form params='{firstName: firstName, lastName: lastName,
fullName: fullName, capitalizeNames: capitalizeNames}'></my-form>
<my-list params='{names: names, removeName: removeName}'></my-list>
`
});
Vous noterez la présence de l’attribut params qui va nous permettre d’injecter dans chaque template une référence aux données et fonctions propres à chaque « vue-modèle ». En appliquant cette technique, on n’a aucune modification à effectuer du côté des templates my-form et my-list.
Je vous recommande d’étudier avec soin la documentation officielle relative à la gestion de composants, car il y a quelques subtilités à connaître, et une bonne maîtrise de ce mécanisme vous permettra de l’adapter à vos besoins.
La documentation officielle indique que cette gestion de composants fonctionne sur un large panel de navigateurs, y compris de vieux navigateurs comme IE6. Bref, avec KO, vous pouvez implémenter l’équivalent de webcomponents cross-browser, sans l’aide d’aucun polyfill. Tant de possibilités dans un framework qui ne pèse que 60 Kio ? Franchement, que demande le peuple !!!
2. Welcome to the real world
2.1 Du data binding à tous les étages
Nous n’avons vu qu’un échantillon des possibilités de KO en matière de data binding.
La documentation du site officiel met l’accent sur ce sujet en le découpant en différentes parties :
- contrôle du texte et de l’apparence avec : visible, text, html, css, style, attr(ibute) ;
- contrôle du flux d’exécution avec : foreach, if, ifnot, with, component ;
- gestion des champs de formulaire avec : click, event, submit, enable, disable, value, textInput, hasFocus, checked, options, selectedOptions, uniqueName ;
- rendu des pages avec le template binding.
Nous allons revoir certains de ces concepts dans les sections qui suivent. Mais je vous propose de nous attarder un instant sur la notion de « binding context » [6]. Nous avions découvert le sujet avec les variables internes $parent et $root, fournies par KO au niveau des vues. La doc officielle indique ceci :
« Un contexte de liaison (binding context) est un objet qui contient les données auxquelles vous pouvez vous référer, à partir des liaisons (binding) que vous avez mises en place. Lors de l'application des liaisons, Knockout crée et gère automatiquement une hiérarchie de contextes de liaison. Le niveau racine de la hiérarchie fait référence au paramètre viewModel que vous avez fourni à ko.applyBindings (viewModel). »
En plus de $parent et $root, nous disposons des objets suivants :
- $parents : tableau contenant la hiérarchie des contextes ;
- $component : équivalent à $root, sauf dans le cas de composants imbriqués, car dans ce cas $component fait référence à l’objet « vue-modèle » le plus proche du composant ;
- $data et $index : nous les avions vus précédemment, ce sont des sous-produits de la structure foreach ;
- $parentContext : là où $parent faisait référence aux données du parent référencé, $parentContext fait référence à l’objet encapsulant $parent ;
- $dataData : sensiblement équivalent à $data, mais correspond à l’objet « observable », là où $data correspond aux données brutes ;
- $componentTempateNodes : permet la transmission de paramètres entre composants ;
- $context : objet faisant office de raccourci vers le contexte de liaison en cours ;
- $element : permet de réutiliser certains attributs – ou certaines fonctions – associés à l’objet courant.
Je ne peux décemment pas développer tous ces sujets ici, et je pense que c’est inutile, car ils sont bien expliqués dans la doc officielle. En revanche, il y a quelques sujets qui sont peu ou pas abordés dans la doc officielle. Nous allons nous y intéresser dans les sections suivantes.
2.2 Internationalisation et localisation
KO n’embarque pas de mécanisme gérant internationalisation et localisation. Je rappelle que l’internationalisation traite des problématiques de traduction, et que la localisation se focalise sur des sujets connexes comme les formats de date et les formats monétaires. Dans la littérature informatique, internationalisation est souvent abrégé en i18n, localisation est abrégé en L10n, et on regroupe parfois les 2 notions sous le terme de « globalization » (abrégé en g11n).
J’ai fait quelques recherches sur le Web pour voir comment la communauté des développeurs KO abordait ces sujets. Ma recherche n’a pas été très satisfaisante. Le projet i18next [7] me semblait être une bonne piste. Mais il propose pour son implémentation dans KO d’utiliser un composant tiers, i18next-ko, que je ne suis pas parvenu à faire fonctionner correctement.
Au final, j’ai trouvé sur Stackoverflow deux exemples d’implémentation d’un mécanisme de traduction. Ces deux exemples proposent des méthodes différentes, et j’ai souhaité vous les présenter toutes les deux, car elles vont nous permettre de découvrir quelques mécanismes de KO, dont certains ne sont pas très bien documentés. Les exemples que j’avais trouvés ne me satisfaisaient pas pleinement, aussi je les ai retravaillés et partiellement refactorisés.
Dans les deux exemples que je vais présenter, nous aurons la même page contenant deux lignes distinctes (un message de bienvenue et un texte de présentation), avec en dessous deux boutons permettant de permuter l’affichage sur l’une des deux langues suivantes : français et anglais. Cela va ressembler à ce que vous pouvez voir en ligne 3.
Fig. 3 : Mécanisme de traduction avec KO.
Les exemples que nous allons étudier ne couvrent que l’internationalisation, c’est-à-dire la traduction. La localisation est un sujet pour lequel j’ai trouvé très peu d’informations satisfaisantes. Cet aspect est mieux pris en charge par des projets complémentaires, tels que l’architecture JET d’Oracle, aussi je ne le développerai pas dans le présent article.
2.2.1 Premier exemple de traduction
Voici le code HTML de notre premier exemple :
<h1 data-bind="text: l().header"></h1>
<p data-bind="text: l().body"></p>
<button data-bind="click: change.bind($data, 'fr'), text: l().lang_fr"></button>
<button data-bind="click: change.bind($data, 'en'), text: l().lang_en"></button>
Vous noterez un détail dont je n’avais pas parlé jusqu’ici : il est possible de « binder » plusieurs choses en même temps, avec un même attribut data-bind. Dans notre exemple, le data binding prend en charge en une seule passe, un événement de type click et la modification du contenu textuel de chaque bouton. Il faut simplement penser à insérer une virgule entre chaque paramètre, KO s’occupe du reste.
Et voici le code JavaScript :
(function () {
'use strict';
var translations = {
fr : {
header: 'Bienvenue',
body: 'Vous êtes dans la démo de traduction',
lang_fr: 'Français',
lang_en: 'Anglais',
lang_es: 'Espagnol'
},
en : {
header: 'Welcome',
body: "This is the language demo",
lang_fr: 'French',
lang_en: 'English',
lang_es: 'spanish'
}
};
function AppViewModel($languages) {
var self = this;
self.languages = $languages;
self.l = ko.observable(self.languages['fr']);
self.change = function(lang){
self.l(self.languages[lang]);
//console.log(self.l());
}
}
ko.applyBindings(new AppViewModel(translations));
}());
Ce qui est intéressant dans cet exemple, c’est surtout l’utilisation de la méthode bind associée à la fonction change. Nous n’avons pas eu à écrire cette méthode bind, elle est fournie automatiquement par KO :
<button data-bind="click: change.bind($data, 'fr'), text: l().lang_fr"></button>
La méthode bind nous permet de modifier dynamiquement l’objet $data associé au « context binding » en cours. Vous noterez que côté JS, la fonction change ne reçoit que le paramètre lang. Le paramètre $data, c’est l’affaire de la méthode bind. Ce premier exemple fonctionne très bien, passons maintenant au second exemple.
2.2.2 Second exemple de traduction
Voici le code HTML de notre second exemple :
<h1 data-bind='text: l().header'></h1>
<p data-bind='text: l().body'></p>
<button data-bind='click: chooselanguage, text: l().lang_fr, attr: {"data-lang": "fr"}'></button>
<button data-bind='click: chooselanguage , text: l().lang_en, attr: {"data-lang": "en"}'></button>
Et voici le code JavaScript :
(function () {
'use strict';
var translations = {};
translations.fr = {
header: "Bienvenue",
body: 'Vous êtes dans la démo de traduction',
lang_fr: 'Français',
lang_en: 'Anglais',
lang_es: 'Espagnol'
};
translations.en = {
header: "Welcome",
body: "This is the language demo",
lang_fr: 'French',
lang_en: 'English',
lang_es: 'spanish'
}
var ViewModel = function (translations) {
var self = this;
self.translations = translations;
self.l = ko.observable(self.translations.fr);
self.chooselanguage = function (event, object) {
//console.log(object);
var lang = object.target.getAttribute('data-lang');
self.l(self.translations[lang]);
};
};
ko.applyBindings(new ViewModel(translations));
}());
La différence avec le premier exemple réside dans le fait que je n’ai pas voulu modifier le « binding context » de l’objet courant $data.
J’ai souhaité que la fonction chooselanguage associée à chacun des boutons, récupère le contenu d’un attribut HTML personnalisé que j’ai appelé data-lang. Cet attribut est défini sur chacun des boutons, il contient la langue correspondant à chaque bouton (fr ou en).
Je trouvais plutôt cool de procéder de cette manière. Mais j’ai eu beaucoup de mal à trouver comment, dans KO, récupérer un attribut personnalisé associé à un élément du DOM (si c’est indiqué dans la doc, c’est bien planqué).
Après pas mal de tests infructueux, j’ai découvert, je ne sais plus trop comment, que ma fonction chooselanguage pouvait recevoir deux paramètres. Après avoir observé le contenu de ces deux paramètres via la fonction console.log j’ai décidé de les appeler respectivement event et object. Si le paramètre event n’est pas très utile, c’est grâce au paramètre object que je suis parvenu à récupérer le contenu de l’attribut personnalisé data-lang, via le code suivant :
var lang = object.target.getAttribute('data-lang');
Ouf, j’ai perdu un peu de temps sur ce coup-là, mais cela en valait la peine.
Vous noterez que, pour alimenter l’attribut personnalisé data-lang de chaque bouton, j’ai utilisé une technique que nous n’avions pas encore vue, désignée dans la documentation sous le terme de « attr binding » :
<button data-bind='click: chooselanguage, text: l().lang_fr, attr: {"data-lang": "fr"}'></button>
C’est du « data-binding trois en un »… KO, c’est vraiment trop cool.
2.3 Validation de formulaire
La validation de formulaire dans KO peut se faire de différentes manières, mais elle passe généralement par l’utilisation d’objets de type ko-extenders. Ces objets permettent d’étendre les possibilités des objets observables, on peut en particulier les utiliser pour ajouter des contrôles d’erreur personnalisés.
Commençons par ajouter à notre page HTML quelques classes CSS, histoire de rendre nos messages d’erreur un peu plus attractifs :
<style>
.input-validation-error {
border-style:solid; border-color:red;
}
.input-message-error {
color:red;
}
</style>
Nous allons ensuite créer un formulaire « ultra-lite », avec 2 champs de saisie, nom et prénom. Je souhaite ajouter un contrôle de type « saisie obligatoire » sur le champ de saisie correspondant au prénom, et un contrôle interdisant la saisie de nom de plus de 30 caractères. Le data binding appliqué à ces deux champs de saisie impacte leurs propriétés liées à la visibilité, au contenu textuel et au rendu CSS :
<form data-bind="submit: submitForm">
<p data-bind="css: { error: firstName.hasError }">
<input data-bind='value: firstName' />
<span data-bind='visible: firstName.hasError,
text: firstName.validationMessage,
css: "input-message-error"'> </span>
</p>
<p data-bind="css: { error: lastName.hasError }">
<input data-bind='value: lastName' />
<span data-bind='visible: lastName.hasError,
text: lastName.validationMessage,
css: "input-message-error"'> </span>
</p>
<button type="submit">Go caps</button>
</form>
Dans le code JS suivant, nous avons créé deux extensions, required et maxlength, en respectant le modèle fourni dans la documentation officielle.
Au niveau de la classe « vue-modèle », vous devez surtout observer comment les objets observables firstname et lastname ont été enrichis avec l’intégration des « extenders » :
(function () {
'use strict';
ko.extenders.required = function (target, overrideMessage) {
target.hasError = ko.observable();
target.validationMessage = ko.observable();
function validate(newValue) {
var error = newValue ? false : true;
target.hasError(error);
target.validationMessage(!error ? "" : overrideMessage || "Field required");
}
validate(target());
target.subscribe(validate);
return target;
};
ko.extenders.maxlength = function (target, options) {
target.hasError = ko.observable();
target.validationMessage = ko.observable();
var message = options.message || '';
var maxsize = options.maxsize || 256;
function validate(newValue) {
var error = newValue.length > maxsize ? true : false;
target.hasError(error);
target.validationMessage(!error ? "" : message || "Field too large");
}
validate(target());
target.subscribe(validate);
return target;
};
function AppViewModel(one, two) {
var self = this;
self.firstName = ko.observable('Kid')
.extend({ required: "firstname is required" });
self.lastName = ko.observable('Paddle')
.extend({ maxlength: {message:"lastname exceeds 30 characters",
maxsize:30}});
self.capitalizeLastName = function() {
var currentVal = self.lastName().trim();
self.lastName(currentVal.toUpperCase());
};
self.submitForm = function() {
self.capitalizeLastName();
};
}
ko.applyBindings(new AppViewModel(221.2234, 123.4525));
}());
Je pensais qu’il serait possible, avec ce système, d’ajouter deux objets « extenders » à un même « observable », mais je ne suis pas parvenu à le faire. Mais je n’ai peut-être pas employé la bonne méthode.
Dans la documentation officielle, je vous encourage à lire la page [8] relative à l’utilisation des « extenders ». Vous y trouverez notamment un exemple pour la saisie de donnée numérique, qui est intéressant à étudier.
Pour une gestion d’erreur plus sophistiquée, il est possible de s’appuyer sur un composant complémentaire, le projet knockout-validation [9]. J’ai trouvé un bon article d’introduction sur ce sujet, avec un exemple de formulaire bien détaillé implémentant plusieurs types de contrôles, je vous le recommande [10].
Conclusion
Dans cette présentation de KO, je me suis focalisé sur certains points qu’il me semblait important de présenter, mais KO ne se résume pas à cela, et je vous encourage à prendre le temps de parcourir sa documentation qui est très bien faite. Et je vous recommande aussi d’essayer le tuto en ligne, disponible sur le site officiel, il est très intéressant.
Je vous invite également à regarder la présentation de KO, que Steve Sanderson avait faite en 2014 [11]. Dans cette vidéo, Steve Sanderson présente KO comme une librairie et non pas comme un framework. Je pense qu’il était trop modeste (mais peut a-t-il revu sa position depuis). Certes KO ne fournit pas tous les éléments nécessaires à un projet de type SPA, mais il n’en est pas très loin. L’architecture JET d’Oracle tend à combler les quelques lacunes de KO, pour en faire un outil qui me semble bien adapté au développement d’applications d’entreprise (aussi bien web que mobile). J’y reviendrai dans un prochain article.
Je vous avoue que pour ma part je suis séduit par les possibilités de KO, et j’en viens à regretter de ne pas m’y être intéressé plus tôt. Je trouve qu’il répond bien à la plupart des problématiques que je rencontre, que ce soit sur des projets de PME, ou des projets de grands comptes. Sa simplicité d’usage en fait aussi un bon outil d’apprentissage pour les étudiants en informatique, désireux de s’initier à l’architecture MVVM. Et je pense qu’il peut être utile aux « creative coders », qui lui trouveront certainement tout un tas d’usages dont je n’ai même pas idée.
Références
[1] Site officiel de JET : http://www.oracle.com/webfolder/technetwork/jet/index.html
[2] Site officiel de Knockout : http://knockoutjs.com/
[3] Documentation sur les tableaux : http://knockoutjs.com/documentation/observableArrays.html
[4] Documentation sur le template binding : http://knockoutjs.com/documentation/template-binding.html
[5] Documentation sur les composants : http://knockoutjs.com/documentation/component-overview.html
[6] Documentation sur le binding context : http://knockoutjs.com/documentation/binding-context.html
[7] Projet i18next : https://www.i18next.com
[8] Documentation sur les extenders : http://knockoutjs.com/documentation/extenders.html
[9] Projet knockout-validation : https://github.com/Knockout-Contrib/Knockout-Validation
[10] Tuto sur knockout-validation : https://www.c-sharpcorner.com/article/validation-form-with-knockout-js
[11] Vidéo de Steve Sanderson : https://vimeo.com/97519516