Modèle object
Avec Ember, la quasi totalité des objets utilisés est dérivée d’une classe de base, la classe Ember.Object
: les contrôleurs, les modèles, l’application elle-même.
C’est cette classe qui permet aux objets Ember de partager des comportements communs. Chaque objet Ember est ainsi capable d’observer les valeur de propriétés portées par d’autres objets, d’éventuellement lier leurs propres propriétés à celles des objets observés, de construire et d’exposer des propriétés calculées, etc.
Nous allons explorer pas à pas certains de ces comportements. Pour cela, il faut en premier lieu disposer de l’objet Ember lui-même.
-
Créer un fichier html mettant en place un contexte Ember simple :
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Ember Object model</title> <script src="https://code.jquery.com/jquery-3.2.1.min.js"></script> <script src="https://builds.emberjs.com/release/ember.debug.js"></script> <script src="https://builds.emberjs.com/release/ember-template-compiler.js"></script> </head> <body> </body> </html>
-
Ouvrir ce fichier dans un navigateur, console Javascript ouverte.
La console doit être exempte d’erreur et afficher l’information suivante :
DEBUG: For more advanced debugging, install the Ember Inspector from https://chrome.google.com/webstore/detail/ember-inspector/bmdblncegkenkacieihfhpjfppoconhi
-
Entrer
Ember
dans la consoleLa réponse doit être :
Object { __loader: {…}, isNamespace: true, toString: Ember.toString(), A: A(), getOwner: getOwner(), setOwner: setOwner(), generateGuid: generateGuid(), GUID_KEY: "__ember1521711735088", guidFor: guidFor(), inspect: inspect(), … }
On constate, en développant cet objet, qu’il contient l’ensemble des objets et fonctions du framework. En particulier la classe
Ember.Object
que nous allons manipuler.
Classes et instances
Definition
Pour définir et utiliser un nouvel objet Ember, il est nécessaire d’étendre - au minimum - la classe Ember.Object
via la méthode extend()
.
-
Dans la console, créer une classe
Book
qui étendEmber.Object
et définit une méthodelogTitle
affichant en console une chaîne de caractères (le titre) passée en paramètre.> Book = Ember.Object.extend({ logTitle(title) { console.log(title); } });
-
Instancier cette classe et afficher un titre dans la console
> one = new Book(); > one.logTitle("titre");
Initialisation
On souhaite désormais initialiser l’objet à sa création avec un titre et afficher ce titre plutôt qu’un paramètre de méthode.
-
Modifier la classe
Book
en conséquence et créer l’objet via la méthode create() d’Ember en initialisant un champstitle
.> Book = Ember.Object.extend({ logTitle() { console.log(this.title); } }); > one = Book.create({title: "My Title"}); > one.logTitle();
L’utilisation de la méthode create() en lieu et place d’un simple
new
permet l’initialisation de propriétés via un objet passé en paramètre. La méthodecreate
permet également d’effectuer des opérations d’initialisations complémentaires via l’appel de la méthode init(). -
Ajouter une méthode d’initialisation qui réalise un simple log console du titre passé au create. Le résultat doit être le suivant :
> one = Book.create({title: "My Title"}); My Title
> Book = Ember.Object.extend({ init() { this.logTitle(); }, logTitle() { console.log(this.title); } });
Héritage
On peut évidemment étendre une sous classe d’Ember.Object
plutôt que Ember.Object
directement.
A noter que c’est ce qui est fait chaque fois que l’on étend un objet natif d’Ember puisque tous étendent Ember.Object
: Ember.Controller
, Ember.Route
, etc.
Dans le cas d’une route, par exemple :
> BookRoute = Ember.Route.extend({
...
});
Dans le cadre de l’héritage d’Ember.Object
, l’ensemble des méthodes peuvent être surchargées.
Les méthodes de la classe mère peuvent être accédées via l’appel de la méthode spéciale _super(...)
.
-
Modifier la classe
Book
pour lui ajouter une méthodelogType
qui affiche “Book”. Le résultat doit être le suivant :> one = Book.create({title: "My Title"}); My Title > one.logType(); Book
> Book = Ember.Object.extend({ ... logType() { console.log("Book"); } });
-
Définir une classe
Comic
qui étendBook
et surcharge la méthodelogType
en affichantComic
.On doit pouvoir effectuer les opérations suivantes :
> one = Comic.create({title: "My Title"}); My Title > one.logType(); Comic
> Comic = Book.extend({ logType() { console.log("Comic"); } });
-
Modifier
Comic
de sorte quelogType
affiche également le type de la classe mère.On doit pouvoir effectuer les opérations suivantes :
> one = Comic.create({title: "My Title"}); My Title > one.logType(); Book Comic
> Commic = Book.extend({ ... logType() { this._super(); console.log("Comic"); } });
L’appel à la méthode mère doit donc être explicite. Lorsque vous héritez d’un objet Ember (
Controller
,Route
, etc.) et que vous surchargez la méthodeinit
dans votre implémentation, soyez sûr de bien appeler la méthode_super
au tout début de l’init. Dans le cas contraire, les traitements d’initialisation standard prévus par Ember ne pourraient pas s’exécuter correctement entraînant des comportements erratiques.
Accesseurs
Jusqu’à présent, nous ne nous sommes pas posé beaucoup de questions sur la manière d’accéder aux propriétés des objets Ember.
Pourtant, tout Ember.Object
expose des accesseurs qu’il est nécessaire d’utiliser.
-
Copier le contenu suivant dans le fichier html créé, juste avant la balise
</head>
:<script> Book = Ember.Object.extend({ init() { this.displayTitle(); }, displayTitle() { console.log(this.title); }, logType() { console.log("Book"); } }); Comic = Book.extend({ logType() { this._super(); console.log("Comic"); } }); one = Comic.create({title: "My Title"}); App = Ember.Application.create(); App.ApplicationController = Ember.Controller.extend({ comic: one }); </script> <script type="text/x-handlebars"> <p> {{comic.title}} </p> </script>
-
Effectuer les opérations suivantes sur l’instance one :
> one.title; > one.get("title"); > one.title = "new title"; > one.set("title", "new title"); > one.get("title");
Les résultats sont les suivants :
> one.title; "My Title" > one.get("title"); "My Title" > one.title = "new title"; Error: Assertion Failed: You must use set() to set the `title` property ... > one.set("title", "new title"); "new title" > one.get("title"); "new title"
On constate qu’Ember
met à disposition des accesseur pour manipuler les propriétés d’un objet, en lecture ou écriture et que lorsqu’on essaie de faire une affectation directe sur une propriété d’un Ember.Object
, une exception explicite est levée nous obligeant à appeler le setter set()
La raison est qu’Ember met en place un certain nombre de mécanismes que nous explorerons par la suite et qui ne seront pas correctement déclenchés sans appel aux accesseurs Ember
.
Parmi ces mécanismes, les computed properties
, les observers
ainsi que l’ensemble des mécanismes de binding du template qui permettent au framework de réagir de manière native et transparente aux changements survenant sur différents objets.
Les mécanismes de binding sont, en particulier, au coeur du moteur de rendu d’Ember et permettent aux templates html de se mettre automatiquement à jour lors d’un changement sur un objet et cela de manière performante et ciblée, sans avoir à parcourir l’ensemble des objets connus.
On remarque ainsi qu’il suffit de modifier, dans la console, le titre via one.set("title", "new title");
pour que le template - et la page - soit mis à jour, sans action supplémentaire de notre part !
Ce fonctionnement ainsi que tous les mécanismes d’observation à la base du framework s’appuie sur l’utilisation des getters / setters des Ember.Object
.
Il est donc absolument nécessaire de les utiliser systématiquement.
Lorsque c’est possible, Ember nous y oblige.
Cependant (notamment dans le cas des getters), il n’est pas toujours possible de forcer l’usage de ces accesseurs et il est donc important d’être vigilant sur ces points.
Réouvrir une classe
Les instances et les sous-classes d’Ember.Object
mettent également à disposition une méthode reopen
.
Cette méthode permet de définir les classes et instances de manière itérative et d’enrichir les classes avec de nouvelles propriétés ou méthodes.
-
Dans la console, réouvrir la classe
Book
et lui ajouter une propriétépages
.> Book.reopen({ pages: 10 });
-
Afficher la valeur de
pages
sur l’instance existanteone
en utilisant l’accesseur. Que constate-t-on ?> one.get('pages'); undefined
On constate que la propriété n’est pas définie.
-
Créer une nouvelle instance
two
deBook
puis afficher la valeur depages
sur cette instance. Afficher de nouveau la valeur depages
sur l’instanceone
. Que constate-t-on sur chacune de ces deux instances ? Pourquoi ?> two = Book.create({title: 'two'}) two > two.get('pages'); 10 > one.get('pages'); 10
On constate que la propriété a bien été définie et initialisée dans nos deux instances. Y compris l’instance
one
qui existait déjà. Les propriétés et méthodes ajoutées parreopen
ne sont donc ajoutées effectivement au prototype de la classe que lors de la prochaine création d’une instance de cette classe, en mode lazy. cf. cette discussion -
Lors de l’utilisation de
reopen
, il est possible, tout comme dans le cas d’un héritage, de redéfinir une méthode existante mais également d’utiliser la méthode_super(...)
pour appeler la méhode définie précédement. Utiliserreopen
pour redéfinirdisplayTitle
et afficher une ligneTitle:
avant d’afficher le titre.> Book.reopen({ displayTitle() { console.log('Title:'); this._super(); }}); > three = Book.create({title: 'three'}); Title: three
La méthode reopen
permet donc d’ajouter des propriétés et méthodes de classe.
Cette méthode permet, de manière très pratique, de définir une classe de manière itérative et donc bien plus dynamique.
Il faut tout de même être conscient que les nouvelles méthodes et propriétés ne sont disponibles dans les instances existantes qu’après la création d’une nouvelle instance.
De manière générale, il est fortement recommandé d’éviter d’appeler reopen
sur une classe après en avoir créé des instances.
Ember.Object
propose également une méthode reopenClass
permettant d’ajouter des variables ou méthodes de classe statiques.
-
Utiliser
reopenClass
pour ajouter une propriétécanBeRead
à la classeBook
. Afficher la valeur de cette propriété statique dans la console.> Book.reopenClass({ canBeRead: true }) > Book.canBeRead true > four = Book.create({title: 'four'}) > four.canBeRead undefined
Propriétés calculées (Computed properties
)
Les propriétés calculées (computed properties
) constituent un élément essentiel du modèle objet d’Ember.
Une propriété calculée permet de définir une propriété sous la forme d’une fonction.
Cette fonction est exécutée automatiquement lorsque l’on accède à la propriété (via un classique get('myProp')
).
Une propriété calculée est classiquement déclarée comme dépendant d’une ou plusieurs autres propriétés, permettant ainsi à Ember d’effectuer le calcul de la valeur de cette propriété au changement d’une ou plusieurs de ces propriétés dont il dépend.
-
Réouvrir la classe
Comic
pour y ajouter deux propriétéswriter
etdrawer
ainsi qu’une propriété calculéeauthors
dont la valeur correspond à la concaténation des deux propriétés précédentes séparées par' and '
. La propriété calculéeauthors
doit afficher un log d’exécution quelconque et son exécution doit dépendre des deux propriétéswriter
etdrawer
.Créer ensuite une instance de
Comic
puis accéder plusieurs fois de suite à la propriétéauthors
. Changer ensuite l’une des deux propriétéswriter
etdrawer
(via unset
) et accéder de nouveau àauthors
. Que constate-t-on lors de ces différentes opérations ? En quoi une propriété calculée est différente d’une simple méthode ?> Comic.reopen({ writer: null, drawer: null, authors: Ember.computed('writer', 'drawer', function() { console.log('computed property calculated'); return this.get('writer') + ' and ' + this.get('drawer'); }) }); > five = Comic.create({title:'five', writer: '5 writer', drawer: '5 drawer'}); five Object { title: "five", writer: "5 writer", drawer: "5 drawer", _super: ROOT(), … } > five.get('authors'); computed property calculated "5 writer and 5 drawer" > five.get('authors'); "5 writer and 5 drawer" > five.set('writer', 'new writer'); "new writer" > five.get('authors'); computed property calculated "new writer and 5 drawer" > five.get('authors'); "new writer and 5 drawer"
On constate qu’une propriété calculée est bien différente d’une fonction en ce sens qu’Ember calcule sa valeur en fonction du contexte dont elle dépend. Cette valeur n’est ensuite recalculée que si ce contexte est modifié. Dans le cas contraire, Ember se contente de renvoyer la précédente valeur mise en cache, d’où l’absence de log dans ce cas.
-
Modifier la déclaration de la propriété calculée
authors
en supprimant la dépendance aux deux propriétéswriter
etdrawer
. Réexécuter ensuite la série d’opérations précédente. Que constate-t-on ?> Comic.reopen({ writer: null, drawer: null, authors: Ember.computed(function() { console.log('computed property calculated'); return this.get('writer') + ' and ' + this.get('drawer'); }) }); > five = Comic.create({title:'five', writer: '5 writer', drawer: '5 drawer'}); five Class {title: "five", writer: "5 writer", drawer: "5 drawer", __ember1439469290671: null, __nextSuper: undefined…} > five.get('authors'); computed property calculated "5 writer and 5 drawer" > five.get('authors'); "5 writer and 5 drawer" > five.set('writer', 'new writer'); "new writer" > five.get('authors'); "5 writer and 5 drawer"
On s’aperçoit ici que la propriété n’est pas recalculée lorsque l’on change l’une des propriétés puisqu’elle ne dépend plus de ces propriétés. Ember utilisera donc toujours la valeur calculée cachée de cette propriété puisque, pour lui, celle-ci ne peut pas changer.
Enchaînement des propriétés calculées
Les propriétés calculées peuvent être chaînées les unes avec les autres, permettant ainsi de mettre automatiquement à jour une série de propriétés en cascade lors de la modification de l’une d’entre elles.
-
Réouvrir la classe
Comic
et ajouter une nouvelle propriété calculéesummary
qui retourne une concaténation du titre et des auteurs de la série lorsque l’une des propriétéstitle
ouauthors
change. Modifier ensuite la valeur de la propriétéwriter
et constater queauthors
etsummary
ont été correctement mises à jour (Ne pas oublier de redéclarerwriter
etdrawer
comme propriétés dontauthors
dépend).> Comic.reopen({ authors: Ember.computed('writer', 'drawer', function() { console.log('computed property calculated'); return this.get('writer') + ' and ' + this.get('drawer'); }), summary: Ember.computed('title', 'authors', function() { return this.get('title') + ' by ' + this.get('authors'); }) }); > five = Comic.create({title:'five', writer: '5 writer', drawer: '5 drawer'}); five Class {title: "five", writer: "5 writer", drawer: "5 drawer", __ember1439469290671: null, __nextSuper: undefined…} > five.get('summary'); computed property calculated "five by 5 writer and 5 drawer" > five.set('writer', 'new writer'); "new writer" > five.get('summary'); "five by new writer and 5 drawer"
Modification de propriétés calculées
Il est également possible de modifier une propriété calculée afin de mettre à jour en cascade les propriétés dont elle est dépendante.
Cela se fait en passant à Ember.computed
un objet javascript contenant à la fois une méthode get et une méthode set au lieu de la simple fonction utilisée précédement.
-
Réouvrir la classe
Comic
de manière à modifier la propriétéauthors
pour lui fournir un setter afin de mettre à jourwriter
etdrawer
lorsque l’on modifieauthors
. L’objectif est de permettre la séquence suivante :> five = Comic.create({title:'five', writer: '5 writer', drawer: '5 drawer'}); Object { title: "five", writer: "5 writer", drawer: "5 drawer", _super: ROOT(), … } > five.set('authors', 'Véronique and Davina'); "Véronique and Davina" > five.get('writer'); "Véronique" > five.get('drawer'); "Davina"
Comic.reopen({ authors: Ember.computed('writer', 'drawer', { get(key) { console.log('computed property calculated'); return this.get('writer') + ' and ' + this.get('drawer'); }, set(key, value) { console.log('computed property modified'); const authors = value.split(/ and /); this.set('writer', authors[0]); this.set('drawer', authors[1]); return value; } }) });
NB : Il est nécessaire d’utiliser
Ember.computed
à cause de certaines incompatibilités de syntaxe sur les navigateurs actuels.
Propriétés calculées sur les collections
Ember prévoit également que ses propriétés calculées puissent s’appuyer sur des évènements portant sur les éléments d’une collection (ajout, suppression, modification).
Cela est possible au travers de la notation myCollection.@each.myProperty
ou encore myCollection.[]
.
-
Réouvrir
Book
pour y ajouter une propriétéisPublished
par défaut à false. Créer ensuite une nouvelle classeCollection
contenant un ensemble decomic
. Enfin, créer deux nouvelles séries :> Book.reopen({ isPublished: false }); > Collection = Ember.Object.extend({ books: [] }); > two = Comic.create({title:'two', isPublished: true}); > three = Comic.create({title:'three'});
-
Réouvrir
Collection
pour y ajouter une propriété calculée permettant de compter le nombre de livres publiés au sein de la collection. Cette propriété doit être déclenchée lors de la modification de l’un des statutsisPublished
des éléments de la collectionbooks
, lors d’un ajout ou d’une suppression (books.@each.isPublished
). Cette propriété retourne le nombre de livres publiés dans la collection. Placer un log dans la fonction de manière à tracer son exécution.Créer ensuite une collection contenant les trois séries créées.
Constater que cette propriété est bien mise à jour (calculée) lorsque l’on change la valeur de la propriété
isPublished
de l’une des trois série ou lorsque l’on en supprime une. En revanche, elle n’est pas exécutée lorsque n’importe quelle autre propriété d’une série est modifiée.> Collection.reopen({ numberOfPublished: Ember.computed('books.@each.isPublished', function() { console.log("compute numberOfPublished"); return this.get('books').filterBy('isPublished', true).length; }) }); > newCollection = Collection.create({books: [one, two, three]}); Object { books: […], … } > newCollection.get('numberOfPublished'); compute numberOfPublished 1 > one.set('isPublished', true); true > newCollection.get('numberOfPublished'); compute numberOfPublished 2 > newCollection.get('books').removeAt(0); Array [ {…}, {…} ] > newCollection.get('numberOfPublished'); compute numberOfPublished 1 > two.set('writer', 'new writer'); "new writer" > newCollection.get('numberOfPublished'); 1
-
Réouvrir
Collection
pour changer les conditions de dépendance de la propriété calculée en supprimant le filtre supplémentaire sur la propriétéisPublished
(books.[]
).Créer ensuite une collection contenant les trois séries créées.
Constater que cette propriété n’est mise à jour (calculée) que lors d’un ajout ou d’une suppression dans la liste des séries. La modification d’une propriété d’un élément de la liste (quelque soit cette propriété) ne déclenche pas l’éxécution de la fonction.
> Collection.reopen({ numberOfPublished: Ember.computed('books.[]', function() { console.log("compute numberOfPublished"); return this.get('books').filterBy('isPublished', true).length; }) }); > newCollection = Collection.create({books: [one, two, three]}); Object { books: […], … } > newCollection.get('numberOfPublished'); compute numberOfPublished 2 > three.set('isPublished', true); true > newCollection.get('numberOfPublished'); 2 > newCollection.get('books').removeAt(0); Array [ {…}, {…} ] > newCollection.get('numberOfPublished'); compute numberOfPublished 2 > newCollection.get('books').removeAt(0); Array [ {…} ] > newCollection.get('numberOfPublished'); compute numberOfPublished 1
-
Modifier enfin une dernière fois
Collection
et la propriéténumberOfPublished
pour faire en sorte que la propriété soit recalculée à la fois lors de la modification d’un livre existant et lors de l’ajout ou la suppression d’un livre.> Collection.reopen({ numberOfPublished: Ember.computed('books.[]', 'books.@each.isPublished', function() { console.log("compute numberOfPublished"); return this.get('books').filterBy('isPublished', true).length; }) }); > newCollection = Collection.create({books: [one, two, three]}); Object { books: […], … } > newCollection.get('numberOfPublished'); compute numberOfPublished 3 > three.set('isPublished', false); false > newCollection.get('numberOfPublished'); compute numberOfPublished 2 > newCollection.get('books').removeAt(0); Array [ {…}, {…} ] > newCollection.get('numberOfPublished'); compute numberOfPublished 1 > newCollection.get('books').pushObject(one); Object { title: Getter & Setter, isPublished: Getter & Setter, … } > newCollection.get('numberOfPublished'); compute numberOfPublished 2
Observeurs (Observers
)
Des observeurs Ember peuvent également être déclarés sur toute propriété (y compris les propriétés calculées) et déclenchés au changement de la valeur de cette propriété.
-
Déclarer un observeur du changement de la propriété calculée
authors
. Créer une nouvelle instance deComic
et noter le moment où l’observeur est appelé.> Comic.reopen({ authorsChanged: Ember.observer('authors', function() { console.log('authors observer called'); }) }); > six = Comic.create({title:'six', writer: '6 writer', drawer: '6 drawer'}); Object { title: "six", writer: "6 writer", drawer: "6 drawer", … } > six.get('authors'); computed property calculated "6 writer and 6 drawer" > six.set('writer', 'new writer'); authors observer called "new writer"
La documentation est très complète sur le sujet et il n’est nul besoin de la paraphraser ici, je vous invite donc à vous y reporter ici. Cependant, pour résumer, il est bon de noter les points suivants :
- Les observeurs sont exécutés de manière synchrône comme on a pu le constater. Le déclenchement a eu lieu immédiatement après la modification de la propriété, avant même le calcul de la propriété calculée qui en dépend.
- Cela signifie que plusieurs modifications déclencheront plusieurs fois les observeurs de manière non optimisée.
Si l’on souhaite maîtriser d’avantage ces déclenchements, il est nécessaire de faire appel à la méthode
Ember.run.once
comme expliqué dans la documentation
Les observeurs permettent donc de déclencher des traitements (et non de recalculer des propriétés) lors du changement d’une propriété. Ils sont en particulier très utiles lorsque l’on souhaite déclencher un traitement après que le binding ait été effectué.
API Collections (Enumerables
)
Ember gère ses collections et énumérations (et nous propose de gérer les nôtres) au travers d’objets Ember.Enumerable.
Cette API s’appuie sur les opérations de l’API javascript standard (array
).
Cette API permet de gérer toutes les collections d’objets via une interface normalisée et commune et nous permet donc d’utiliser et de proposer des structures de données complètement nouvelles sans impact sur le reste de notre application.
Cette API est décrite de manière succinte ici et exhaustive ici.
RunLoop
Un autre mécanisme extrêmement important est impliqué tant dans l’optimisation du moteur de rendu que dans le calcul et la synchronisation des propriétés entre elles : la RunLoop.
Ce mécanisme est absolument central dans le fonctionnement d’Ember et s’appuie sur la micro librairie Backburner.
Dans la plupart des cas, on n’a pas à s’en préoccuper et on peut parfaitement mettre en place une application Ember complète sans interagir directement avec la RunLoop.
Il est cependant parfois nécessaire, lorsqu’on ajoute nos propres helpers
Handlebars ou nos propres composants avancés.
C’est de toutes façons essentiel d’en comprendre le fonctionnement.
Comme son nom ne l’indique pas, la RunLoop n’est pas une loop mais un ensemble de queues permettant à Ember de différer et d’organiser un certain nombre d’opérations qui seront ensuite exécutées en dépilant ces queues dans un ordre de priorité donné.
Les queues sont :
sync
: synchronisation des bindingsactions
: exécution des tâches planifiées et résolution des promisesrouterTransitions
: transitions entre routesrender
: mise à jour du DOMafterRender
: opérations devant s’exéuter après la mise à jour du DOMdestroy
: destruction des objets
C’est ce mécanisme qui permet, en quelque sorte, d’empiler les calculs de propriétés calculées lorsque les propriétés observées sont modifiées et surtout c’est grâce à ce mécanisme que le rendu n’est effectué qu’une seule fois lors de la modification d’un modèle.
Pour reprendre l’exemple de la doc officielle, si l’on a l’objet suivant :
const User = Ember.Object.extend({
firstName: null,
lastName: null,
fullName: function() {
return this.get('firstName') + ' ' + this.get('lastName');
}.property('firstName', 'lastName')
});
Et le template :
{{firstName}}
{{fullName}}
Sans la RunLoop, on exécuterait le rendu deux fois si l’on modifie successivement firstname
puis lastname
.
La RunLoop met tout ça (et plein d’autres choses) en queue et n’effectue le rendu qu’une seule et unique fois, lorsque nécessaire.
our aller plus loin, se référer à la documentation officielle et à cette présentation d’Eric Bryn.