Ember.js - Ein Überblick

Ember.js ist ein Javascript-Framework zum Erstellen von Single-Page-Apps, die sich immer größerer Beliebtheit erfreuen. Andere prominente Vertreter dieser Technologie sind Backbone.js und AngularJS. Der aus der Rails-Welt bekannte Entwickler Yehuda Katz ist der Schöpfer dieses Projekts und einer der Entwickler.

Es ist ursprünglich aus dem SproutCore-Framework entstanden und hat seit Beginn dieses Jahres den 1.0 Release-Candidate-Status. Dadurch wird es in nächster Zeit keine größeren Änderungen mehr an der API geben.

Aktuell zum Zeitpunkt der Veröffentlichung dieses Blog-Eintrags ist die Version 1.2.0. Ein Starter-Kit zum schnellen Einstieg ist auf der Ember Webseite verfügbar. Mit ember-rails ist ebenso die einfache Verwendung in der Asset-Pipeline von Rails 3.1+-Anwendungen möglich.

Empfehlenswert zum Einstieg und schnellem Testen von Code-Schnippseln ist ebenfalls JSFiddle.

Bestandteile von Ember.JS

Wenn man aus der Rails-Welt kommt, ist ein gewisses Umdenken notwendig, da Ember nicht das gewohnte MVC-Pattern von Ruby on Rails verwendet. Zu bekannten Komponenten wie den Controllern, den Models und den Views kommt zusätzlich das zentrale Konzept des Routers.

Router

Der Router ist verantwortlich für den state der Anwendung: Er sorgt dafür, dass zu einer gewissen URL ein Model geladen wird und die richtigen Templates angezeigt werden. Außerdem setzt er das content-Property des Controllers.

Für einen tieferen Einstieg in das Thema state und Routing empfehle ich den Beitrag Building Web Applications with Ember.js von Yehuda Katz von der diesjährigen HTML5DevConf.

Ein Beispiel für eine Route, die dann über /foo erreichbar wäre, sieht so aus:

App = Ember.Application.create({
  LOG_TRANSITIONS: true
});

App.Router.map(function() {
  this.route('test');
});

URLs können entweder mit den bekannten Hash-Tags # (hashchange-Event des Browsers) oder mit der History-API erzeugt werden. Das Verhalten kann über das location-Property des Routers gesteuert werden:

App.Router.reopen({
  location: 'history'
});

Routen können auch mit einer resource gruppiert und verschachtelt werden:

App.Router.map(function() {
  this.resource('posts', function(){
    this.route('about');
  })
});

Dadurch werden zwei Routen angelegt:

  • posts mit der URL /posts
  • posts.about mit der URL /posts/about

Die Pfade sind konfigurabel aber standardmässig entsprechen sie dem Namen der Route.

Ein zentrales Konzept von Ember.JS ist, dass ein verschachteltes User-Interface durch verschachtelte Routen abgebildet werden sollte. Darauf wird auch noch später in den Templates eingegangen.

Model

Das Model beinhaltet die “Nutzdaten” der Anwendung und ist für Persistenz verantwortlich. Änderungen, die beim Neubauen des DOM noch vorhanden sein müssen, sind hier vorzunehmen. Das Laden und Speichern von Models übernimmt Ember-Data. Hierzu muss ein sog. store konfiguriert werden, der je nach verwendetem Addapter festlegt, ob und wie Daten zu einer Datenquelle repliziert werden sollen.

Das Laden von Models mittels find() funktioniert fast analog zu Rails:

App.PostsRoute = Ember.Route.extend({
  model: function() {
    var store = this.get('store');

    // Posts in den Store laden
    store.find('post');
    // Nur bereits gespeicherte Posts in der Liste anzeigen
    return store.filter('post', function(post){
      return !post.get('isNew');
    });
  }
});

Model-Attribute müssen mit ihrem Typ deklariert werden. Der REST-Adapter kennt vier verschiedene Attribut-Typen: string, number, boolean und date. Diese werden dann über den REST-Adapter und die zugrunde liegende JSON-API automatisch befüllt. Ein einfaches Model mit zwei Attributen title und body sähe beispielsweise so aus:

App.Post = DS.Model.extend({
  title: DS.attr('string'),
  body: DS.attr('string')
});

Die JSON-Antwort des Servers sollte dementsprechend folgendermaßen aussehen:

{
  "post": {
    "title": "Ember.js",
    "body": "A framework for creating abitious web applications"
  }
}

Auf Attribute kann man mit get zugreifen und mit set schreiben:

post = store.find('post', 1);
post.get("title"); # -> "Ember.js"
new_title = "Hello, Ember!";
post.set("title", new_title);

Desweiteren kann man sog. computed properties definieren. Es handelt sich hierbei um Methoden, die Anhand von Attributen weitere Werte zur Verfügung stellen und diese auch mittels Bindings aktualisieren, wenn sich die referenzierten Attribute ändern. Um an das vorherige Beispiel anzuknüpfen, möchte ich beispielsweise eine Methode teaser haben, die mir den ersten Satz des Attributs body bereitstellt:

