ASP.NET

Single Page Applications: Erstellen moderner und reaktionsfähiger Web-Apps mit ASP.NET

Mike Wasson

Beispielcode herunterladen.

Single Page Applications (SPAs) sind Web-Apps, die eine einzelne HTML-Seite laden und diese dynamisch aktualisieren, während der Benutzer mit der App interagiert.

SPAs verwenden AJAX und HTML5, um fließende und reaktionsfreudige Apps zu erstellen, ohne dass Seiten permanent erneut geladen werden. Das bedeutet jedoch, dass ein Großteil der Arbeit auf der Clientseite stattfindet, in JavaScript. Für den traditionellen ASP.NET-Entwickler kann dieser Unterschied ein Problem darstellen. Glücklicherweise gibt es viele Open-Source-JavaScript-Frameworks, mit denen SPAs einfacher erstellt werden können.

In diesem Artikel erläutere ich das Erstellen einer einfachen SPA-App. Dabei stelle ich einige grundlegende Konzepte für das Erstellen von SPAs vor. Dazu gehören Muster für Model-View-Controller (MVC) und Model-View-ViewModel (MVVM), Datenbindung und Routing.

Über die Beispiel-App

Bei der von mir erstellten Beispiel-App handelt es sich um eine einfache Filmdatenbank (siehe Abbildung 1). Ganz links außen finden Sie eine Liste der Genres. Durch Klicken auf ein Genre wird eine Liste von Filmen angezeigt, die dem Genre angehören. Wenn Sie auf die Schaltfläche „Edit“ neben einem Eintrag klicken, können Sie den Eintrag ändern. Klicken Sie auf die Schaltfläche „Save“, nachdem Sie die Änderungen vorgenommen haben, um das Update an den Server zu senden, oder klicken Sie auf „Cancel“, um die Änderungen rückgängig zu machen.

The Single-Page Application Movie Database App
Abbildung 1: Filmdatenbank-App mit Single Page Application

Ich habe zwei verschiedene Versionen der App erstellt. Eine Version verwendet die Knockout.js-Bibliothek und die andere die Ember.js-Bibliothek. Diese beiden Bibliotheken basieren auf unterschiedlichen Konzepten. Folglich ist es lehrreich, sie zu vergleichen. In beiden Fällen enthielt die Client-App weniger als 150 Zeilen JavaScript. Auf Serverseite habe ich die ASP.NET-Web-API verwendet, um JSON für den Client bereitzustellen. Sie finden den Quellcode für beide Versionen der App github.com/MikeWasson/MoviesSPA.

Hinweis: Ich habe die App mithilfe der Release Candidate-Version (RC) von Visual Studio 2013 erstellt. Möglicherweise gibt es einige Änderungen an der zur Produktion freigegebenen (RTM-)Version, sie sollten aber keine Auswirkungen auf den Code haben.)

Hintergrund

In einer herkömmlichen Web-App rendert der Server jedes Mal eine neue HTML-Seite, wenn er von der App aufgerufen wird. Dadurch wird im Browser eine Seitenaktualisierung ausgelöst. Wenn Sie jemals eine Web Forms-Anwendung oder eine PHP-Anwendung geschrieben haben, kommt Ihnen dieser Seitenlebenszyklus sicherlich bekannt vor.

In einer SPA finden nach dem Laden der ersten Seite alle Interaktionen mit dem Server über AJAX-Aufrufe statt. Diese AJAX-Aufrufe geben Daten – kein Markup – zurück, normalerweise im JSON-Format. Die App aktualisiert die Seite mithilfe der JSON-Daten dynamisch, ohne sie erneut zu laden. In Abbildung 2 wird der Unterschied zwischen den beiden Herangehensweisen illustriert.

The Traditional Page Lifecycle vs. the SPA Lifecycle
Abbildung 2: Herkömmlicher Seitenlebenszyklus im Vergleich zum SPA-Lebenszyklus

Ein Vorteil von SPAs sticht sofort ins Auge: Die Anwendungen sind ohne die Beeinträchtigungen durch das erneute Laden und Rendering der Seite fließender und reaktionsfähiger. Ein weiterer Vorteil ist möglicherweise nicht so offensichtlich und betrifft die Art und Weise, wie Sie eine Web-App konzipieren. Wenn die App-Daten als JSON gesendet werden, wird eine Trennung zwischen der Präsentationslogik (HTML-Markup) und der Anwendungslogik (AJAX-Anforderungen plus JSON-Reaktionen) erzeugt.

