AngularJS ist ein JavaScript-Framework, das es ermöglicht mit wenig Aufwand skalierbare Single-Page-Applikationen zu bauen. Mit Hilfe von Directives kann Angulars HTML-Compiler ($compile) einem DOM Element und dessen Kind-Elementen ein spezifisches Verhalten beibringen. Die wichtigste Directive ist sicherlich ngApp, mit der festgelegt wird, welcher Bereich der Seite die eigentliche Applikation beinhaltet. Angular enthält von Haus aus schon eine Reihe von Directives wie z.B. ngRepeat oder ngHide, die universell einsetzbar sind. Wird eine individuelle DOM Manipulation mehrfach benötigt, bietet Angular die Möglichkeit eigene wiederverwendbare Directives zu schreiben um Redundanz zu minimieren. Das folgende Tutorial soll dabei helfen die Struktur und Implementierung einer einfachen Custom Directive nachvollziehen zu können.
Normalisierung
Zunächst ein Vorwort zur Schreibweise. Wie wir es von HTML gewohnt sind, werden Worte durch Bindestriche getrennt. Javascript würde dieses Zeichen jedoch als mathematischen Minus-Operator interpretieren. Daher findet intern eine Normalisierung statt.
So wird die folgende HTML Schreibweise
<article-and-price></article-and-price>
in folgende Javascript seitige camelCase Schreibweise umgewandelt
articleAndPrice
Directive erstellen
Als Beispiel erstelle ich die Directive „article-and-price“. Das Anlegen einer Directive erfolgt mit dem gleichnamigen Schlüsselwort, gefolgt vom Namen (hier sehen wir die vorher besprochene Normalisierung) und einer Funktion die spezifische Eigenschaften zurück gibt.
angular.module('myApp') .directive("articleAndPrice", function() { return { restrict: 'AECM', template: '<a href="#" class="list-group-item"><h4 class="list-group-item-heading">Peperoni Pizza</h4><p class="list-group-item-text">5.99</p></a>', replace: true }; });
Mit dieser einfachen Directive wird der statische Content von template in den HTML-Tag gerendert. Das folgende Bild veranschaulicht die Funktionsweise (anklicken um vergrößerte Vorschau anzuzeigen).
Mittels der restrict Option können wir bestimmen in welcher Form die Directive verwendet werden kann. Die folgende Auflistung der Abkürzungen gibt einen Überblick über die verfügbaren Eigenschaften an:
A = Attribute
<div article-and-price></div>
E = Element
<article-and-price></article-and-price>
C = Class
<div class="article-and-price"></div>
M = Comment
<!-- directive: article-and-price -->
Die standardmäßige und wohl am häufigsten verwendete Implementierung der restrict Option ist Attribute und Element (AE oder EA; die Reihenfolge spielt keine Rolle).
Für das template habe ich eine einfache list group von Bootstrap benutzt. Diese wird zur Laufzeit in den HTML-Tag gerendert.
Durch die Angabe von replace: true wird der HTML-Tag vollständig ersetzt
<a href="#" class="list-group-item"> <h4 class="list-group-item-heading">Peperoni Pizza</h4> <p class="list-group-item-text">5.99</p> </a>
im Gegensatz zu der Standardmäßigen Implementierung replace: false. Hier wird der Inhalt von template in den Tag eingefügt
<article-and-price> <a href="#" class="list-group-item"> <h4 class="list-group-item-heading">Peperoni Pizza</h4> <p class="list-group-item-text">5.99</p> </a> </article-and-price>
Dies kann jedoch unter Umständen zu Problemen führen, da evtl. Komponenten von Frameworks wie Bootstrap, YAML, Foundation usw. nicht richtig erkannt und dargestellt werden können.
Auslagerung des Templates
Da es unschön ist das HTML Template als String in die Directive zu schreiben werden wir diese nun in eine separate HTML Datei auslagern. Hierfür empfiehlt es sich einen Ordner für alle Template Dateien anzulegen. Für unser Beispiel wird das Template in eine Datei namens articleAndPrice.html gespeichert. Damit die Directive nun Zugriff auf diese Datei hat muss eine Änderung vorgenommen werden. Dies geschieht indem wir template durch templateUrl ersetzen und wie beim routing den Pfad zu besagter Datei angeben.
angular.module('myApp') .directive("articleAndPrice", function() { return { restrict: 'AECM', templateUrl: 'templates/articleAndPrice.html', replace: true }; });
Directive und Scope(@)
Angenommen der folgende Controller ist an die Seite gebunden, die unsere Directive enthält
angular.module('myApp') .controller('mainCtrl',['$scope', function($scope) { $scope.item = { article : 'Peperoni Pizza', price : '5.99' } }]);
Dann könnten wir unser Template dahingehend verändern, sodass wir das Model im View binden.
<a href="#" class="list-group-item"> <h4 class="list-group-item-heading">{{item.article}}</h4> <p class="list-group-item-text">{{item.price}}</p> </a>
Diese Umsetzung kann jedoch zu unerwarteten Seiteneffekten führen. Denn nun hat die Directive direkten Zugriff auf die im Scope befindlichen Daten und kann diese manipulieren. Um diesen Effekt zu umgehen, gibt es die Möglichkeit einen isolierten Scope einzufügen. Dieser scope ist nur von der Directive selbst benutzbar und hat somit auch ein eigenes Model.
angular.module('myApp') .directive("articleAndPrice", function() { return { restrict: 'AECM', templateUrl: 'templates/articleAndPrice.html', replace: true, scope: { itemArticle : "@", itemPrice : "@" } }; });
Wie wir sehen enthält die Directive jetzt einen eigenen Scope. Im Scope befinden sich zwei Attribute die wir nun in unserem HTML-Tag verwenden können. Diesen Attributen können wir Werte übergeben, wobei das @ Zeichen besagt, dass es sich bei den Übergabeparametern um reinen Text handeln muss. Abschließend können wir das Template und den Directive-Tag (man beachte die Normalisierung) anpassen.
<article-and-price item-article="{{ item.article }}" item-price="{{ item.price }}"></article-and-price>
<a href="#" class="list-group-item"> <h4 class="list-group-item-heading">{{ article }}</h4> <p class="list-group-item-text"> {{ price }} </p> </a>
Den Attributen im isolierten Scope der Directive werden die Daten aus dem Scope des Controllers übergeben. Danach hat die Directive Zugriff auf diese Daten und kann die View entsprechend aktualisieren.
Directive und Scope(=)
Anstelle von Text als Übergabeparameter können wir durch die Angabe des = Zeichens auch ganze Objekte übergeben. Bei der Übergabe von Objekten sollte jedoch die bidirektionale Datenbindung beachtet werden. Das heißt, wenn das Objekt in der Directive verändert wird, wirkt es sich auch auf das Objekt im Controller aus und vice versa.
angular.module('myApp') .directive("articleAndPrice", function() { return { restrict: 'AECM', templateUrl: 'templates/articleAndPrice.html', replace: true, scope: { itemObject: "=" } }; });
<a href="#" class="list-group-item"> <h4 class="list-group-item-heading">{{ itemObject.article }}</h4> <p class="list-group-item-text"> {{ itemObject.price }} </p> </a>
<article-and-price item-object="item"></article-and-price>
Zu beachten ist, dass dem Attribut direkt das item-Object aus dem Scope des Controllers übergeben wird. Eine Interpolation innerhalb des HTML-Tags der Directive findet nicht mehr statt.
Directive und Scope(&)
Als letzten möglichen Übergabeparameter werfen wir einen Blick auf Funktionen. Diese können durch Angabe des & Zeichens übergeben werden. Als Beispiel wird der Controller zuerst um eine Funktion erweitert, die auf den Preis eine Mehrwertsteuer von 8,5% erhebt und das Ergebnis auf zwei Nachkommastellen aufrundet.
angular.module('myApp') .controller('mainCtrl',['$scope', function($scope) { $scope.item = { article : 'Peperoni Pizza', price : '5.99' } $scope.calculateTaxes = function(item) { var price = parseFloat(item.price); return (Number(price + (price * 0.085)).toFixed(2)).toString(); }; }]);
Dann muss der isolierte Scope der Directive entsprechend erweitert werden.
angular.module('myApp') .directive("articleAndPrice", function() { return { restrict: 'AECM', templateUrl: 'templates/articleAndPrice.html', replace: true, scope: { itemObject: "=", calculateTaxesFunction: "&" } }; });
Nun kann mit Hilfe eines zweiten Attributs die Funktion übergeben werden.
<article-and-price item-object="item" calculate-taxes-function="calculateTaxes(innerItem)"></article-and-price>
Zum Schluss muss noch das template der Directive angepasst werden.
<a href="#" class="list-group-item"> <h4 class="list-group-item-heading">{{ itemObject.article }}</h4> <p class="list-group-item-text"> {{ calculateTaxesFunction({ innerItem: itemObject }) }} </p> </a>
Hier ist zu beachten, dass wir nicht einfach von Außen ein Objekt an die Funktion übergeben können. Angular übergibt das passende Objekt aus dem isolierten Scope anhand des Parameternamens an die Funktion.
ngRepeat
Im folgenden Abschnitt möchte ich zeigen, dass die selbe Directive auch für eine Liste von Objekten verwendet werden kann. Dafür wird $scope.item im Controller zu $scope.items umbenannt, in ein Array umgewandelt und um zusätzliche Objekte erweitert.
angular.module('myApp') .controller('mainCtrl',['$scope', function($scope) { $scope.items = [ { article : 'Margherita Pizza', price : '3.50' }, { article : 'Peperoni Pizza', price : '5.99' }, { article : 'BBQ Chicken Pizza', price : '8.99' } ] $scope.calculateTaxes = function(item) { var price = parseFloat(item.price); return (Number(price + (price * 0.085)).toFixed(2)).toString(); }; }]);
Um weiterhin auf die einzelnen Objekte von $scope.items zugreifen zu können iteriere ich mithilfe der von AngularJS ausgelieferten Directive ngRepeat über die Liste.
<article-and-price ng-repeat="item in items" item-object="item" calculate-taxes-function="calculateTaxes(innerItem)"></article-and-price>
Anhand dieses Beispiels ist gut zu erkennen, dass sich Directiven beliebig verschachteln lassen können. Für jedes item in items wird nun auch ein neuer isolierter Scope instantiiert.
Link
Bisher haben wir uns nur mit dem Verarbeiten von JSON-Daten und dem rendern von Templates beschäftigt. Im letzten und wohl interessantesten Abschnitt geht es um die DOM-Manipulation mithilfe der Directive. Dafür stellt AngularJS eine Funktion namens Link zur Verfügung. Intern benutzt AngularJS eine kleine und der API angepassten Version von JQuery namens jqLite. Diese kann bei Bedarf wieder durch JQuery ersetzt werden, indem JQuery vor AngularJS in die Applikation geladen wird. Wie im folgenden Beispiel zu sehen, besitzt die Link-Funktion drei Parameter. Bei diesen Parametern handelt es sich um den isolierten Scope sowie die Elemente und Attribute des templates.
angular.module('myApp') .directive("articleAndPrice", function() { return { restrict: 'AECM', templateUrl: 'templates/articleAndPrice.html', replace: true, scope: { itemObject: "=", calculateTaxesFunction: "&" }, link: function(scope, elem, attrs) { elem.on('mouseenter', function() { if(Number(parseFloat(scope.calculateTaxesFunction({innerItem: scope.itemObject}))) < 4) { elem.addClass('list-group-item-success'); } else if(Number(parseFloat(scope.calculateTaxesFunction({innerItem: scope.itemObject}))) < 8.5) { elem.addClass('list-group-item-warning'); } else { elem.addClass('list-group-item-danger'); } }); elem.on('mouseleave', function() { elem.removeClass('list-group-item-success'); elem.removeClass('list-group-item-warning'); elem.removeClass('list-group-item-danger'); }); } }; });
In diesem Beispiel wird dem Element eine der drei verschiedenen Bootstrap Klassen (success, warning, danger) hinzugefügt, sobald sich die Maus über einem Element der list group befindet. Die Vergabe der Klasse wird dabei in Preiskategorien eingeteilt (success (grün): günstig < 4.00$, warning (gelb): mittlere Preiskategorie < 8.50$, danger (rot): teuer >= 8.50$). Sobald die Maus das Element verlässt wird die Klasse wieder entfernt. Das folgende Bild veranschaulicht einen ‚mouseenter‘ auf dem ersten Element der List (anklicken um vergrößerte Vorschau anzuzeigen).
Guter Artikel, Mirko!