App.Post = DS.Model.extend({
  title: DS.attr('string'),
  body: DS.attr('string'),

  teaser: function() {
    var body = this.get("body");
    if(body) return body.split(".")[0]
  }.property("body")

1-zu-N-Beziehungen analog zu ActiveRecords has_many und belongs_to können ebenfalls abgebildet werden. Darauf möchte ich hier nicht näher eingehen und es soll an dieser Stelle auf die Dokumentation verwiesen werden.

Store und REST-Adapter

Der Store ist für das Laden und Speichern von Models veranwortlich. Das Verhalten kann über einen von verschiedenen Adaptern gesteuert werden. Im Momement stehen dafür durch Ember-Data der FixtureAdapter und RestAdapter zur Verfügung.

Der FixtureAdapter lädt definierte Testdaten in den Speicher, Änderungen sind demnach nicht persistent. Mit Fixtures kann eine Anwendung zunächst unabhängig von einem Backend, das später die Daten bereitstellt, entwickelt werden, daher ist er vor allem in der frühen Entwicklung hilfreich. Der RestAdapter beschreibt die JSON-Schnittstelle zu einer Backend-Anwendung (API). Wir werden uns im folgenden vor allem darauf konzentrieren.

Um den Fixture-Adapter zu verwenden, wird zunächst der Store konfiguriert:

App.Store = DS.Store.extend({
  revision: 15,
  adapter: 'App.ApplicationAdapter'
});

Fixtures in einem Model können beispielsweise so hinterlegt werden:

App.Post.FIXTURES = [
  {
    id: 1,
    title: 'Ember.js',
    body: 'A framework for creating abitious web applications'
  }
]

Controller

Controller bilden in Embers Objektmodell das Zwischenstück zwischen Templates und Models. Es ist möglich nicht-persistente (im Sinne des Adapters) Properties auf einem Model zu definieren. Wenn im Template auf ein Property (zum Beispiel title eines Artikels) zugegriffen wird, dann wird zunächst im Controller nach diesem gesucht, und falls dort nicht vorhanden, direkt im Model.

Ein Controller kann ein einzelnes Model repräsentieren oder ein Array von Models. Die beiden Superklassen heissen jeweils ObjectController und ArrayController. Das Model des Controllers muss über die Property setupController innerhalb einer Route gesetzt werden:

App.PostRoute = Ember.Route.extend({
  model: function(params){
    return this.store.find('post', params.post_id);
  },

  setupController: function(controller, post){
    controller.set('content', post);
    this.controllerFor('posts').set('activePostID', post.get('id'));
  }
});

App.PostsNewRoute = Ember.Route.extend({
  model: function() {
    return this.store.createRecord('post');
  },

  setupController: function(controller, post) {
    controller.set('content', post);
  }
});

Für diesen gängigen Fall ist es nicht notwendig das Model und den Controller in der Route festzulegen, da Ember es durch active code generation selbständig erledigt. Das Controller-Objekt wird ebenfalls standardmäßig von Ember erzeugt.

Templates

In Ember wird die Handlebars Template-Engine verwendet. Damit kann das HTML-Gerüst durch dynamische Ausdrücke erweitert werden. Handlebars-Befehle werden in geschweifte Klammern gesetzt, beispielsweise, um über alle Models eines Controllers zu iterieren (siehe model-Property im Abschnitt “Router”):

{{#each model}}
  {{model.title}}
{{/each}}

Um ein genestetes Template zu rendern, wird analog zum bekannten yield aus Rails der Ausdruck outlet verwendet:

<!-- Sidebar -->
<div class="nav">
  <ul>
  {{#each model} }
    <li>{{model.title}}</li>
  {{/each} }
  </ul>
</div>
<!-- Content -->
<div class="main">
 { {outlet} }
</div>

Das würde den folgenden Routen entsprechen:

App.Router.map(function() {
  this.resource('posts', function(){
    this.resource('post', { path: ':post_id' });
  })
});

Und damit dem folgenden Layout des Interfaces:

+---------------------------------------------------------------+
|                        +                                      |
|                        |                                      |
|   Post-Liste           |       Einzelner Post                 |
|                        |                                      |
|   (posts-Controller)   |       (post-Controller)              |
|                        |                                      |
|   /posts               |       /posts/1                       |
|                        |                                      |
|                        |                                      |
|                        +                                      |
+---------------------------------------------------------------+

Views und data bindings

Views werden verwendet, um Events (Klicken, etc.) an Controller-Events zu delegieren und wiederverwendbare UI-Bestandteile zu erzeugen.

Zwischen Models und Views (das ist die übliche Konstellation, aber es geht im Prinzip zwischen allen Objekten) sorgen automatische Bindings von Ember dafür, dass verknüpfte Properties sich automatisch aktualiseren, wie in folgendem Beispiel. Wird im Controller ein Model mit dem Attribut name instaniziert oder geladen, dann sorgt folgender Template-Snippet dafür, dass das Attribut bereits beim Schreiben bereits aktualisiert wird.