Aufgrund dieser Trennung ist es einfacher, jede Schicht zu entwerfen und weiterzuentwickeln. In einer gut strukturierten SPA können Sie das HTML-Markup ändern, ohne den Code anzurühren, der die Anwendungslogik implementiert (zumindest idealerweise). Wenn ich unten die Datenbindung bespreche, zeige ich dies in Aktion.

In einer reinen SPA findet die gesamte UI-Interaktion auf der Clientseite über JavaScript und CSS statt. Nach dem ersten Laden der Seite agiert der Server als reine Dienstschicht. Der Client muss nur die Anweisung erhalten, welche HTTP-Anforderungen gesendet werden sollen. Dabei ist es unerheblich, wie der Server die Implementierungen am Back-End durchführt.

Bei dieser Architektur sind der Client und der Dienst unabhängig. Sie könnten das gesamte Back-End, das den Dienst ausführt, ersetzen. Solange Sie die API nicht ändern, beschädigen Sie den Client nicht. Auch das Gegenteil trifft zu: Sie können die gesamte Client-App ersetzen, ohne die Dienstschicht zu ändern. Zum Beispiel könnten Sie einen systemeigenen mobilen Client schreiben, der den Dienst nutzt.

Erstellen des Visual Studio-Projekts

Visual Studio 2013 besitzt einen einzelnen ASP.NET-Webanwendungs-Projekttyp. Sie können mit dem Projekt-Assistenten die ASP.NET-Komponenten auswählen, die Ihrem Projekt hinzugefügt werden sollen. Ich habe mit der Vorlage „Leer“ angefangen und dann dem Projekt die ASP.NET-Web-API hinzugefügt, indem ich unter „Ordner und Kernreferenzen hinzufügen für“ das Kontrollkästchen die Option „Web-API“ aktiviert habe (siehe Abbildung 3).

Creating a New ASP.NET Project in Visual Studio 2013
Abbildung 3: Erstellen eines neuen ASP.NET-Projekts in Visual Studio 2013

Das neue Projekt verfügt über alle für die Web-API erforderlichen Bibliotheken sowie Web API-Konfigurationscode. Ich habe keinen Bezug zu Web Forms oder ASP.NET MVC hergestellt.

Beachten Sie in Abbildung 3, dass Visual Studio 2013 die Vorlage „Single Page Application“ enthält. Mit dieser Vorlage wird eine Gerüst-SPA auf der Grundlage von „Knockout.js” installiert. Sie unterstützt die Anmeldung mithilfe einer Mitgliedschaftsdatenbank oder eines externen Authentifizierungsanbieters. Ich habe die Vorlage in meiner App nicht verwendet, weil ich ein einfacheres Beispiel von Grund auf zeigen wollte. Die SPA-Vorlage ist dennoch eine großartige Ressource, insbesondere, wenn Sie Ihrer App Authentifizierung hinzufügen möchten.     

Erstellen der Dienstschicht

Ich habe die ASP.NET-Web-API verwendet, um eine einfache REST-API für die App zu erstellen. An dieser Stelle möchte ich die Web-API nicht detailliert erläutern. Bei Bedarf finden Sie weitere Informationen unter „asp.net/web-api”.

Als Erstes habe ich eine Movie-Klasse erstellt, die einen Film darstellt. Diese Klasse kann zweierlei:

  • Anweisungen an Entity Framework (EF) erteilen, wie die Datenbanktabellen erstellt werden, in denen die Filmdaten gespeichert werden
  • Anweisung an die Web-API erteilen, wie die JSON-Nutzlast formatiert wird

Sie müssen für beide Situationen nicht dasselbe Modell verwenden. Beispielsweise möchten Sie vielleicht, dass sich Ihr Datenbankschema von den JSON-Nutzlasten unterscheidet. Für diese App habe ich alles möglichst einfach gehalten:

namespace MoviesSPA.Models
{
  public class Movie
  {
    public int ID { get; set; }
    public string Title { get; set; }
    public int Year { get; set; }
    public string Genre { get; set; }
    public string Rating { get; set; }
  }
}

Als Nächstes habe ich ein Visual Studio-Gerüst verwendet, um einen Web-API-Controller zu erstellen, der EF als Datenschicht verwendet. Um das Gerüst zu verwenden, klicken Sie im Projektmappen-Explorer mit der rechten Maustaste auf den Ordner „Controller“, und wählen Sie „Hinzufügen“ | „Neues Gerüst für das Element“ aus. Wählen Sie im Assistenten „Gerüst hinzufügen“ die Option „Web API 2-Controller mit Aktionen, Verwenden von Entity Framework“ aus (siehe Abbildung 4).

Adding a Web API Controller
Abbildung 4: Hinzufügen eines Web-API-Controllers

