Pourquoi imposer une architecture à nos applications ?
Nos projets en JavaScript deviennent de plus en plus complexes : par leur taille, par leur fonctionnement et par leur coût de développement.
Le nombre d’outils et de librairies accessibles aux développeurs ne cesse de grandir et ce n’est pas en 2015 que la cadence va s’arrêter. Il est donc de plus en plus facile de se perdre et de rendre notre code illisible, difficilement maintenable et évolutif.
Toute notre problématique est là : rendre nos applications scalables pour les :
- Maintenir
-> Facilement améliorer le code courant
-> Rendre son code compréhensible de tous
- Adapter
-> Inclure de nouvelles librairies
-> Etendre, changer des parties
- Debugger
-> Tester chaque partie de manière indépendante
- Développer plus rapidement
-> Chaque développeur peut travailler en parallèle.
Le pattern MVVM va nous aider à résoudre ces problèmes tout en nous simplifiant la vie.
Pattern MVVM
Le pattern MVVM est un dérivé du célèbre modèle de conception MVC.
L’ancêtre MVC
L’idée principale du MVC, signifiant Model-View-Controller, est de découper l’application en trois parties interconnectées :
- Model: représente les données reçues du serveur
- View: contient l’ensemble des vues affichées à l’utilisateur
- Controller: contient la logique de l’application
Le Modèle définit la structure des données et communique avec le serveur.
La Vue affiche les informations du Modèle et reçoit les actions de l’utilisateur.
Le Contrôleur gère les évènements et la mise à jour de la Vue et du Modèle.
Nous avons un premier découpage de l’application qui nous permet déjà de répondre à certaines de nos problématiques. En identifiant clairement les parties logiques, nous pouvons plus facilement maintenir notre application et la tester.
Mais qu’en est-il de l’adaptation et de l’évolution de notre application ? Si le modèle de données est amené à changer, nous devons obligatoirement modifier l’ensemble de nos entités.
Qu’apporte alors le pattern MVVM et que signifie-t-il ?
Principes du MVVM
MVVM est l’acronyme de : Model-View-ViewModel.
On pourrait donc simplement croire qu’on a remplacé le nom Controller par ViewModel. Mais si nous reprenons le schéma précédent avec le pattern MVVM, nous obtenons les interactions suivantes :
La Vue reçoit toujours les actions de l’utilisateur et interagit seulement avec le ViewModel.
Le Modèle communique avec le serveur et notifie le ViewModel de son changement.
Le ViewModel s’occupe de :
- présenter les données du Model à la Vue,
- recevoir les changements de données de la Vue,
- demander au Model de se modifier.
Data Binding
La Vue n’a donc plus aucun lien avec le Model. Ainsi le ViewModel s’occupe entièrement du cycle de modification de ce dernier. Il réalise à la fois la réception et l’envoi des données à la Vue. On parle alors de « data binding ». Les informations affichées sont liées entre deux entités et mises à jour en temps réel.
Ce dernier mécanisme est la clef du pattern MVVM. Il nous permet de découpler les différentes parties de notre application en étant capable de la faire évoluer de manière modulaire.
Dans le schéma suivant, nous pouvons voir qu’un même Modèle peut notifier plusieurs ViewModel de son changement. Ainsi ces ViewModel vont à leur tour indiquer à leur Vue de se rafraîchir.
Si la Vue n°2 modifie le Model, la Vue n°1 sera automatiquement mise à jour.
Du fait de cette indépendance entre la Vue et le Modèle, nous pouvons modifier ou remplacer les différents composants visuels sans impacter le cœur de notre application. Nous pouvons également changer, améliorer la logique de notre application à travers le ViewModel sans toucher à la Vue et au Model.
Presenter
Le pattern MVVM est également cité comme une extension du MVP : Model-View-Presenter.
La structure de données que nous recevons du serveur n’est pas forcément celle à afficher à l’utilisateur. Il est néanmoins conseillé de garder cette structure initiale pour pouvoir l’exploiter à d’autres moments. Le ViewModel peut présenter à la Vue, une autre structure ou des données formatées différemment.
Cet aspect du MVVM va nous permettre de facilement modifier nos services et donc notre Modèle sans toucher à la Vue. Seul le ViewModel devra se réadapter pour fournir à la Vue les mêmes informations.
Un autre avantage est la possibilité de mocker nos Webservices dans le ViewModel. Ainsi nous pouvons travailler sur notre IHM en parallèle de la mise en place de nos WebServices.
Implémentations
Il existe de nombreux frameworks Javascript, qui permettent de développer des applications MVVM et qui fournissent des outils pour réaliser un data-binding.
Voyons comment au travers de trois frameworks : KnockoutJS, AngularJS et ExtJS, nous pouvons créer une application TodoList. Notre exemple sera volontairement simple pour faire apparaître les différents mécanismes du MVVM.
Notre application sera composée de deux vues :
Vue 1 :
Un formulaire d’édition composé de :
– Un bouton : au clic une nouvelle tâche est ajoutée.
– Un input texte : affiche et édite la tâche en cours.
– Un label : affiche le nombre de tâches.
Vue 2 :
Une liste de label : chaque label affichera le titre d’une tâche.
Au clic sur un label, la tâche courante sera modifiée pour afficher dans le formulaire, le titre sélectionné.
Notre modèle sera composé de deux « classes » :
Une classe Task, pour définir une tâche, composée d’un titre
Une classe Setting contenant :
– La tâche courante à éditer.
– La liste de toutes les tâches crées.
KnockOutJS
KnockoutJS est une libraire Javascript, qui peut facilement s’intégrer à une architecture déjà existante. Elle propose plusieurs outils pour simplifier et automatiser la mise à jour de l’UI lorsque les données changent.
Model
On définit les deux classes. Chaque propriété doit être attribuée à partir des méthodes « observables » de KnockOutJS. L’appel à ces méthodes va permettre d’enregistrer ces données pour écouter leur modification.
var Task = function (title, index) {
this.title = ko.observable(title);
};
var Setting = function() {
var task = new Task("new task");
this.currentTask = ko.observable(task);
this.tasks = ko.observableArray([task]);
};
var setting = new Setting();
ViewModel
Les ViewModel contiennent :
- le modèle, également écouté par KnockOutJS via ko.observable.
- des données calculées via la méthode ko.computed, qui vont permettre d’étendre le modèle de base.
- la logique de la Vue.
Chaque ViewModel s’occupe d’une partie logique côté IHM.
// ViewModel associé au formulaire d’édition d’une tâche
var FormViewModel = function () {
// charge le modèle dans le ViewModel
this.setting = ko.observable(setting);
// ajoute une nouvelle tâche dans la liste
// et change la tâche courante avec celle-ci
this.addNewTask = function() {
var currentTask = new Task("new task");
this.setting().tasks.push(currentTask);
this.setting().currentTask(currentTask);
}.bind(this);
// retourne le nombre de tâches
// propriété calculé, étendant le modèle
this.remainingCount = ko.computed(function () {
return this.setting().tasks().length;
}.bind(this));
};
// crée le ViewModel et l’applique à la bonne vue
var formViewModel = new FormViewModel();
ko.applyBindings(formViewModel, document.getElementById("form"));
// ViewModel associé à la vue de la liste des tâches
var ListViewModel = function () {
// charge le même modèle
this.setting = ko.observable(setting);
// change la tâche courante
this.editTask = function(item) {
this.setting().currentTask(item);
}.bind(this);
}
// crée le ViewModel et l’applique à la bonne vue
var listViewModel = new ListViewModel();
ko.applyBindings(listViewModel, document.getElementById("list"));
View
La Vue, décrite en html, récupère les données et les méthodes depuis le ViewModel. A l’aide de la propriété data-bind, nous déclarons toutes nos liaisons à des données et des méthodes.
// formulaire d’édition d’une tâche
<div id="form">
// méthode addNewTask définit dans le ViewModel
<button class="add" data-bind="click: $root.addNewTask"></button>
//bind la tâche courante depuis le ViewModel
<input id="new-todo" data-bind="value: setting.currentTask.title , valueUpdate: 'afterkeydown' " autofocus>
//bind le nombre de tâches ajoutées
<strong data-bind="text: remainingCount">0</strong>
</div>
//liste des tâches
<div id="list">
<ul id="todo-list" data-bind="foreach: tasks">
<li>
<label data-bind="text: title, event: { click: $root.editTask }"></label>
</li>
</ul>
</div>
Le tag « input » affiche et modifie la valeur currentTask.title.
L’objet currentTask est aussi affiché dans la liste, via la propriété tasks du Modèle Setting.
Ainsi lorsque l’utilisateur clique sur un tag « label », le titre de la tâche s’affiche dans l’input du formulaire. Si ce dernier modifie cette valeur, le label de la liste est automatiquement mis à jour.
AngularJS
AngularJS n’est plus vraiment à présenter. La framework JS made in Google n’implémente pas à proprement parlé le pattern MVVM. On utilise le $scope comme ViewModel. On parle alors plus de MVW, pour Model-View-Whatever. L’important ici est de séparer la Vue et le Modèle.
Model
Nous n’avons pas besoin de créer de structure de Model spécifique. Angular se rappelle des valeurs précédemment stockées et envoie un évènement si elles sont différentes. Il nous suffit alors d’instancier un objet avec les propriétés que nous voulons utiliser. On peut également utiliser les Services d’AngularJS pour initialiser nos modèles depuis un serveur et les fournir ensuite au ViewModel.
//initialisation de la première tâche
var task = {title : 'new task'};
//créer un objet global contenant
//un tableau de tâches
//et la tâche courante
var setting = {};
setting.currentTask = task;
setting.tasks = [task];
ViewModel
Côté ViewModel, nous nous rapprochons de ce que peut produire KnockOutJS. C’est pourquoi on compare souvent ces deux frameworks pour créer des applications MVVM. Nous déclarons deux ViewModel, qui garde une référence sur le même objet setting, qui est ajouté un $scope
.
angular.module('myApp', [])
//ViewModel utilisé par le formulaire
.controller('FormViewModel', ['$scope', function($scope) {
//charge le modèle dans le ViewModel
$scope.setting = setting;
//méthode d’ajout d’une tâche
$scope.addTask = function() {
var newtask = {title : "new task"}
$scope.setting.tasks.push(newtask);
$scope.setting.currentTask = newtask;
};
//retourne le nombre de tâches
//la propriété calculée, permet d’étendre le modèle
$scope.nbtasks = function() {
var count = 0;
angular.forEach($scope.setting.tasks, function(todo) {
count += 1;
});
return count;
};
}])
//ViewModel utilisé par la liste
.controller('ListViewModel', ['$scope', function($scope) {
//charge le même modèle
$scope.setting = setting;
//méthode permettant d’éditer une tâche
$scope.editTask = function(task) {
$scope.setting.currentTask = task;
};
}]);
View
Les Vues, décrites en HTML, déclarent leur ViewModel et indiquent quelles données et méthodes vont être liées. Encore une fois seule la syntaxe diffère de KnockoutJS. On utilise la propriété ng-model ou des doubles accolades pour lier le modèle à la vue.
//formulaire d’édition d’une tâche
<div ng-controller="FormViewModel">
<span>nb tasks : {{nbtasks()}}</span>
<form ng-submit="addTask()">
<input class="btn-primary" type="submit" value="add new task"/>
</form>
<input type="text" ng-model="setting.currentTask.title" size="30"/>
</div>
//liste des tâches
<div ng-controller="ListViewModel">
<ul>
<li ng-repeat="task in setting.tasks">
<span ng-click="editTask(task)">{{task.title}}</span>
</li>
</ul>
</div>
ExtJS 5
ExtJS dans sa version 5 propose une implémentation revisitée du MVVM . En plus du ViewModel, il introduit une quatrième entité : le ViewController. Associé à une unique Vue, le ViewController va permettre de gérer le comportement et la logique de la Vue, alors que le ViewModel se cantonne au rôle de Presenter.
2.3.1 Model
Nous déclarons une tâche et un setting, avec la syntaxe ExtJS. Nous créons aussi une collection de tâches et une collection de settings. Cette dernière va nous permettre de récupérer les settings n’importe où. De plus nous utilisons cette collection pour ajouter des méthodes de manipulation sur le Modèle.
//structure d’une tâche, composée uniquement d’un titre
Ext.define(‘Task’, {
extend: 'Ext.data.Model',
fields: [{
name: 'title',
type: 'string',
defaultValue : 'new task'
}]
});
//donnée globale de l’application
//contient la liste de toutes les tâches
//et la tâche courante
Ext.define(‘Settings', {
extend: 'Ext.data.Model',
fields: [{
name: 'currentTicket',
},{
name: 'tasks',
}]
});
//définit une collection de tâches
Ext.define(‘Tasks, {
extend: 'Ext.data.Store',
model : 'Task
storeId : tasks
});
//définit une collection de settings
//cette collection ne contiendra qu’un seul élément
Ext.define('SettingsStore', {
extend: 'Ext.data.Store',
storeId : 'settings',
model : 'Settings',
//méthode appelée au chargement de l’application
load : function() {
//ajoute un seul element Settings
var settings = Ext.create('Settings');
settings.set(tasks, Ext.create('Tasks'));
this.add(settings);
//ajoute une première tâche
this.addTask();
},
//ajoute une nouvelle tâche
addTask : function() {
var newTask = Ext.create('Task');
this.getAt(0).get('tasks').add(newTask);
this.setCurrentTask(newTask);
},
//met à jour la tâche courante
setCurrentTask : function(task) {
this.getAt(0).set('currentTask', task);
}
});
ViewModel et View Controller
Le ViewModel n’est utilisé que pour présenter les données.
Le ViewController est utilisé pour charger les données dans le ViewModel et gérer la logique de la Vue. Chaque ViewController récupère le même modèle Settings et le fournit à son ViewModel.
Ainsi les deux vues manipuleront le même objet sans interagir ensemble.
//ViewModel du composant graphique FormTask
Ext.define('FormTaskViewModel', {
extend: 'Ext.app.ViewModel',
alias: 'viewmodel.formtask',
//data à binder
data : {
settings : null
},
//data “calculée” à binder
formulas : {
nbTasks : function(get) {
return get('settings.tasks').count();
}
}
});
//ViewController du composant graphique FormTask
Ext.define('FormTaskViewController', {
extend: 'Ext.app.ViewController',
alias: 'controller.formtask',
//méthode appelée automatiquement à l’initialisation
init : function() {
//récupère le model settings
var setting = Ext.getStore('settings').getAt(0);
//charge le modèle dans le ViewModel
this.getViewModel().set('settings', setting);
},
onClickCreateButton : function(cmp) {
//ajoute une nouvelle task
this.getViewModel().get('settings').store.addTask();
}
});
// ViewModel du composant graphique GridTicket
Ext.define('MVVM.view.GridTicketViewModel', {
extend: 'Ext.app.ViewModel',
alias: 'viewmodel.gridticket',
data : {
settings : null
}
});
//ViewController associé à la vue GridTicket
Ext.define('MVVM.view.GridTicketViewController', {
extend: 'Ext.app.ViewController',
alias: 'controller.gridticket',
init : function() {
//récupère le model settings
var setting = Ext.getStore('settings').getAt(0);
//charge le modèle dans le ViewModel
this.getViewModel().set('settings', setting);
},
onSelectRow : function(cmp, model) {
//modifie la tâche courante par celle sélectionné
this.getViewModel().get('settings').store.setCurrentTicket(model);
}
});
View
Les Vues en ExtJS sont décrites dans des objets. Chaque Vue déclare son ViewModel et son ViewController, mais n’appelle jamais ses instances. Il suffit alors d’utiliser le mot clef ‘bind’ pour lier la valeur dans le ViewModel à celle de la Vue.
//Composant graphique définissant le formulaire d’édition d’une tâche
Ext.define('FormTask', {
extend: 'Ext.form.Panel',
//déclaration du viewcontroller associé
controller: 'formtask',
//declaration du viewmodel associé
viewModel: {
type: 'formtask'
},
items : [{
xtype : 'button',
label : 'Create new Task',
//fonction définit dans le ViewController
handler : 'onClickCreateButton'
},{
xtype : 'textfield',
fieldLabel : 'Title',
name: 'title',
allowBlank: false,
bind : {
//affiche le titre de la tâche courante
value : '{settings.currentTask.title}'
}
}, {
xtype : 'label',
//affiche le nombre de tâches
bind : '{nbTasks}'
}]
});
//Composant graphique définissant la liste des tâches
Ext.define('MVVM.view.GridTicket', {
extend: 'Ext.grid.Panel',
xtype : 'grid-ticket',
controller: 'gridticket',
viewModel: {
type: 'gridticket'
},
// bind la collection de tâches
// le composant Ext.grid.Panel s’occupe
// d’afficher automatiquement toutes les tâches
bind : {
store : '{settings.tickets}'
},
//affiche dans cette colonne la propriété ‘title’ de chaque tâche
columns : [{
text: 'Tasks',
dataIndex : 'title'
}],
//au clic sur une ligne, on exécute la méthode 'onSelectRow'
listeners : {
select : 'onSelectRow'
}
});
Conclusion
Chaque framework a sa propre manière et syntaxe pour implémenter le pattern MVVM. Le principe d’indépendance entre la Vue et le Modèle est à chaque fois respecté et tous proposent des outils pour mettre en place un data-binding entre la Vue et le ViewModel.
Chaque framework a ses propres avantages :
- KnockoutJS est peu intrusif en étant une simple librairie.
- AngularJS est le plus simple syntaxiquement et le plus éprouvé par la communauté.
- ExtJS pousse encore plus à découper notre application et à structurer notre code.
Mais le pattern MVVM n’a pas que des avantages. Pour de petits projets, il peut être contre-productif de proposer une telle architecture. De plus pour de très gros projets avec de nombreux ViewModel, le data-binding peut consommer la mémoire de manière considérable.
Il n’est donc pas interdit d’implémenter son propre mécanisme de liaison, tant que l’on respecte le découpage complet entre la Vue et le Modèle.