Abbildung 5 zeigt den Assistenten „Controller hinzufügen“. Ich habe ihn „MoviesController“ genannt. Der Name ist wichtig, weil die URIs für die REST-API auf dem Controllernamen basieren. Zudem habe ich „Async-Controlleraktionen verwenden” aktiviert, um das neue Async-Feature in EF 6 zu nutzen. Außerdem habe ich die Movie-Klasse für das Modell ausgewählt und „Neuer Datenkontext” ausgewählt, um einen neuen EF-Datenkontentext zu erstellen.

The Add Controller Wizard
Abbildung 5: Assistent „Controller hinzufügen“

Der Assistent fügt zwei Dateien hinzu:

  • „MoviesController.cs“ definiert den Web-API-Controller, der die REST-API für die App implementiert.
  • „MovieSPAContext.cs” ist grundlegend EF-Kitt, der Methoden zur Abfrage der zugrunde liegenden Datenbank bietet.

Abbildung 6 zeigt die standardmäßige REST-API, die durch das Gerüst erstellt wird.

Abbildung 6: Standardmäßige REST-API, die durch das Web-API-Gerüst erstellt wird

HTTP-Verb URI Beschreibung
GET /api/movies Liste aller Filme abrufen
GET /api/movies/{id} Film mit ID = {id} abrufen
PUT /api/movies/{id} Film mit ID = {id} aktualisieren
POST /api/movies Neuen Film zur Datenbank hinzufügen
DELETE /api/movies/{id} Film aus der Datenbank entfernen

Bei Werten in geschweiften Klammern handelt es sich um Platzhalter. Um zum Beispiel einen Film mit einer ID = 5 abzurufen, lautet der URI „/api/movies/5”.

Ich habe diese API erweitert, indem ich eine Methode hinzugefügt habe, die alle Filme in einem bestimmten Genre sucht:

public class MoviesController : ApiController
{
  public IQueryable<Movie> GetMoviesByGenre(string genre)
  {
    return db.Movies.Where(m =>
      m.Genre.Equals(genre, StringComparison.OrdinalIgnoreCase));
  }
  // Other code not shown

Der Client fügt das Genre in die Abfragezeichenfolge des URI ein. Um beispielsweise alle Filme im Genre „Drama“ abzurufen, sendet der Client eine GET-Anforderung an „/api/movies?genre=drama”. Die Web-API bindet den Queryparameter automatisch an den Genreparameter in der GetMoviesByGenre-Methode.

Erstellen des Webclients

Bis zu diesem Punkt habe ich eine REST-API erstellt. Wenn Sie eine GET-Anforderung an „/api/movies?genre=drama” senden, sieht die rohe HTTP-Antwort folgendermaßen aus:

HTTP/1.1 200 OK
Cache-Control: no-cache
Pragma: no-cache
Content-Type: application/json; charset=utf-8
Date: Tue, 10 Sep 2013 15:20:59 GMT
Content-Length: 240
[{"ID":5,"Title":"Forgotten Doors","Year":2009,"Genre":"Drama","Rating":"R"}, {"ID":6,"Title":"Blue Moon June","Year":1998,"Genre":"Drama","Rating":"PG-13"},{"ID":7,"Title":"The Edge of the Sun","Year":1977,"Genre":"Drama","Rating":"PG-13"}]

Nun muss ich eine Client-App schreiben, die dies in etwas Sinnvolles umsetzt. So sieht der grundlegende Workflow aus:

  • UI löst eine AJAX-Anforderung aus.
  • Der HTML-Code wird aktualisiert, um die Nutzlast der Antwort anzuzeigen.
  • AJAX-Fehler werden verarbeitet.

Sie könnten all dies manuell codieren. Hier ist zum Beispiel jQuery-Code, der eine Liste von Filmtiteln erstellt:

$.getJSON(url)
  .done(function (data) {
    // On success, "data" contains a list of movies
    var ul = $("<ul></ul>")
    $.each(data, function (key, item) {
      // Add a list item
      $('<li>', { text: item.Title }).appendTo(ul);
    });
  $('#movies').html(ul);
});

Der Code weist ein paar Probleme auf. Er vermischt Anwendungslogik mit Präsentationslogik und ist eng an Ihre HTML gebunden. Außerdem ist das Schreiben aufwendig. Anstatt sich auf Ihre App zu konzentrieren, verbringen Sie Ihre Zeit damit, Ereignishandler und Code zum Ändern des DOM zu schreiben.

Die Lösung besteht darin, auf dem JavaScript-Framework aufzubauen. Glücklicherweise können Sie aus vielen Open-Source-JavaScript-Frameworks auswählen. Zu den beliebtesten gehören Backbone, Angular, Ember, Knockout, Dojo und JavaScriptMVC. Die meisten verwenden Varianten der MVC- oder MVVM-Muster. Daher empfiehlt es sich, diese Muster näher zu betrachten.

MVC- und MVVM-Muster

Das MVC-Muster geht bis in die 80er Jahre und die Anfänge grafischer Benutzeroberflächen zurück. Das Ziel von MVC besteht darin, den Code drei separaten Verantwortlichkeiten hinzuzufügen (siehe Abbildung 7). Die Verantwortung ist folgendermaßen aufgeteilt:

  • Das Modell repräsentiert die Domänendaten und die Geschäftslogik.
  • Die Ansicht zeigt das Modell an.
  • Der Controller empfängt Benutzereingaben und aktualisiert das Modell.

The MVC Pattern
Abbildung 7: Das MVC-Muster

Eine neuere Variante von MVC ist das MVVM-Muster (siehe Abbildung 8). In MVVM:

  • Das Modell stellt weiterhin die Domänendaten dar.
  • Das Ansichtsmodell ist eine abstrakte Darstellung der Ansicht.
  • Die Ansicht zeigt das Ansichtsmodell und sendet Benutzereingaben an das Ansichtsmodell.

The MVVM Pattern
Abbildung 8: Das MVVM-Muster

In einem JavaScript-MVVM-Framework ist die Ansicht Markup, und das Ansichtsmodell ist Code.

MVC besitzt viele Varianten und wird in der Dokumentation häufig verwirrend und widersprüchlich dargestellt. Das überrascht vielleicht nicht bei einem Entwurfsmuster, das mit Smalltalk-76 begann und noch heute in modernen Web-Apps verwendet wird. Auch wenn es nützlich ist, die Theorie zu kennen, ist es am wichtigsten, das bestimmte MVC-Framework zu verstehen, das Sie verwenden.

Erstellen des Webclients mit „Knockout.js”

Für die erste Version meiner App habe ich die Knockout.js-Bibliothek verwendet. Knockout befolgt das MVVM-Muster und verwendet Datenbindung, um eine Verbindung zwischen der Ansicht und dem Ansichtsmodell herzustellen.

Um Datenbindungen zu erstellen, fügen Sie HTML-Elementen ein spezielles Datenbindungsattribut hinzu. Im folgenden Markup wird beispielsweise das span-Element an eine Eigenschaft mit dem Namen „genre” im Ansichtsmodell gebunden. Wenn sich der Wert von „genre” ändert, aktualisiert Knockout automatisch die HTML:

<h1><span data-bind="text: genre"></span></h1>

Bindungen können auch in die andere Richtung funktionieren. Wenn zum Beispiel der Benutzer in ein Textfeld Text eingibt, aktualisiert Knockout die entsprechende Eigenschaft im Ansichtsmodell.

Von Vorteil ist, dass die Datenbindung deklarativ ist. Sie müssen das Ansichtsmodell nicht an die HTML-Seitenelemente binden. Fügen Sie nur das Datenbindungsattribut hinzu, Knockout kümmert sich um den Rest.

Ich habe als Erstes eine HTML-Seite mit dem grundlegenden Layout ohne Datenbindung erstellt, wie in Abbildung 9 gezeigt.

(Hinweis: Ich habe die Bootstrap-Bibliothek verwendet, um die App zu formatieren. Folglich besitzt die App viele zusätzliche <div>-Elemente und CSS-Klassen, die die Formatierung steuern. In den Codebeispielen habe ich diese aus Gründen der Übersichtlichkeit ausgelassen.

Abbildung 9: Anfängliches HTML-Layout

<!DOCTYPE html>
<html>
<head>
  <title>Movies SPA</title>
</head>
<body>
  <ul>
    <li><a href="#"><!-- Genre --></a></li>
  </ul>
  <table>
    <thead>
      <tr><th>Title</th><th>Year</th><th>Rating</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><!-- Title --></td>
        <td><!-- Year --></td>
        <td><!-- Rating --></td></tr>
    </tbody>
  </table>
  <p><!-- Error message --></p>
  <p>No records found.</p>
</body>
</html>

Erstellen des Ansichtsmodells

„Observables” bilden den Kern des Knockout-Datenbindungssystems. Ein „observable” ist ein Objekt, das einen Wert speichert und Abonnenten benachrichtigen kann, wenn sich der Wert ändert. Der folgende Code konvertiert die JSON-Darstellung eines Films in das entsprechende Objekt mit „observables”:

function movie(data) {
  var self = this;
  data = data || {};
  // Data from model
  self.ID = data.ID;
  self.Title = ko.observable(data.Title);
  self.Year = ko.observable(data.Year);
  self.Rating = ko.observable(data.Rating);
  self.Genre = ko.observable(data.Genre);
};

In Abbildung 10 wird meine anfängliche Implementierung des Ansichtsmodells veranschaulicht. Diese Version unterstützt nur das Abrufen der Filmliste. Die Bearbeitungsfeatures füge ich später hinzu. Das Ansichtsmodell enthält „observables” für die Filmliste, eine Fehlerzeichenfolge und das aktuelle Genre.

Abbildung 10: Das Ansichtsmodell

var ViewModel = function () {           
  var self = this;
  // View model observables
  self.movies = ko.observableArray();
  self.error = ko.observable();
  self.genre = ko.observable();  // Genre the user is currently browsing
  // Available genres
  self.genres = ['Action', 'Drama', 'Fantasy', 'Horror', 'Romantic Comedy'];
  // Adds a JSON array of movies to the view model
  function addMovies(data) {
    var mapped = ko.utils.arrayMap(data, function (item) {
      return new movie(item);
    });
    self.movies(mapped);
  }
  // Callback for error responses from the server
  function onError(error) {
    self.error('Error: ' + error.status + ' ' + error.statusText);
  }
  // Fetches a list of movies by genre and updates the view model
  self.getByGenre = function (genre) {
    self.error(''); // Clear the error
    self.genre(genre);
    app.service.byGenre(genre).then(addMovies, onError);
  };
  // Initialize the app by getting the first genre
  self.getByGenre(self.genres[0]);
}
// Create the view model instance and pass it to Knockout
ko.applyBindings(new ViewModel());

Beachten Sie, dass „movies” ein „observableArray” ist. Wie der Name nahelegt, dient ein „observableArray” als Array, das Abonnenten informiert, wenn sich der Arrayinhalt ändert.

Die getByGenre-Funktion fordert mit der AJAX-Anforderung vom Server die Filmliste an und füllt das self.movies-Array mit den Ergebnissen auf.

Beim Verwenden einer REST-API gehört die Behandlung der asynchronen Art von HTTP zu den schwierigsten Bereichen. Die jQuery-Funktion „ajax” gibt ein Objekt zurück, das die Promises-API implementiert. Sie können die then-Methode eines Promise-Objekts verwenden, um einen Rückruf festzulegen, der aufgerufen wird, wenn der AJAX-Aufruf erfolgreich abgeschlossen wird, und einen anderen Rückruf, der aufgerufen wird, wenn der AJAX-Aufruf Fehler aufweist.

app.service.byGenre(genre).then(addMovies, onError);

Datenbindungen

Mit meinem neuen Ansichtsmodell kann ich nun eine Datenbindung der HTML an das Modell vornehmen. Für die Genreliste, die am linken Rand des Bildschirms angezeigt wird, habe ich folgende Datenbindungen verwendet:

<ul data-bind="foreach: genres">
  <li><a href="#"><span data-bind="text: $data"></span></a></li>
</ul>

Das Datenbindungsattribut enthält eine oder mehrere Datenbindungsdeklarationen, wobei jede Bindung das Format „Bindung: Ausdruck” enthält. In diesem Beispiel weist die foreach-Bindung Knockout an, die Inhalte des genres-Arrays im Ansichtsmodell zu durchlaufen. Knockout erstellt für jedes Element im Array ein neues <li>-Element. Die Textbindung in <span> stellt den span-Text gleich dem Wert des Array-Elements ein. In diesem Fall entspricht dies dem Namen des Genres.

Jetzt bewirkt ein Klicken auf die Genrenamen noch nichts. Daher habe ich zum Behandeln von Click-Ereignissen eine Klickbindung hinzugefügt:

<li><a href="#" data-bind="click: $parent.getByGenre">
  <span data-bind="text: $data"></span></a></li>

Dadurch wird das Click-Ereignis an die getByGenre-Funktion im Ansichtsmodell gebunden. Ich musste hier „$parent” verwenden, weil diese Bindung im Kontext von „foreach” auftritt. Standardmäßig beziehen sich Bindungen mit „foreach” auf ein aktuelles Element in der Schleife.

Um die Filmliste anzuzeigen, habe ich der Tabelle Bindungen hinzugefügt (siehe Abbildung 11).

Abbildung 11: Hinzufügen von Bindungen zur Tabelle, um eine Filmliste anzuzeigen

<table data-bind="visible: movies().length > 0">
  <thead>
    <tr><th>Title</th><th>Year</th><th>Rating</th><th></th></tr>
  </thead>
  <tbody data-bind="foreach: movies">
    <tr>
      <td><span data-bind="text: Title"></span></td>
      <td><span data-bind="text: Year"></span></td>
      <td><span data-bind="text: Rating"></span></td>
      <td><!-- Edit button will go here --></td>
    </tr>
  </tbody>
</table>

In Abbildung 11 durchläuft die foreach-Bindung ein Array von movie-Objekten. Die Textbindungen verweisen innerhalb von „foreach” auf Eigenschaften im aktuellen Objekt.

Die sichtbare Bindung im <table>-Element steuert, ob die Tabelle gerendert wird. Dadurch wird die Tabelle ausgeblendet, wenn das movies-Array leer ist.

Im Folgenden sehen Sie schließlich die Bindungen für die Fehlermeldung und die Meldung „No records found” (beachten Sie, dass Sie komplexe Ausdrücke in eine Bindung einfügen können):

<p data-bind="visible: error, text: error"></p>
<p data-bind="visible: !error() && movies().length == 0">No records found.</p>

Datensätze bearbeitbar machen

Im letzten Teil dieser App wird dem Benutzer die Möglichkeit gegeben, die Datensätze in der Tabelle zu bearbeiten. Daran sind mehrere Funktionen beteiligt:

  • Umschalten zwischen Anzeigemodus (Nur-Text) und Bearbeitungsmodus (Eingabesteuerelemente)
  • Senden von Updates an den Server
  • Zulassen, dass der Benutzer eine Bearbeitung abbricht und die Anfangsdaten wiederherstellt

Zum Nachverfolgen des Anzeige-/Bearbeitungsmodus, habe ich dem movie-Objekt ein boolesches Flag als „observable” hinzugefügt:

function movie(data) {
  // Other properties not shown
  self.editing = ko.observable(false);
};

Ich wollte, dass in der Tabelle mit den Filmen Text angezeigt wird, wenn die editing-Eigenschaft „false” ist. Bei „true” soll jedoch zu den Eingabesteuerelementen gewechselt werden. Hierfür habe ich die if- und ifnot-Bindungen von Knockout verwendet (siehe Abbildung 12). Mit der „<!-- ko -->”-Syntax können Sie if- und ifnot-Bindungen hinzufügen, ohne sie in einem HTML-Containerelement zu platzieren.

Abbildung 12: Bearbeiten von Filmdatensätzen ermöglichen

<tr>
  <!-- ko if: editing -->
  <td><input data-bind="value: Title" /></td>
  <td><input type="number" class="input-small" data-bind="value: Year" /></td>
  <td><select class="input-small"
    data-bind="options: $parent.ratings, value: Rating"></select></td>
  <td>
    <button class="btn" data-bind="click: $parent.save">Save</button>
    <button class="btn" data-bind="click: $parent.cancel">Cancel</button>
  </td>
  <!-- /ko -->
  <!-- ko ifnot: editing -->
  <td><span data-bind="text: Title"></span></td>
  <td><span data-bind="text: Year"></span></td>
  <td><span data-bind="text: Rating"></span></td>
  <td><button class="btn" data-bind="click: $parent.edit">Edit</button></td>
  <!-- /ko -->
</tr>

Die Wertbindung legt den Wert eines Eingabesteuerelements fest. Dabei handelt es sich um eine bidirektionale Bindung. Wenn der Benutzer also etwas in das Textfeld eingibt oder die Dropdownauswahl ändert, wird die Änderung automatisch an das Ansichtsmodell weitergegeben.

Ich habe die Click-Ereignishandler an Funktionen mit der Bezeichnung „save”, „cancel” und „edit” im Ansichtsmodell gebunden.

Die edit-Funktion ist einfach. Setzen Sie einfach das editing-Flag auf „true”:

self.edit = function (item) {
  item.editing(true);
};

Die Funktionen „save” und „cancel” verhielten sich etwas komplizierter. Um „cancel” zu unterstützen, musste ich den ursprünglichen Wert während der Bearbeitung zwischenspeichern. Glücklicherweise macht es Knockout einfach, das Verhalten von „observables” zu erweitern. Der Code in Abbildung 13 fügt der observable-Klasse eine store-Funktion hinzu. Durch das Aufrufen der store-Funktion für ein „observable” erhält das „observable” zwei neue Funktionen: „revert” und „commit”.

Abbildung 13: Erweitern von „ko.observable” mit „revert” und „commit”

Jetzt kann ich die store-Funktion aufrufen und dem Modell diese Funktion hinzufügen:

function movie(data) {
  // ...
  // New code:
  self.Title = ko.observable(data.Title).store();
  self.Year = ko.observable(data.Year).store();
  self.Rating = ko.observable(data.Rating).store();
  self.Genre = ko.observable(data.Genre).store();
};

Abbildung 14 zeigt die save- und die cancel-Funktion im Ansichtsmodell.

Abbildung 14: Hinzufügen der Funktionen „save” und „cancel”

self.cancel = function (item) {
  revertChanges(item);
  item.editing(false);
};
self.save = function (item) {
  app.service.update(item).then(
    function () {
      commitChanges(item);
    },
    function (error) {
      onError(error);
      revertChanges(item);
    }).always(function () {
      item.editing(false);
  });
}
function commitChanges(item) {
  for (var prop in item) {
    if (item.hasOwnProperty(prop) && item[prop].commit) {
      item[prop].commit();
    }
  }
}
function revertChanges(item) {
  for (var prop in item) {
    if (item.hasOwnProperty(prop) && item[prop].revert) {
      item[prop].revert();
    }
  }
}

Erstellen des Webclients mit Ember

Für den Vergleich habe ich eine weitere Version meiner App mithilfe der Ember.js-Bibliothek geschrieben.

Eine Ember-App beginnt mit einer Routingtabelle, in der definiert wird, wie der Benutzer durch die App navigiert:

window.App = Ember.Application.create();
App.Router.map(function () {
  this.route('about');
  this.resource('genres', function () {
    this.route('movies', { path: '/:genre_name' });
  });
});

In der ersten Codezeile wird eine Ember-Anwendung erstellt. Durch den Aufruf von „Router.map” werden drei Routen erstellt. Jede Route entspricht einem URI oder einem URI-Muster:

/#/about
/#/genres
/#/genres/genre_name

Für jede Route erstellen Sie mithilfe der Vorlagenbibliothek „Handlebars” eine HTML-Vorlage.

Ember besitzt eine Vorlage auf oberster Ebene für die gesamte App. Diese Vorlage wird für jede Route gerendert. Abbildung 15 zeigt die Anwendungsvorlage für meine App. Wie Sie sehen, besteht die Vorlage grundlegend aus in einem Scripttag platzierter HTML mit type=“text/x-handlebars.” Die Vorlage enthält spezielles Handlebars-Markup in doppelten geschweiften Klammern: {{ }}. Dieses Markup dient ähnlichen Zwecken wie das datengebundene Attribut in Knockout. Beispielsweise erstellt „{{#linkTo}}” eine Verknüpfung für eine Route.

Abbildung 15: Die Handlebars-Vorlage auf Anwendungsebene

ko.observable.fn.store = function () {
  var self = this;
  var oldValue = self();
  var observable = ko.computed({
    read: function () {
      return self();
    },
    write: function (value) {
      oldValue = self();
      self(value);
    }
  });
  this.revert = function () {
    self(oldValue);
  }
  this.commit = function () {
    oldValue = self();
  }
  return this;
}
<script type="text/x-handlebars" data-template-name="application">
  <div class="container">
    <div class="page-header">
      <h1>Movies</h1>
    </div>
    <div class="well">
      <div class="navbar navbar-static-top">
        <div class="navbar-inner">
          <ul class="nav nav-tabs">
            <li>{{#linkTo 'genres'}}Genres{{/linkTo}} </li>
            <li>{{#linkTo 'about'}}About{{/linkTo}} </li>
          </ul>
        </div>
      </div>
    </div>
    <div class="container">
      <div class="row">{{outlet}}</div>
    </div>
  </div>
  <div class="container"><p>&copy;2013 Mike Wasson</p></div>
</script>

Wenn nun der Benutzer zu „/#/about” navigiert, wird die Route „about” aufgerufen. Ember rendert zunächst die Anwendungsvorlage auf oberster Ebene. Dann wird die about-Vorlage in „{{outlet}}” der Anwendungsvorlage gerendert. Hier ist die about-Vorlage:

 

<script type="text/x-handlebars" data-template-name="about">
  <h2>Movies App</h2>
  <h3>About this app...</h3>
</script>

Abbildung 16 zeigt, wie die about-Vorlage in der Anwendungsvorlage gerendert wird.

Rendering the About Template
Abbildung 16: Rendern der about-Vorlage

Da jede Route einen eigenen URI besitzt, wird der Browserverlauf beibehalten. Der Benutzer kann mit der Schaltfläche für „Zurück” navigieren. Der Benutzer kann auch die Seite aktualisieren, ohne den Kontext zu verlieren, und für dieselbe Seite ein Lesezeichen setzen und sie neu laden.

Ember-Controller und -Modelle

In Ember besitzt jede Route ein Modell und einen Controller. Das Modell enthält die Domänendaten. Der Controller fungiert für das Modell als Proxy und speichert die Anwendungsstatusdaten für die Ansicht. (Dies entspricht nicht exakt der klassischen Definition von MVC. Der Controller ist auf eine Art eher mit einem Ansichtsmodell zu vergleichen.)

Im Folgenden finden Sie meine Definition des movie-Modells:

App.Movie = DS.Model.extend({
  Title: DS.attr(),
  Genre: DS.attr(),
  Year: DS.attr(),
  Rating: DS.attr(),
});

Der Controller wird von „Ember.ObjectController” abgeleitet (siehe Abbildung 17).

Abbildung 17: Der Movie-Controller wird von „Ember.ObjectController” abgeleitet

App.MovieController = Ember.ObjectController.extend({
  isEditing: false,
  actions: {
    edit: function () {
      this.set('isEditing', true);
    },
    save: function () {
      this.content.save();
      this.set('isEditing', false);
    },
    cancel: function () {
      this.set('isEditing', false);
      this.content.rollback();
    }
  }
});

Es finden einige bemerkenswerte Dinge statt. 1. Ich habe das Modell in der controller-Klasse nicht angegeben. Die Route legt standardmäßig automatisch das Modell im Controller fest. 2. Die Funktionen „save” und „cancel” verwenden die in die DS.Model-Klasse integrierten Transaktionsfeatures. Um Bearbeitungen rückgängig zu machen, können Sie die Rollbackfunktion im Modell aufrufen.

Ember verwendet viele Benennungskonventionen, um mit unterschiedlichen Komponenten Verbindungen herzustellen. Die genres-Route kommuniziert mit „GenresController”, wodurch die genres-Vorlage gerendert wird. Tatsächlich erstellt Ember automatisch ein GenresController-Objekt, wenn Sie keines definieren. Sie können jedoch die Standardeinstellungen überschreiben.

In meiner App habe ich die Route „genres” bzw. „movies” so konfiguriert, dass sie durch Implementierung der renderTemplate-Verknüpfung einen anderen Controller verwenden. Auf diese Weise können verschiedene Routen denselben Controller verwenden (siehe Abbildung 18).

Abbildung 18: Verschiedene Routen können denselben Controller verwenden

App.GenresMoviesRoute = Ember.Route.extend({
  serialize: function (model) {
    return { genre_name: model.get('name') };
  },
  renderTemplate: function () {
    this.render({ controller: 'movies' });
  },
  afterModel: function (genre) {
    var controller = this.controllerFor('movies');
    var store = controller.store;
    return store.findQuery('movie', { genre: genre.get('name') })
    .then(function (data) {
      controller.set('model', data);
  });
  }
});

Das Praktische an Ember ist, dass für die Arbeiten nur sehr wenig Code erforderlich ist. Meine Beispiel-App enthält etwa 110 Zeilen JavaScript. Sie ist kürzer als die Knockout-Version, und ich erhalte den Browserverlauf ohne weiteren Aufwand dazu. Andererseits ist Ember auch ein sehr „eigenwilliges” Framework. Wenn Sie Code nicht nach den „Prinzipien von Ember” schreiben, stoßen Sie sicherlich auf einige Hindernisse. Sie müssen beim Wählen eines Frameworks überlegen, ob der Satz an Features und der Gesamtentwurf des Frameworks Ihren Anforderungen und Ihrem Programmierstil entsprechen.

Weitere Informationen

In diesem Artikel habe ich gezeigt, wie JavaScript-Frameworks das Erstellen von SPAs erleichtern. Dabei habe ich ein paar gängige Features der Bibliotheken vorgestellt, einschließlich Datenbindung, Routing und MVC- und MVVM-Mustern. Sie erhalten weitere Informationen zum Erstellen von SPAs mit ASP.NET unter asp.net/single-page-application.

Mike Wasson ist Programmierer und Autor bei Microsoft. Jahrelang hat er die Win32-Multimedia-APIs dokumentiert. Zurzeit schreibt er über ASP.NET, mit dem Schwerpunkt auf Web API. Er ist zu erreichen unter mwasson@microsoft.com.

Unser Dank gilt dem folgenden technischen Experten für die Durchsicht dieses Artikels: Xinyang Qiu (Microsoft)
Xinyang Qiu ist leitender Software Design Engineer in Test im Microsoft ASP.NET-Team und aktiver Blogger für blogs.msdn.com/b/webdev. Er beantwortet gerne Ihre Fragen zu ASP.NET oder leitet sie zur Beantwortung an Experten weiter. Sie können unter xinqiu@microsoft.com mit ihm Kontakt aufnehmen.