To jest początek strony.
To jest koniec strony.
, aby na ekranie nie były jednocześnie wyświetlane elementy o wartościach id wynoszących top i bottom. Element wykorzystuje dyrektywę ng-click do wywołania funkcji kontrolera o nazwie show(), która akceptuje wartość atrybutu id jako argument i wykorzystuje go w wywołaniu metody $location.hash(). Usługa $anchorScroll jest nietypowa, ponieważ nie wymaga użycia obiektu usługi i wystarczy po prostu zadeklarować zależność. Utworzony obiekt usługi rozpoczyna monitorowanie wartości $location.hash i automatycznie przewija zawartość okna po zmianie wymienionej wartości. Efekt pokazano na rysunku 19.2.
Rysunek 19.2. Przewijanie elementów za pomocą usługi $anchorScroll Istnieje możliwość wyłączenia automatycznego przewijania przeprowadzanego za pomocą dostawcy usługi. To pozwala na selektywne przewijanie przez wywołanie usługi $anchorScroll jako funkcji, jak przedstawiono na listingu 19.8. Listing 19.8. Selektywne przewijanie elementów w pliku domApi.html ...
...
W celu wyłączenia automatycznego przewijania używam wywołania Module.config(), jak omówiono w rozdziale 9., co odbywa się przez wywołanie metody disableAutoScrolling() w $anchorScrollProvider. Zmiany w $location.hash nie będą dłużej powodowały automatycznego przewijania. Aby wyraźnie zainicjować przewijanie, należy wywołać funkcję usługi $anchorScroll. Na listingu wywołanie to występuje, gdy funkcji show() jest przekazywany argument bottom. Efektem jest przewinięcie zawartości okna przeglądarki internetowej po kliknięciu przycisku Przejdź na koniec strony, ale nie po kliknięciu przycisku Przejdź na początek strony.
Rejestracja danych W rozdziale 18. zbudowaliśmy własną, prostą usługę przeznaczoną do rejestracji danych. Jednak AngularJS oferuje usługę $log będącą opakowaniem dla obiektu globalnego console. Usługa $log definiuje metody debug(), info(), log() i warn() odpowiadające metodom zdefiniowanym przez obiekt console. Jak przedstawiono w rozdziale 18. nie ma konieczności użycia usługi $log(), ale dzięki niej testy jednostkowe są łatwiejsze. Na listingu 19.9 przedstawiono zmodyfikowaną wersję naszej usługi rejestracji danych. Tym razem do wyświetlania komunikatów używana jest usługa $log. Listing 19.9. Użycie usługi $log w pliku services.js angular.module("customServices", []) .provider("logService", function () { var counter = true; var debug = true; return { messageCounterEnabled: function (setting) { if (angular.isDefined(setting)) { counter = setting; return this; } else { return counter; } }, debugEnabled: function (setting) { if (angular.isDefined(setting)) { debug = setting; return this; } else { return debug; } }, $get: function ($log) { return { messageCount: 0, log: function (msg) { if (debug) { $log.log("(LOG" + (counter ? " + " + this.messageCount++ + ") " : ") ") + msg); } }
481
AngularJS. Profesjonalne techniki }; } } });
Zwróć uwagę na zadeklarowanie w funkcji $get() zależności od usługi. To jest cecha szczególna użycia funkcji dostawcy i jednocześnie coś, z czym się nie spotkasz podczas pracy z usługą lub metodami fabryki. Aby zademonstrować przykład tego rodzaju rozwiązania, na listingu 19.10 przedstawiono usługę $log użytą we własnej usłudze utworzonej w rozdziale 18. za pomocą metody factory(). Listing 19.10. Przykład konsumpcji w pliku services.html usługi $log zdefiniowanej za pomocą metody fabryki angular.module("customServices", []) .factory("logService", function ($log) { var messageCount = 0; return { log: function (msg) { $log.log("(LOG + " + this.messageCount++ + ") " + msg); } }; });
Wskazówka Zachowanie domyślne usługi $log nie powoduje wykonywania wywołania metody debug() w konsoli. Debugowanie można włączyć przez przypisanie wartości true właściwości $logProvider.debugEnabled. Więcej informacji o ustawianiu właściwości dostawcy znajdziesz w rozdziale 18.
Wyjątki AngularJS wykorzystuje usługę $exceptionHandler do obsługi wszelkich wyjątków, jakie mogą być zgłaszane podczas działania aplikacji. Domyślna implementacja wywołuje definiowaną przez usługę $log metodę error(), która z kolei wywołuje metodę globalną console.error().
Kiedy i dlaczego używać usługi $exceptionHandler? Wyjątki można rozważać w dwóch szerokich kategoriach. Pierwsza obejmuje te wyjątki, które są zgłaszane w trakcie tworzenia kodu i jego testowania. Są naturalną koleją rzeczy, pomagają w nadaniu kształtu tworzonej aplikacji. Druga obejmuje wyjątki zgłaszane użytkownikowi po opublikowaniu aplikacji. Sposób obsługi wyjątków w poszczególnych kategoriach jest inny. Jednak w obu sytuacjach dobrze jest zachować spójność podczas przechwytywania wyjątków, aby można było na nie zareagować oraz (w idealnej sytuacji) zarejestrować je do dalszej analizy. W tym miejscu do gry wchodzi usługa $exceptionHandler. Jej działanie domyślne polega po prostu na wyświetleniu w konsoli JavaScript informacji szczegółowych o wyjątku i umożliwieniu aplikacji dalsze działanie (o ile to możliwe). Jak się wkrótce przekonasz, tę usługę można wykorzystać także do wykonywania znacznie bardziej skomplikowanych zadań, dzięki którym uda się uniknąć niezadowolenia i frustracji użytkowników na skutek awarii aplikacji. Wskazówka Usługa $exceptionHandler działa jedynie z nieprzechwyconymi wyjątkami. Przechwycenie wyjątku odbywa się za pomocą bloku JavaScript try...catch, te wyjątki nie są obsługiwane przez $exceptionHandler.
482
Rozdział 19. Usługi dla obiektów globalnych, błędów i wyrażeń
Praca z wyjątkami Aby zademonstrować usługę $exceptionHandler, dodajemy do katalogu angularjs nowy plik o nazwie exceptions.html i zawartości przedstawionej na listingu 19.11. Listing 19.11. Zawartość pliku exceptions.html
Wyjątki
Zgłoś wyjątek
Ten przykład zawiera element używający dyrektywy ng-click do wywołania funkcji kontrolera o nazwie throwExp() zgłaszającej wyjątek. Po wczytaniu pliku exceptions.html w przeglądarce internetowej i kliknięciu przycisku otrzymasz w konsoli JavaScript dane wyjściowe podobne do poniższych: Error: Zgłoszono wyjątek
W zależności od używanej przeglądarki internetowej wyświetlony może być również stos wywołań zawierający nazwę pliku i numer wiersza, w którym użyto polecenia throw.
Bezpośrednia praca z usługą $exceptionHandler Wprawdzie AngularJS automatycznie przekazuje wyjątki do usługi $exceptionHandler, ale więcej informacji kontekstu można przekazać w trakcie bezpośredniej pracy z usługą w kodzie. Na listingu 19.12 przedstawiono zadeklarowanie zależności od usługi $exceptionHandler, co pozwala na przekazanie wyjątku bezpośrednio do usługi. Listing 19.12. Bezpośrednia praca z usługą $exceptionHandler w pliku exceptions.html
Wyjątki
483
AngularJS. Profesjonalne techniki
Zgłoś wyjątek
Obiekt usługi $exceptionHandler to funkcja pobierająca dwa argumenty: wyjątek oraz opcjonalny ciąg tekstowy opisujący przyczynę zgłoszenia wyjątku. W omawianym przykładzie może być tylko jeden wyjątek, a więc argument cause nie jest zbyt użyteczny. Jeżeli wyjątki są przechwytywane w pętli przetwarzającej dane, to podanie szczegółów dotyczących elementu danych, który spowodował problem, może być użyteczne. Poniżej przedstawiono dane wyjściowe generowane w konsoli po kliknięciu przycisku w omawianym przykładzie. Zgłoszono wyjątek kliknięcie przycisku
Implementacja własnej procedury obsługi wyjątków W rozdziale 18. wspomniano o tym, jak ważne jest nadawanie unikalnych nazw usługom, aby uniknąć nadpisania usług definiowanych przez AngularJS lub inne używane pakiety. W tym punkcie celowo nadpiszemy oferowaną przez AngularJS implementację usługi $errorHandler — aby zdefiniować własną politykę obsługi wyjątków. Na listingu 19.13 możesz zobaczyć, jak zaimplementowano wymienioną usługę. Listing 19.13. Zastępowanie usługi $errorHandler w pliku exceptions.html
Wyjątki
Zgłoś wyjątek
W kodzie wykorzystaliśmy omówioną w rozdziale 18. metodę factory() do ponownego zdefiniowania obiektu usługi $errorHandler, aby lepiej formatował komunikat na podstawie wyjątku i przyczyny jego zgłoszenia. Wskazówka Wprawdzie zachowanie domyślne można zastąpić znacznie bardziej skomplikowaną logiką, ale zalecam zachowanie ostrożności. Kod odpowiedzialny za obsługę błędów powinien być niezawodny, ponieważ jeśli będzie zawierał błędy, to nie będziesz otrzymywał informacji o rzeczywistych problemach w aplikacji. Ogólnie rzecz biorąc, najlepsza jest najprostsza procedura obsługi błędów.
Po wczytaniu dokumentu exceptions.html w przeglądarce internetowej i kliknięciu przycisku, w konsoli otrzymasz następujące dane wyjściowe: Komunikat: Zgłoszono wyjątek (przyczyna: kliknięcie przycisku)
Praca z niebezpiecznymi danymi Często spotykany rodzaj ataku w aplikacjach sieciowych polega na próbie wyświetlenia danych w celu oszukania użytkownika. Najczęściej oznacza to wykonanie przez przeglądarkę internetową kodu JavaScript przygotowanego przez atakującego. Jednak ataki mogą obejmować także próbę zmiany układu graficznego aplikacji za pomocą starannie przygotowanych stylów CSS. Typów ataków mamy niezliczoną ilość, ich wspólnym mianownikiem jest wstrzykiwanie złośliwej zawartości do aplikacji za pomocą formularzy sieciowych. Wspomniana złośliwa zawartość może być później wykorzystywana przez atakującego lub wyświetlana ofierze. AngularJS oferuje wbudowaną obsługę minimalizacji ryzyka ataku. W tym podrozdziale dowiesz się, jak to działa, i poznasz wbudowane funkcje pozwalające na przejście kontroli nad procesem minimalizacji ryzyka ataku. W tabeli 19.7 wymieniono usługi oferowane przez AngularJS do pracy z niebezpiecznymi danymi. Tabela 19.7. Usługi operujące na niebezpiecznych danych Nazwa
Opis
$sce
Usuwa z kodu znaczników HTML niebezpieczne elementy i atrybuty.
$sanitize
W ciągach tekstowych HTML niebezpieczne znaki zastępuje ich bezpiecznymi odpowiednikami.
485
AngularJS. Profesjonalne techniki
Kiedy i dlaczego używać usług przeznaczonych do pracy z niebezpiecznymi danymi? AngularJS ma dobrą domyślną politykę przeznaczoną do pracy z niebezpieczną zawartością. Jeżeli chcesz uzyskać nieco większą elastyczność, to trzeba bezpośrednio pracować z usługami omówionymi w tym punkcie. Takie rozwiązanie może okazać się konieczne podczas tworzenia aplikacji pozwalającej użytkownikom na generowanie zawartości HTML (na przykład internetowy edytor HTML) lub w trakcie pracy z zawartością generowaną przez przestarzały system, który miesza w kodzie HTML dane i sposób ich prezentacji (pod tym względem stare systemy zarządzania treścią i portale są naprawdę okropne).
Wyświetlanie niebezpiecznych danych AngularJS wykorzystuje funkcję o nazwie SCE (ang. strict contextual escaping) w celu uniemożliwienia udostępnienia niebezpiecznych wartości przez mechanizm dołączania danych. Ta funkcja jest domyślnie włączona. Aby zademonstrować sposób jej działania, w katalogu angularjs tworzymy nowy plik o nazwie htmlData.html wraz z zawartością przedstawioną na listingu 19.14. Listing 19.14. Zawartość pliku htmlData.html
SCE
{{htmlData}}
W omawianym przykładzie zakres kontrolera zawiera element powiązany z właściwością htmlData, której wartość jest wyświetlana za pośrednictwem osadzonego wyrażenia. Właściwości przypisano niebezpieczny ciąg tekstowy HTML, aby zwolnić Cię z konieczności jego ręcznego wprowadzenia w elemencie . Idea jest następująca: atakujący próbuje zmusić przeglądarkę internetową do wykonania pewnego kodu JavaScript, podanego w elemencie . W omawianym przypadku kod JavaScript powoduje wyświetlenie okna dialogowego. Większość ataków, z którymi się spotkałem, polega na tym, że atakujący próbuje zmusić aplikację do przekazywania innym danych wprowadzanych przez użytkownika, najczęściej prosząc o podanie danych uwierzytelniających, lub po prostu jest to akt destrukcji. Aby pomóc zminimalizować ryzyko, AngularJS automatycznie zastępuje niebezpieczne znaki (takie jak < i > w zawartości HTML) ich bezpiecznymi odpowiednikami, jak pokazano na rysunku 19.3.
486
Rozdział 19. Usługi dla obiektów globalnych, błędów i wyrażeń
Rysunek 19.3. AngularJS automatycznie neutralizuje wartości pobierane za pomocą mechanizmu dołączania danych Biblioteka AngularJS przeprowadziła transformację ciągu tekstowego HTML z elementu : To są niebezpieczne dane.
na bezpieczny do wyświetlenia: To są niebezpieczne dane.
Każdy znak, który zmusiłby przeglądarkę internetową do potraktowania go jako HTML, został zastąpiony bezpiecznym odpowiednikiem. Wskazówka Proces neutralizowania zawartości nie wpływa na pierwotne wartości w zakresie, lecz jedynie na sposób ich wyświetlania przez mechanizm dołączania danych. To oznacza możliwość bezpiecznej pracy z danymi HTML i zezwolenie bibliotece AngularJS na ich bezpieczne generowanie w przeglądarce internetowej.
W większości aplikacji domyślne zachowanie AngularJS sprawdza się doskonale i uniemożliwia wyświetlanie niebezpiecznych danych. Jeżeli znajdziesz się w rzadko występującej sytuacji, gdy trzeba wyświetlić zawartość HTML bez jej neutralizacji, będziesz mógł skorzystać z kilku technik gotowych do użycia.
Użycie niebezpiecznego mechanizmu dołączania danych Pierwsza technika polega na użyciu dyrektywy ng-bind-html, która pozwala na określenie danych jako zaufanych, a tym samym na rezygnację z ich unieszkodliwiania. Działanie dyrektywy ng-bind-html zależy od modułu ngSanitize, który nie znajduje się w głównej bibliotece AngularJS. Przejdź do witryny https://angularjs.org/, kliknij przycisk Download, wybierz wersję (w chwili pisania była to wersja 1.2.22), a następnie kliknij łącze Browse additional modules w lewym dolnym rogu, jak pokazano na rysunku 19.4. Pobierz plik angular-sanitize.js i umieść go w katalogu angularjs. Na listingu 19.15 pokazano dodanie zależności od modułu ngSanitize i zastosowanie dyrektywy ng-bind-html w celu wyświetlenia niebezpiecznej wartości. Listing 19.15. Przykład wyświetlania zaufanych danych w pliku htmlData.html
SCE
Dla dyrektywy ng-bind-html nie ma osadzonego wyrażenia i dlatego dodaliśmy element , aby móc dodać do niego zawartość. Efekt wprowadzonych zmian pokazano na rysunku 19.5.
Rysunek 19.5. Efekt użycia dyrektywy ng-bind-html Wprawdzie zawartość jest wyświetlana jako HTML, ale obsługa zdarzeń onmouseover dodana do elementu nie działa. To skutek drugiego zabezpieczenia, które usuwa z ciągów tekstowych HTML niebezpieczne elementy i atrybuty. Poniżej przedstawiono postać, na którą jest przekształcana wartość htmlData: To są niebezpieczne dane.
488
Rozdział 19. Usługi dla obiektów globalnych, błędów i wyrażeń
Skutkiem działania zabezpieczeń jest usunięcie elementów
Zmieniliśmy dyrektywę ng-model w elemencie i ustawiliśmy niejawnie zdefiniowaną zmienną o nazwie dangerousData. W kontrolerze używamy funkcji monitorującej w celu monitorowania właściwości defaultData pod kątem zmian. Po zmianie wartości obiekt usługi $sanitize przetwarza nową wartość. Obiekt $sanitize jest funkcją pobierającą potencjalnie niebezpieczną wartość i zwraca wartość poddaną sanityzacji. Aby zademonstrować efekt, wykorzystaliśmy standardową dyrektywę ng-bind do wyświetlenia wartości htmlData poddanej sanityzacji, jak pokazano na rysunku 19.6.
489
AngularJS. Profesjonalne techniki
Rysunek 19.6. Wyraźne przeprowadzenie sanityzacji danych Jak widzisz, proces sanityzacji usunął z ciągu tekstowego procedurę obsługi w JavaScript wprowadzoną w elemencie . Wartość nie jest wyświetlana jako kod HTML, ponieważ dyrektywa ng-bind nadal neutralizuje niebezpieczne znaki.
Wyraźne zaufanie danym Istnieją pewne — niezwykle rzadkie — sytuacje, w których może wystąpić potrzeba wyświetlenia potencjalnie niebezpiecznej zawartości bez jej wcześniejszej neutralizacji lub sanityzacji. Za pomocą usługi $sce zawartość można zdefiniować jako wartą zaufania. Ostrzeżenie Na przestrzeni lat pracowałem nad wieloma projektami aplikacji sieciowych, a mimo to sporadycznie spotykałem się z potrzebą wyświetlenia niezmodyfikowanych, niezaufanych danych. Taki trend panował w połowie pierwszej dekady XXI wieku. Podczas dostarczania aplikacji jako portali każdy fragment aplikacji zawierał własny kod JavaScript i CSS. Kiedy portale zaniknęły, zastępujące je aplikacje odziedziczyły bazę danych fragmentów zawartości, która musiała być wygenerowana bez zakłóceń, co oznaczało konieczność wyłączenia funkcji będących luźnymi odpowiednikami SCE w AngularJS. W pozostałych projektach zmagałem się z trudnościami, aby osiągnąć odwrotny efekt, czyli by zapewnić bezpieczeństwo wszystkim elementom danych, które były wyświetlane przez aplikację. Dotyczyło to zwłaszcza danych wprowadzanych przez użytkowników. Podsumowując, nie kombinuj na tym obszarze, o ile nie masz ku temu naprawdę ważnego powodu.
Obiekt usługi $sce definiuje metodę trustAsHtml(), której wartość zwrotna będzie wyświetlana przez zastosowany proces SCE, jak przedstawiono na listingu 19.17. Listing 19.17. Wyświetlanie niebezpiecznej zawartości w pliku htmlData.html
SCE
Funkcję monitorującą wykorzystaliśmy do przypisania właściwości trustedData wartości zwrotnej metody $sce.trustAsHtml(). Nadal używamy dyrektywy ng-bind-html do wyświetlania wartości jako kodu HTML, a nie zneutralizowanego tekstu. Zaufanie danym uniemożliwia usunięcie procedury obsługi w JavaScript, a zastosowanie dyrektywy ng-bind-html uniemożliwia neutralizację niebezpiecznych znaków. W efekcie przeglądarka internetowa wyświetla zawartość wprowadzoną w elemencie i przetwarza kod JavaScript. Jeżeli umieścisz kursor myszy nad pogrubionym tekstem, to na ekranie zostanie wyświetlony komunikat, jak pokazano na rysunku 19.7.
Rysunek 19.7. Wyświetlenie niebezpiecznych, niezneutralizowanych danych
Praca z wyrażeniami i dyrektywami AngularJS AngularJS oferuje zbiór usług przeznaczonych do pracy z zawartością AngularJS i wyrażeniami dołączania danych. Te usługi wymieniono w tabeli 19.8, przetwarzają zawartość w funkcjach, które można wywołać w celu wygenerowania zawartości w aplikacji. Dostępne są różne funkcje, począwszy od prostych wyrażeń, aż po fragmenty kodu HTML zawierające dyrektywy i polecenia dołączania danych. Tabela 19.8. Usługi oferujące wyrażenia w AngularJS Nazwa
Opis
$compile
Konwertuje fragment HTML zawierający dyrektywy i operacje dołączania danych na funkcję wywoływaną w celu wygenerowania zawartości.
$interpolate
Konwertuje ciąg tekstowy zawierający osadzone operacje dołączania danych na funkcję, która może być wywołana w celu wygenerowania zawartości.
$parse
Konwertuje wyrażenie AngularJS na funkcję, która może być wywołana w celu wygenerowania zawartości.
491
AngularJS. Profesjonalne techniki
Kiedy i dlaczego używać usług wyrażeń i dyrektyw? Wymienione usługi mogą być użyteczne podczas tworzenia dyrektyw, ponieważ pozwalają na uzyskanie wyraźnej kontroli nad procesem generowania zawartości. Usługi te nie będą potrzebne w podstawowych dyrektywach, ale okażą się nieocenione, gdy wystąpią problemy wymagające precyzyjnego zarządzania szablonami.
Konwersja wyrażenia na funkcję Usługa $parse pobiera wyrażenie AngularJS i konwertuje je na funkcję, którą można wykorzystać do obliczenia wartości wyrażenia za pomocą obiektu zakresu. To użyteczne rozwiązanie, które możemy zastosować we własnych dyrektywach, pozwala na dostarczanie wyrażeń przez atrybuty i obliczanie ich nawet wtedy, gdy dyrektywa nie zna szczegółów wyrażenia. Aby zademonstrować przykład użycia usługi $parse, w katalogu angularjs tworzymy plik expressions.html o zawartości przedstawionej na listingu 19.18. Listing 19.18. Zawartość pliku expressions.html
Wyrażenia
Wynik:
492
Rozdział 19. Usługi dla obiektów globalnych, błędów i wyrażeń
W tym przykładzie znajduje się dyrektywa o nazwie evalExpression, skonfigurowana z wykorzystaniem właściwości zakresu zawierającej wyrażenie obliczane przez usługę $parse. Dyrektywa została zastosowana w elemencie i skonfigurowana do użycia właściwości zakresu o nazwie expr dołączonej do elementu , co pozwala na wprowadzanie i dynamiczne obliczanie wyrażenia. Efekt pokazano na rysunku 19.8.
Rysunek 19.8. Użycie usługi $parse do obliczania wartości wyrażeń Ponieważ potrzebujemy danych, to użyliśmy kontrolera w celu dodania właściwości zakresu o nazwie price i przypisania jej wartości liczbowej. Na rysunku pokazano efekt wprowadzenia wyrażenia price | currency w elemencie . Właściwość price jest przetwarzana przez filtr currency, a wynik wyświetlany jako zawartość text elementu , w którym zastosowano dyrektywę.
Zwykle nie oczekuje się od użytkowników wprowadzania wyrażenia AngularJS w aplikacji (wkrótce poznasz znacznie bardziej typowy przykład użycia usługi $parse). W tym miejscu chciałem jednak pokazać, jak głęboko można wejść do komponentów AngularJS i zmienić wyrażenie, a nie tylko wartości danych. Proces używający usługi $parse jest prosty — obiekt usługi to funkcja, której jedynym argumentem jest wyrażenie do obliczenia. Wartością zwrotną jest funkcja przeznaczona do wykonania, gdy trzeba będzie obliczyć wartość wyrażenia. Usługa $parse sama nie oblicza wartości wyrażenia, stanowi raczej fabrykę dla funkcji faktycznie przeprowadzających obliczenia. Poniżej przedstawiono polecenie z omawianego przykładu, w którym używamy obiektu usługi $parse: ... var expressionFn = $parse(scope.expr); ...
Wyrażenie — w omawianym przykładzie wprowadzone przez użytkownika w elemencie — zostaje przekazane funkcji $parse, a zmiennej expressionFn jest przypisana funkcja otrzymana jako wartość zwrotna. Następnie funkcja jest wywoływana i otrzymuje zakres jako źródło wartości danych dla wyrażenia: ... var result = expressionFn(scope); ...
Nie ma konieczności użycia zakresu jako źródła dla wartości w wyrażeniu, ale zwykle stosuje się takie rozwiązanie. (W kolejnym punkcie zobaczysz, jak używać zakresu i danych lokalnych). Wynikiem wywołania funkcji jest obliczenie wartości wyrażenia. W omawianym przykładzie będzie to wartość właściwości price po jej przetworzeniu przez filtr currency, jak pokazano na rysunku 19.8. Podczas obliczania wartości wyrażeń wziętych przez użytkownika pod uwagę, należy uwzględnić możliwość, że wyrażenie będzie nieprawidłowe. Usunięcie kilku znaków z nazwy filtru w elemencie oznacza wskazanie nieistniejącego filtru, a skutkiem będzie komunikat informujący o braku możliwości obliczenia wartości wyrażenia. Wspomniany komunikat został wygenerowany po przechwyceniu wyjątku zgłoszonego podczas próby przetworzenia i obliczenia wartości nieprawidłowego wyrażenia. Trzeba być przygotowanym również na otrzymanie wyniku w postaci wartości undefined podczas obliczania wyrażenia, co może się zdarzyć, gdy wyrażenie odwołuje się do nieistniejących danych. Dyrektywy dołączania danych w AngularJS automatycznie wyświetlają wartość undefined jako pusty ciąg tekstowy. Jednak tym należy zająć się samodzielnie podczas bezpośredniej pracy z usługą $parse. W omawianym przykładzie wyświetlany jest ciąg tekstowy brak, gdy wartością wyrażenia będzie undefined. ...
493
AngularJS. Profesjonalne techniki if (result == undefined) { result = "brak"; } ...
Dostarczanie danych lokalnych W poprzednim przykładzie usługa $parse została wykorzystana w nietypowy dla niej sposób, ponieważ rzadko oczekuje się wprowadzenia przez użytkownika wyrażenia przeznaczonego do obliczenia. Znacznie częściej spotykana sytuacja to wyrażenie zdefiniowane w aplikacji, dla którego użytkownik podaje dane. Na listingu 19.19 przedstawiono zmodyfikowaną wersję dokumentu expressions.html, w którym użytkownik podaje wartości dla obliczanego wyrażenia. Listing 19.19. Przykład obliczania w pliku expressions.html wartości wyrażenia na podstawie danych podanych przez użytkownika oraz danych na stałe zdefiniowanych w kodzie
Wyrażenia
Wynik:
494
Rozdział 19. Usługi dla obiektów globalnych, błędów i wyrażeń
W omawianym listingu dyrektywę dla odmiany zdefiniowaliśmy za pomocą obiektu definicji, jak przedstawiono w rozdziale 16. Wyrażenie jest przetwarzane przez usługę $parse w funkcji fabryki dyrektywy; jest przetwarzane tylko jeden raz, a następnie będzie wywoływana funkcja obliczająca wartość wyrażenia po każdej zmianie właściwości amount. Wyrażenie zawiera odniesienie do właściwości total nieistniejącej w zakresie, a tym samym obliczanej dynamicznie w funkcji monitorującej używającej dwóch właściwości dołączonych do odizolowanego zakresu: ... var localData = { total: Number(newValue) + (Number(newValue) * (Number(scope.tax) /100)) } element.text(expressionFn(scope, localData)); ...
Punktem kluczowym, na który trzeba zwrócić uwagę w powyższych poleceniach, jest sposób przekazywania funkcji wyrażenia obiektu zawierającego właściwość total. Takie rozwiązanie uzupełnia wartości pobierane z zakresu i dostarcza wartości dla odniesienia total w wyrażeniu. Dlatego też gdy zostanie wprowadzona wartość w elemencie , w elemencie , w którym zastosowano dyrektywę, wyświetli się wartość całkowita uwzględniająca konfigurowaną stawkę podatku, jak pokazano na rysunku 19.9.
Rysunek 19.9. Dostarczanie danych lokalnych podczas obliczania wartości wyrażenia
Interpolacja ciągów tekstowych Usługa $interpolate i jej dostawca $interpolateProvider są wykorzystywane do konfiguracji sposobu, w jaki AngularJS przeprowadza interpolację, czyli procesu wstawiania wyrażeń w ciągach tekstowych. Usługa $interpolate jest znacznie elastyczniejsza od $parse, ponieważ pozwala na pracę z ciągami tekstowymi zawierającymi wyrażenia, a nie jedynie z samymi wyrażeniami. Na listingu 19.20 przedstawiono przykład użycia usługi $interpolate w pliku expressions.html. Listing 19.20. Przykład interpolacji ciągów tekstowych w pliku expressions.html
Wyrażenia
Jak pokazano na listingu, użycie usługi $interpolate odbywa się podobnie jak $parse, choć występuje kilka ważnych różnic. Pierwsza i najbardziej oczywista różnica polega na tym, że usługa $interpolate może operować na ciągach tekstowych o zawartości innej niż AngularJS, połączonej z osadzonymi poleceniami dołączania danych. W rzeczywistości znaki {{ i }} oznaczające dołączanie danych są nazywane znakami interpolacji, ponieważ są bardzo ściśle powiązane z usługą $interpolate. Druga różnica polega na tym, że tworzonej przez usługę $interpolate funkcji interpolacji nie można dostarczyć zakresu i danych lokalnych. Zamiast tego trzeba się upewnić, że dane wymagane przez wyrażenie znajdują się w obiekcie przekazywanym funkcji interpolacji.
Konfiguracja interpolacji AngularJS to nie jedyna biblioteka używająca znaków {{ i }}, co może stanowić problem, jeśli próbujesz połączyć AngularJS z innym pakietem. Na szczęście istnieje możliwość zmiany znaków stosowanych przez AngularJS do interpolacji. Zmiana odbywa się za pomocą wymienionych w tabeli 19.9 metod dostawcy usługi $interpolate, czyli $interpolateProvider. Tabela 19.9. Metody zdefiniowane przez dostawcę usługi $interpolate Nazwa
Opis
startSymbol(symbol)
Zastępuje symbol początkowy, którym domyślnie jest {{.
endSymbol(symbol)
Zastępuje symbol końcowy, którym domyślnie jest }}.
Podczas użycia metod wymienionych w tabeli 19.9 należy zachować ostrożność, ponieważ mają one wpływ na interpolację w AngularJS, między innymi w osadzonych w kodzie znaczników HTML poleceniach dołączania danych. Przykład zmiany znaków interpolacji przedstawiono na listingu 19.21. 496
Rozdział 19. Usługi dla obiektów globalnych, błędów i wyrażeń
Listing 19.21. Przykład zmiany znaków interpolacji w pliku expressions.html
Wyrażenia
Wartość pierwotna: !!dataValue!!
Początkowy i końcowy symbol interpolacji zamieniono na znaki !!. Aplikacja nie będzie więc dłużej rozpoznawać znaków {{ i }} jako osadzonego wyrażenia dołączania danych i operuje jedynie na nowej sekwencji znaków: ... $interpolate("Wartość całkowita wynosi: !!amount | currency!! (łącznie z podatkiem)"); ...
497
AngularJS. Profesjonalne techniki
W elemencie dokumentu expressions.html umieściliśmy osadzone wyrażenie, aby pokazać, że efekt wprowadzonej zmiany jest większy niż w przypadku bezpośredniego użycia usługi $interpolate. ... Wartość pierwotna: !!dataValue!!
...
Zwykłe osadzone polecenia dołączania danych są przez AngularJS przetwarzane za pomocą usługi $interpolate, a ponieważ obiekty usług to Singleton, więc wszelkie zmiany w konfiguracji dotyczą całego modułu.
Kompilacja zawartości Usługa $compile przetwarza fragment kodu HTML zawierający polecenia dołączania danych i wyrażenia. Na tej podstawie tworzy funkcję, którą można wykorzystywać do wygenerowania zawartości zakresu. Przypomina usługi $parse i $interpolate, ale zapewnia obsługę dyrektyw. Na listingu 19.22 możesz zobaczyć, że użycie usługi $compile jest nieco bardziej skomplikowane niż użycie pozostałych usług omówionych w tym punkcie. Listing 19.22. Przykład kompilacji zawartości w pliku expressions.html
Wyrażenia
W omawianym przykładzie kontroler definiuje tablicę nazw miast. Dyrektywa wykorzystuje usługę $compile do przetworzenia fragmentu kodu HTML zawierającego dyrektywę ng-repeat odpowiedzialną za wypełnienie elementu danymi miast. Proces użycia usługi $compile został podzielony na poszczególne
polecenia, co pozwala na jego wyjaśnienie krok po kroku. Na początek definiujemy fragment kodu HTML i opakowujemy go w obiekt jqLite: ...
498
Rozdział 19. Usługi dla obiektów globalnych, błędów i wyrażeń var content = "" var listElem = angular.element(content); ...
Tutaj wykorzystujemy prosty fragment, ale równie dobrze możesz umieścić znacznie bardziej skomplikowaną zawartość z elementów szablonu, jak pokazano podczas pracy z dyrektywami w rozdziałach od 15. do 17. Kolejnym krokiem jest użycie będącego funkcją obiektu usługi $compile do utworzenia funkcji odpowiedzialnej za wygenerowanie zawartości: ... var compileFn = $compile(listElem); ...
Mając przygotowaną funkcję kompilacji, można ją wywołać w celu przetworzenia zawartości znajdującej się w wybranym fragmencie kodu. To spowoduje obliczenie wartości wyrażeń i wykonanie dyrektyw znajdujących się w danym fragmencie. Zwróć uwagę na brak wartości zwrotnej podczas wywoływania funkcji kompilacji: ... compileFn(scope); ...
Zamiast tego operacja przetwarzania zawartości uaktualnia elementy obiektu jqLite i dlatego na końcu dodajemy te elementy do modelu DOM: ... element.append(listElem); ...
W efekcie element zawiera elementy - dla każdej wartości w tablicy cities, jak pokazano na rysunku 19.10.
Rysunek 19.10. Kompilacja zawartości
Podsumowanie W tym rozdziale poznałeś wbudowane usługi, które mogą być używane do zarządzania elementami, obsługi błędów, wyświetlania niebezpiecznych danych i przetwarzania wyrażeń. Wspomniane usługi stanowią fundament AngularJS, a przez ich wykorzystywanie zyskujesz kontrolę nad pewnymi funkcjami podstawowymi, co może być szczególnie przydatne podczas tworzenia własnych dyrektyw. W następnym rozdziale poznasz usługi zapewniające obsługę asynchronicznych żądań HTTP i obietnic, czyli obiektów wymaganych do obsługi odpowiedzi na te żądania.
499
AngularJS. Profesjonalne techniki
500
ROZDZIAŁ 20
Usługi dla technologii Ajax i obietnic W tym rozdziale zostaną omówione wbudowane usługi AngularJS przeznaczone do wykonywania żądań Ajax oraz przedstawiania działań asynchronicznych. To są niezwykle ważne usługi, ponieważ stanowią fundamenty innych usług, które zostaną omówione w kolejnych rozdziałach. Podsumowanie materiału zamieszczonego w rozdziale przedstawiono w tabeli 20.1. Tabela 20.1. Podsumowanie materiału zamieszczonego w rozdziale Problem
Rozwiązanie
Listing
Jak utworzyć żądanie Ajax? Jak pobrać dane z żądania Ajax?
Użyj usługi $http. Zarejestruj funkcję wywołania zwrotnego, używając metody success(), error() lub then() w obiekcie zwróconym przez metodę $http(). Pobierz dane za pomocą funkcji wywołania zwrotnego metody success() lub then(). Jeśli dane są w formacie XML, to do ich przetworzenia można użyć jqLite. Użyj funkcji transformacji.
od 1 do 3 4
7i8
Użyj dostawcy $httpProvider.
9
Za pomocą dostawcy $httpProvider zarejestruj przechwytującą funkcję fabryki. Użyj obietnicy utworzonej na podstawie obiektów deferred i promise.
10
Jak przetworzyć dane inne niż w formie JSON? Jak skonfigurować żądanie lub wstępnie przetworzyć odpowiedź? Jak ustawić wartości domyślne dla żądania Ajax? W jaki sposób przechwytywać żądania i odpowiedzi? W jaki sposób przedstawić aktywność, która zostanie ukończona w nieokreślonym czasie w przyszłości? Jak pobrać obiekt deferred? Jak pobrać obiekt promise? Jak łączyć ze sobą obietnice?
Jak czekać na wiele obietnic?
Wywołaj metodę defer() oferowaną przez usługę $q. Użyj wartości promise zdefiniowanej przez obiekt deferred. Użyj metody then() do rejestracji wywołań zwrotnych. Metoda then() zwraca inną obietnicę, która będzie uwzględniona podczas wykonywania funkcji wywołania zwrotnego. Użyj metody $q.all() do utworzenia obietnicy, która nie będzie spełniona aż do chwili spełnienia wszystkich obietnic jej danych wejściowych.
5i6
11
12 13 14
15
AngularJS. Profesjonalne techniki
Kiedy i dlaczego używać usług Ajax? Ajax to podstawa nowoczesnej aplikacji sieciowej. Usługi omawiane w tym rozdziale będziesz wykorzystywał za każdym razem, gdy wystąpi potrzeba komunikacji z serwerem bez konieczności wczytywania nowej zawartości przez przeglądarkę internetową. Jeżeli dane wykorzystujesz za pomocą API RESTful, to powinieneś sięgać po usługę $resource. API REST i usługa $resource zostaną omówione w rozdziale 21. W tym momencie powinieneś wiedzieć, że usługa $resource zapewnia działające na wysokim poziomie API oparte na usługach omówionych w tym rozdziale i ułatwia wykonywanie najczęściej przeprowadzanych operacji na danych.
Przygotowanie przykładowego projektu W tym rozdziale dodamy nowe pliki do katalogu angularjs. W wielu przykładach będziemy potrzebowali pliku z danymi i dlatego tworzymy nowy o nazwie productData.json wraz z zawartością przedstawioną na listingu 20.1. Listing 20.1. Zawartość pliku productData.json [{ "name": "Jabłka", "category": "Owoce", "price": 1.20, "expiry": 10 }, { "name": "Banany", "category": "Owoce", "price": 2.42, "expiry": 7 }, { "name": "Brzoskwinie", "category": "Owoce", "price": 2.02, "expiry": 6 }, { "name": "Tuńczyk", "category": "Ryby", "price": 20.45, "expiry": 3 }, { "name": "Łosoś", "category": "Ryby", "price": 17.93, "expiry": 2 }, { "name": "Pstrąg", "category": "Ryby", "price": 12.93, "expiry": 4 }]
Plik zawiera pewne informacje o produktach — są to dane podobne do wykorzystywanych we wcześniejszych rozdziałach i wyrażone w formacie JSON (ang. JavaScript Object Notation), który omówiono w rozdziale 5. Format JSON to niezależny od języka programowania sposób przedstawiania danych opracowany dla języka JavaScript. Od dawna jest powszechnie wykorzystywany i obsługiwany przez praktycznie wszystkie najważniejsze języki programowania — tak intensywnie, że zastąpił inne formaty danych, zwłaszcza w aplikacjach sieciowych. Wcześniej jako format wymiany danych był stosowany XML (litera X w akronimie Ajax oznacza właśnie XML). Został jednak wyparty przez JSON, ponieważ JSON jest zwięźlejszy i łatwiejszy w odczycie dla programistów. W przypadku aplikacji sieciowych dodatkową zaletą jest łatwość generowania i przetwarzania danych JSON przez JavaScript, a AngularJS automatycznie zajmuje się formatowaniem i przetwarzaniem danych w tym formacie.
Żądania Ajax Do wykonywania i przetwarzania żądań Ajax jest wykorzystywana usługa $http. Żądania Ajax są standardowymi żądaniami HTTP przeprowadzanymi asynchronicznie. Technologia Ajax stanowi serce nowoczesnych aplikacji sieciowych, a możliwość pobierania w tle zawartości i danych, gdy w tym czasie użytkownik korzysta z pozostałych funkcji aplikacji, stanowi ważny aspekt zapewnienia użytkownikowi jak najlepszych wrażeń podczas pracy z aplikacją. Aby pokazać wykonywanie żądań Ajax za pomocą usługi $http, utworzymy prostą aplikację, która na początku nie zawiera żadnych danych. Na listingu 20.2 przedstawiono zawartość pliku ajax.html, który należy dodać do katalogu angularjs. Listing 20.2. Aplikacja bez danych w pliku ajax.html
Ajax
502
Rozdział 20. Usługi dla technologii Ajax i obietnic
Nazwa | Kategoria | Cena |
---|
Brak danych |
{{name}} | {{category}} | {{price | currency}} |
Wczytaj dane
Ten przykład składa się z tabeli wraz z wierszem używającym dyrektywy ng-hide do kontrolowania jego widoczności na podstawie liczby elementów znajdujących się w tabeli o nazwie products. Wymieniona tablica danych nie jest domyślnie zdefiniowana i dlatego wyświetlany jest komunikat o braku danych. W elemencie znajduje się wiersz wraz z zastosowaną dyrektywą ng-repeat, która wygeneruje wiersz dla każdego obiektu danych product znajdującego się w tablicy. W aplikacji umieściliśmy także przycisk używający dyrektywy ng-click w celu wywołania funkcji kontrolera o nazwie loadData(). Obecnie jest to pusta funkcja, ale w niej będzie wykonywane żądanie Ajax za pomocą usługi $http. Początkowy stan aplikacji pokazano na rysunku 20.1. Na tym etapie kliknięcie przycisku jeszcze nie powoduje żadnego efektu.
Rysunek 20.1. Początkowy stan przykładowej aplikacji
503
AngularJS. Profesjonalne techniki
Zobaczysz aplikację przed użyciem i po użyciu usługi $http, co ma na celu podkreślenie, jak niewielka ilość dodatkowego kodu jest wymagana do wykonania żądania Ajax i przetworzenia odpowiedzi na nie. Na listingu 20.3 przedstawiono dokument ajax.html po jego uzupełnieniu o kod przeznaczony do zastosowania usługi $http. Listing 20.3. Użycie usługi $http w celu utworzenia żądania Ajax w pliku ajax.html
Ajax
Nazwa | Kategoria | Cena |
---|
Brak danych |
{{item.name}} | {{item.category}} | {{item.price | currency}} |
Wczytaj dane
Na listingu zadeklarowaliśmy zależność od usługi $http oraz dodaliśmy trzy wiersze kodu. Jedna z różnic między pracą z technologią Ajax w aplikacjach AngularJS a pracą z, powiedzmy, jQuery polega na tym, że dane pobrane z serwera są umieszczane w zakresie, który następnie automatycznie odświeża operacje dołączania danych, aby tym samym uaktualnić elementy HTML w aplikacji. Dlatego też niepotrzebny jest kod, który w aplikacji jQuery odpowiada za przetworzenie danych i przeprowadzanie operacji na modelu DOM w celu wyświetlenia tych danych. Mimo tego prosty mechanizm wykonywania żądań powinien być znany osobom, które mają doświadczenie w pracy z jQuery. Sama operacja składa się z dwóch etapów — wykonywania żądania i otrzymania odpowiedzi — omówionych w kolejnych punktach.
504
Rozdział 20. Usługi dla technologii Ajax i obietnic
Wykonywanie żądania Ajax Istnieją dwa sposoby wykonywania żądań za pomocą usługi $http. Pierwszy — i najczęściej spotykany — to użycie jednej z wygodnych metod definiowanych przez usługę. Te wygodne metody wymieniono w tabeli 20.2, pozwalają na wykonywanie żądań za pomocą powszechnie stosowanych metod HTTP. Wszystkie metody akceptują opcjonalny obiekt konfiguracyjny, który zostanie omówiony w punkcie „Konfiguracja żądań Ajax” w dalszej części rozdziału. Tabela 20.2. Metody zdefiniowane przez usługę $http w celu utworzenia żądania Ajax Nazwa
Opis
get(url, konfiguracja)
Wykonuje żądanie GET do wskazanego adresu URL.
post(url, dane, konfiguracja)
Wykonuje żądanie POST do wskazanego adresu URL w celu wysłania podanych danych.
delete(url, konfiguracja)
Wykonuje żądanie DELETE do wskazanego adresu URL.
put(url, dane, konfiguracja)
Wykonuje żądanie PUT do wskazanego adresu URL wraz z określonymi danymi.
head(url, konfiguracja)
Wykonuje żądanie HEAD do wskazanego adresu URL.
jsonp(url, konfiguracja)
Wykonuje żądanie GET w celu pobrania fragmentu kodu JavaScript, który następnie będzie wykonany. JSONP, czyli JSON with Padding, to sposób pokonania ograniczenia, jakie przeglądarki internetowe nakładają w zakresie źródła pochodzenia kodu. W tej książce nie znajdziesz opisu JSONP, ponieważ ta technika wiąże się z ogromnym ryzykiem. Więcej informacji na temat JSONP znajdziesz na stronie http://en.wikipedia.org/wiki/JSONP.
Drugi sposób wykonywania żądania Ajax polega na potraktowaniu obiektu usługi $http jako funkcji i przekazaniu jej obiektu konfiguracyjnego. To użyteczne rozwiązanie, gdy wymagane jest zastosowanie metody HTTP, dla której istnieje dostępna metoda wygodna. Przekazujesz wówczas obiekt konfiguracyjny (omówiony w dalszej części rozdziału) zawierający metodę HTTP przeznaczoną do użycia. Właśnie taki sposób wykonywania żądań Ajax będzie pokazany w rozdziale 21., podczas omawiania usług typu RESTful. Natomiast w tym rozdziale koncentrujemy się na metodach wygodnych. Na podstawie informacji przedstawionych w tabeli widać, że żądania GET można wykonywać bez obiektu konfiguracyjnego, jak to zrobiono na listingu 20.3: ... $http.get("productData.json") ...
Jako adres URL podano plik productData.json. Adres URL w takiej postaci jest względny dla głównego dokumentu HTML, co oznacza brak konieczności podawania na stałe w aplikacji protokołu, nazwy hosta i portu.
Metody GET i POST — wybór odpowiedniej Istnieje następująca reguła: żądania GET powinny być używane jedynie w odniesieniu do danych tylko do odczytu, podczas gdy żądania POST — do wszelkich operacji zmieniających stan aplikacji. Wedle standardów żądania GET są przeznaczone dla bezpiecznych operacji (brak efektów ubocznych poza pobraniem danych), natomiast żądania POST — dla niebezpiecznych operacji (podejmowanie decyzji lub zmiana czegokolwiek). Wymienione konwencje zostały określone przez konsorcjum W3C (World Wide Web Consortium). Więcej informacji na ten temat znajdziesz na stronie http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html.
505
AngularJS. Profesjonalne techniki
Żądania GET są adresowalne, czyli wszystkie informacje znajdują się w adresie URL. Istnieje więc możliwość dodania tych adresów do ulubionych i stosowania ich w łączach. Nie należy używać żądań GET do przeprowadzania operacji zmieniających stan. Wielu programistów boleśnie się o tym przekonało w roku 2005, gdy udostępniono publicznie Google Web Accelerator. Wymieniona aplikacja pobierała całą zawartość powiązaną łączami z każdą stroną, co jest dozwolone w HTTP, ponieważ żądania GET powinny dotyczyć jedynie bezpiecznych operacji. Niestety, wielu programistów zignorowało te konwencje HTTP, umieszczając w aplikacjach proste łącza do operacji „usuń element” i „dodaj do koszyka”. Skutkiem zastosowania takich rozwiązań był chaos. Jedna z firm była przekonana, że jej system zarządzania treścią (CMS) był celem nieustannego ataku, ponieważ cała zawartość ciągle była usuwana. Później pracownicy firmy odkryli, że robot silnika wyszukiwarki internetowej, sprawdzający poszczególne adresy URL na stronie administracyjnej, powodował aktywację łączy, między innymi odpowiadających za usunięcie zawartości.
Otrzymywanie odpowiedzi na żądanie Ajax Wykonanie żądania Ajax to tylko pierwsza część procesu; konieczne jest również otrzymanie odpowiedzi, gdy będzie ona gotowa. Litera A w akronimie Ajax oznacza asynchroniczny, czyli przeprowadzanie żądań w tle. Gdy nadejdzie odpowiedź z serwera, aplikacja zostanie o tym poinformowana. AngularJS używa wzorca JavaScript o nazwie obietnica (ang. promise) do przedstawienia wyniku operacji asynchronicznej, takiej jak żądanie Ajax. Obietnica jest obiektem definiującym metody przeznaczone do zarejestrowania funkcji wywoływanych po zakończeniu operacji. Obietnicami zajmiemy się dokładniej w dalszej części rozdziału, podczas omawiania usługi $q. W tym momencie powinieneś wiedzieć, że obiekt obietnicy zwracany przez wymienione w tabeli 20.2 metody usługi $http definiuje metody wymienione w tabeli 20.3. Tabela 20.3. Metody zdefiniowane w obiekcie obietnicy zwracanym przez metody usługi $http Nazwa
Opis
success(fn)
Wywołuje specjalną funkcję, gdy żądanie HTTP zakończy się powodzeniem.
error(fn)
Wywołuje specjalną funkcję, gdy żądanie HTTP nie zakończy się powodzeniem.
then(fn, fn)
Pozwala na rejestrację funkcji success() i error().
Metody success() i error() przekazują ich funkcje uproszczonemu widokowi odpowiedzi z serwera. Funkcja success() zawiera dane otrzymane z serwera, natomiast funkcja error() otrzymuje ciąg tekstowy opisujący błąd, który wystąpił. Co więcej, jeżeli odpowiedź z serwera to dane w formacie JSON, to AngularJS przetworzy je w celu utworzenia obiektów JavaScript, które następnie automatycznie będą przekazane funkcji success(). Tę możliwość wykorzystaliśmy na listingu 20.3 w celu otrzymania danych z pliku productData.json i dodania ich do zakresu: ... $http.get("productData.json").success(function (data) { $scope.products = data; }); ...
W funkcji success() właściwości products przypisywany jest obiekt danych utworzony przez AngularJS na podstawie odpowiedzi JSON. Efektem jest usunięcie w tabeli wiersza z komunikatem o braku danych, a dyrektywa ng-repeat generuje nowy wiersz dla każdego elementu pobranego z serwera, jak pokazano na rysunku 20.2.
506
Rozdział 20. Usługi dla technologii Ajax i obietnic
Rysunek 20.2. Wczytanie danych JSON za pomocą żądania Ajax Wskazówka Wynikiem wykonania metod success() i error() jest obiekt obietnicy, co pozwala na łączenie wymienionych metod w jednym poleceniu.
Uzyskanie dodatkowych szczegółów odpowiedzi Użycie metody then() w obiekcie obietnicy pozwala na rejestrację funkcji success() i error() w jednym wywołaniu metody. Co ważniejsze jednak, zapewnia uzyskanie dostępu do szczegółowych informacji o odpowiedzi uzyskanej z serwera. Obiekt przekazywany przez metodę then() funkcjom success() i error() definiuje właściwości wymienione w tabeli 20.4. Tabela 20.4. Właściwości obiektu przekazywanego przez metodę then() Nazwa
Opis
data
Zwraca dane z żądania.
status
Zwraca kod stanu HTTP podany przez serwer.
headers
Zwraca funkcję, która może być użyta w celu pobrania nagłówków według ich nazw.
config
Obiekt konfiguracji używany do wykonania żądania (więcej informacji znajdziesz w punkcie „Konfiguracja żądań Ajax”).
Na listingu 20.4 przedstawiono sposób wykorzystania metody then() do rejestracji funkcji success() — funkcja error() jest opcjonalna — oraz wyświetlenia w konsoli pewnych informacji szczegółowych o otrzymanej odpowiedzi. Listing 20.4. Przykład użycia metody then() w pliku ajax.html ...
...
Kod przedstawiony na listingu wyświetla w konsoli informacje o kodzie stanu HTTP oraz o zawartości nagłówków Content-Type i Content-Length. Po kliknięciu przycisku generowane są następujące dane wyjściowe: Kod stanu: 200 Typ: application/json Wielkość: 434
AngularJS nadal automatycznie przetwarza dane JSON podczas użycia metody then(), co oznacza możliwość przypisania właściwości products w zakresie kontrolera wartości właściwości data obiektu response.
Przetwarzanie innego typu danych Wprawdzie pobranie danych JSON to najczęstszy sposób użycia usługi $http, ale nie zawsze możemy pracować z danymi w formacie, który jest automatycznie przetwarzany przez AngularJS. W przypadku danych w formatach innych niż JSON AngularJS przekaże funkcji success() obiekt zawierający właściwości wymienione w tabeli 20.4. Twoim zadaniem jest przetworzenie danych. Aby pokazać przykład takiego rozwiązania, tworzymy prosty plik XML o nazwie productData.xml, zawierający takie same informacje o produktach jak w pliku productData.json, ale wyrażone jako fragment dokumentu XML. Zawartość pliku productData.xml przedstawiono na listingu 20.5. Listing 20.5. Zawartość pliku productData.xml
W powyższym pliku XML znajduje się zdefiniowany element products zawierający zbiór elementów product, z których każdy używa wartości atrybutów do opisania jednego produktu. To jest typowy dokument XML; z takimi dokumentami miałem okazję pracować w starszych systemach zarządzania treścią. Dane XML są wyrażone jako fragmenty pozbawione schematu, ale doskonale przygotowane i spójnie wygenerowane. Na listingu 20.6 przedstawiono uaktualnioną wersję dokumentu ajax.html w celu wykonywania żądań Ajax i przetwarzania danych w formacie XML. Listing 20.6. Przykład pracy z danymi w formacie XML w pliku ajax.html ...
...
Formaty XML i HTML są ze sobą dość blisko związane — tak bardzo, że istnieje wersja specyfikacji HTML o nazwie XHTML, która jest zgodna z XML. Praktyczny efekt wspomnianego podobieństwa to możliwość użycia jqLite do przetwarzania fragmentów XML tak, jakby były kodem HTML. Tego rodzaju rozwiązanie przedstawiono w omówionym powyżej przykładzie. Właściwość data obiektu przekazywanego funkcji success() zwraca zawartość pliku XML. Następnie opakowujemy ją obiektem jqLite, używając metody angular.element(). Później metoda find() jest wykorzystywana do odszukania elementów product, a pętla for do pobrania ich i wyodrębnienia wartości atrybutów. Wszystkie metody jqLite zostały omówione w rozdziale 15.
Konfiguracja żądań Ajax Metody definiowane przez usługę $http akceptują opcjonalny argument w postaci obiektu zawierającego ustawienia konfiguracyjne. W większości aplikacji domyślna konfiguracja żądań Ajax będzie sprawdzała się doskonale. Zawsze istnieje możliwość zmiany sposobu wykonywania żądań przez zdefiniowanie w obiekcie konfiguracyjnym właściwości wymienionych w tabeli 20.5. Tabela 20.5. Właściwości konfiguracyjne metod $http Nazwa
Opis
data
Pozwala na określenie danych wysyłanych do serwera. Jeżeli wskażesz obiekt, to AngularJS przeprowadzi jego serializację do formatu JSON.
headers
Właściwość wykorzystywana w celu konfiguracji nagłówków żądania. Przypisz jej obiekt headers wraz z właściwościami o nazwach i wartościach odpowiadających nagłówkom i wartościom, które mają być dodane do żądania.
method
Wskazanie metody HTTP użytej w żądaniu.
params
Właściwość wykorzystywana do ustawiania parametrów adresu URL. Przypisz jej obiekt params wraz z właściwościami o nazwach i wartościach odpowiadających parametrom, które mają być dodane.
timeout
Określa liczbę milisekund, zanim żądanie wygaśnie.
transformRequest
Właściwość wykorzystywana do operowania na żądaniu przed jego wysłaniem do serwera (patrz nieco dalej w rozdziale).
transformResponse
Właściwość wykorzystywana do operowania na odpowiedzi po jej otrzymaniu z serwera (patrz nieco dalej w rozdziale).
509
AngularJS. Profesjonalne techniki
Tabela 20.5. Właściwości konfiguracyjne metod $http — ciąg dalszy Nazwa
Opis
url
Pozwala na określenie adresu URL dla żądania.
withCredentials
Po przypisaniu wartości true następuje włączenie opcji withCredentials w obiekcie żądania przeglądarki internetowej, co powoduje dołączenie do żądania cookie uwierzytelnienia. Przykład użycia tej właściwości przedstawiono w rozdziale 8.
xsrfHeaderName
Te właściwości są używane w odpowiedzi na tokeny CSRF, które mogą być wymagane przez serwery. Więcej informacji na ten temat znajdziesz na stronie http://pl.wikipedia.org/wiki/Cross-site_request_forgery.
xsrfCookieName
Najbardziej interesującą funkcją konfiguracyjną jest możliwość transformacji żądania i odpowiedzi za pomocą właściwości o nazwach transformRequest i transformResponse. AngularJS definiuje dwie wbudowane transformacje — dane wychodzące są serializowane na postać JSON, natomiast dane przychodzące są przekształcane na postać obiektów JavaScript.
Transformacja odpowiedzi Transformację odpowiedzi można przeprowadzić przez przypisanie funkcji właściwości transformResponse obiektu konfiguracyjnego. Wspomniana funkcja transformacji otrzymuje dane odpowiedzi; można ją wykorzystać w celu pobrania wartości nagłówka. Funkcja jest odpowiedzialna za zwrot przekształconych danych, czyli najczęściej deserializowanej wersji formatu zastosowanego przez serwer. Na listingu 20.7 pokazano, jak można wykorzystać funkcję transformacji do przeprowadzenia automatycznej deserializacji danych XML znajdujących się w pliku productData.xml. Listing 20.7. Przetwarzanie odpowiedzi w pliku ajax.html ...
...
Na listingu sprawdziliśmy wartość nagłówka Content-Type, aby upewnić się, że pracujemy z danymi XML. Ponadto sprawdzamy, czy dane mają postać ciągu tekstowego. Istnieje możliwość przypisania wielu funkcji transformacji z wykorzystaniem tablicy (lub dostawcy usługi $http, który zostanie omówiony w dalszej części rozdziału). Dlatego też ważne jest zagwarantowanie, że funkcja transformacji operuje na danych w oczekiwanym formacie. Ostrzeżenie Na listingu zastosowano pewien skrót. W kodzie przyjęto założenie, że wszystkie dane XML dostarczone przez żądanie zawierają elementy product wraz z elementami name, category i price. To jest rozsądne podejście w przykładzie przedstawionym w książce. Jednak w rzeczywistych projektach należy zachować większą ostrożność i sprawdzać, czy otrzymane dane są w oczekiwanym formacie.
Po upewnieniu się o otrzymaniu danych w formacie XML używamy przedstawionej wcześniej techniki jqLite do przetworzenia danych XML na postać obiektów JavaScript, które będą zwrócone przez funkcję transformacji. Dzięki przedstawionej transformacji danych XML nie trzeba przetwarzać w funkcji success(). Wskazówka Zwróć uwagę na zwrot danych początkowych, jeśli odpowiedź nie zawiera danych XML lub nie są one w postaci ciągu tekstowego. To jest bardzo ważne, ponieważ wartość zwrotna funkcji transformacji będzie przekazana do funkcji obsługi success().
Transformacja żądania Transformację żądania można przeprowadzić przez przypisanie funkcji właściwości transformRequest obiektu konfiguracyjnego. Funkcja otrzymuje dane wysyłane do serwera i zwraca wartości nagłówka (wiele nagłówków może być ustawionych przez przeglądarkę internetową przed wykonaniem żądania). Wartość zwrotna funkcji będzie wykorzystana w żądaniu, które dostarcza serializowane dane. Na listingu 20.8 przedstawiono funkcję transformacji, która serializuje dane produktu na postać XML. Wskazówka Nie musisz używać funkcji transformacji, jeśli chcesz wysłać dane JSON, ponieważ AngularJS automatycznie przeprowadzi ich serializację.
Listing 20.8. Przykład zastosowania funkcji transformacji żądania w pliku ajax.html
Ajax
Nazwa | Kategoria | Cena |
---|
Brak danych |
{{item.name}} | {{item.category}} | {{item.price | currency}} |
Wczytaj dane
Wyślij dane
W kodzie umieściliśmy element wykorzystujący dyrektywę ng-click w celu wywołania funkcji o nazwie sendData() po kliknięciu przycisku. Wymieniona funkcja definiuje obiekt konfiguracyjny wraz z funkcją transformacji wykorzystującą XML do wygenerowania XML na podstawie danych żądania. (Najpierw musisz kliknąć przycisk Wczytaj dane, aby pobrać dane z pliku; dopiero wtedy będziesz mógł je wysłać z powrotem do serwera). 512
Rozdział 20. Usługi dla technologii Ajax i obietnic
Użycie jqLite do wygenerowania XML W rzeczywistych projektach prawdopodobnie nie będziesz chciał używać jqLite do generowania XML, ponieważ istnieją doskonałe biblioteki JavaScript przeznaczone właśnie do tego celu. Jeżeli jednak trzeba wygenerować niewielką ilość danych XML i nie chcesz dodawać nowej zależności do projektu, to jqLite sprawdzi się dobrze, o ile będziesz świadom istnienia kilku sztuczek. Przede wszystkim trzeba używać znaków < i > w nazwach tagów podczas tworzenia nowego elementu: ... angular.element("") ...
Jeżeli pominiesz znaki < i >, to jqLite zgłosi wyjątek wraz z komunikatem o braku możliwości wyszukania elementów za pomocą selektorów. Kolejna sztuczka jest związana z pobieraniem przygotowanych danych XML. jqLite ułatwia pobieranie zawartości elementu, ale nie samego elementu. Dlatego też trzeba utworzyć fikcyjny element, na przykład: ... var rootElem = angular.element(""); ...
Zwykle decyduję się na tag xml, ale to tylko moje preferencje — wskazany przez Ciebie element nie będzie znajdował się w ostatecznych danych wyjściowych. Gdy już będzie można pobrać ciąg tekstowy XML na podstawie danych, użyj metody wrap() w celu wstawienia wymaganego elementu głównego, a następnie wywołaj metodę html() względem fikcyjnego elementu: ... rootElem.children().wrap("").html(); return rootElem.html(); ...
W ten sposób otrzymasz dane XML w postaci elementu products zawierającego wiele elementów product. Sam element nie będzie znajdował się w ostatecznych danych wyjściowych.
Dane do serwera są wysyłane za pomocą metody $http.post(). Celem jest adres URL dokumentu ajax.html, ale dane zostaną zignorowane przez serwer, który po prostu ponownie odeśle zawartość pliku ajax.html. Ponieważ nie jesteśmy zainteresowani zawartością pliku HTML, nie definiujemy funkcji success() i error(). Wskazówka Zwróć uwagę na wyraźne przypisanie nagłówkowi Content-Type wartości application/xml w obiekcie konfiguracyjnym. AngularJS nie wie, jak funkcja transformacji przeprowadziła serializację danych, i dlatego musi starannie zdefiniować nagłówek. Jeżeli o tym zapomnisz, serwer może nie przetworzyć poprawnie żądania.
Ustawienie wartości domyślnych żądania Ajax Wartości domyślne ustawień żądania Ajax można zdefiniować za pomocą dostawcy usługi $http, czyli $httpProvider. Właściwości oferowane przez dostawcę wymieniono w tabeli 20.6. Wskazówka Dostęp do obiektu defaults, w którym definiowanych jest wiele z wymienionych właściwości, można uzyskać także przez właściwość $http.defaults. Pozwala ona na zmianę globalnej konfiguracji Ajax za pomocą usługi.
513
AngularJS. Profesjonalne techniki
Tabela 20.6. Właściwości zdefiniowane przez dostawcę $httpProvider Nazwa
Opis
defaults.headers.common
Zdefiniowanie nagłówków domyślnych używanych we wszystkich żądaniach.
defaults.headers.post
Zdefiniowanie nagłówków domyślnych używanych w żądaniach POST.
defaults.headers.put
Zdefiniowanie nagłówków domyślnych używanych w żądaniach PUT.
defaults.transformResponse
Tablica funkcji transformacji, które są stosowane we wszystkich odpowiedziach.
defaults.transformRequest
Tablica funkcji transformacji, które są stosowane we wszystkich żądaniach.
interceptors
Tablica funkcji fabryki interceptora. Tego rodzaju funkcja stanowi znacznie bardziej zaawansowaną formę funkcji transformacji. Sposób ich działania zostanie omówiony w kolejnym punkcie.
withCredentials
Ustawia opcję withCredentials dla wszystkich żądań. Ta właściwość jest używana w przypadku żądań CSRF wymagających uwierzytelnienia. Przykład użycia tej właściwości przedstawiono w rozdziale 8.
Właściwości defaults.transformResponse i defaults.transformRequest są przydatne podczas stosowania funkcji transformacji we wszystkich żądaniach Ajax wykonywanych przez aplikację. Wspomniane właściwości są definiowane w postaci tablic, czyli dodanie wartości musi odbywać się za pomocą metody push(). Na listingu 20.9 przedstawiono użytą wcześniej funkcję deserializacji XML, ale zmodyfikowaną do wykorzystania $httpProvider. Listing 20.9. Ustawianie globalnej funkcji transformacji odpowiedzi w pliku ajax.html ...
...
Użycie interceptorów Ajax Dostawca $httpProvider oferuje funkcję o nazwie interceptor żądania. Najlepszym sposobem wyrażenia tej funkcji jest zaawansowana alternatywa funkcji transformacji. Na listingu 20.10 przedstawiono przykład użycia interceptora w pliku ajax.html. Listing 20.10. Przykład użycia interceptora w pliku ajax.html
Ajax
Nazwa | Kategoria | Cena |
---|
Brak danych |
{{item.name}} |
515
AngularJS. Profesjonalne techniki {{item.category}} | {{item.price | currency}} |
Wczytaj dane
Właściwość $httpProvider.interceptor to tablica; umieszcza się w niej funkcje fabryki zwracające obiekty wraz z właściwościami wymienionymi w tabeli 20.7. Poszczególne właściwości odpowiadają różnym typom interceptorów, a funkcje przypisane właściwościom mają możliwość zmiany żądania lub odpowiedzi. Tabela 20.7. Właściwości interceptora Nazwa
Opis
request
Funkcja interceptora jest wywoływana przed wykonaniem żądania i przekazywana obiektowi konfiguracyjnemu, który definiuje właściwości wymienione w tabeli 20.5.
requestError
Funkcja interceptora jest wywoływana, gdy poprzedni interceptor request zgłasza błąd.
response
Funkcja interceptora jest wywoływana po otrzymaniu odpowiedzi i przekazywana obiektowi odpowiedzi, który definiuje właściwości wymienione w tabeli 20.4.
responseError
Funkcja interceptora jest wywoływana, gdy poprzedni interceptor response zgłasza błąd.
W omawianym przykładzie obiekt generowany przez metodę fabryki zawiera zdefiniowane właściwości request i response. Funkcja przypisana właściwości request pokazuje, jak interceptor może zmienić żądanie
przez wymuszenie, aby adres URL wskazywał plik productData.json, niezależnie od wartości przekazanej metodzie usługi $http. W tym celu zdefiniowaliśmy właściwość url w obiekcie konfiguracyjnym, jest ona zwracana przez funkcję i może być przekazana do kolejnego interceptora. Jeżeli nasz interceptor jest ostatni w tablicy, to będzie wykonane wskazane żądanie. Jeśli chodzi o interceptor response, w kodzie pokazano, jak funkcję można wykorzystać do debugowania odpowiedzi udzielonej przez serwer — tutaj interceptory sprawdzają się doskonale i są najbardziej użyteczne — przez wyszukanie właściwości data w obiekcie odpowiedzi i wyświetlenie informacji o liczbie obiektów w odpowiedzi. Przygotowany tutaj interceptor response opiera się na tym, że AngularJS używa interceptorów do przetworzenia danych JSON. Dlatego też sprawdzamy format danych: tablica obiektów czy ciąg tekstowy. Tego raczej nie robi się w rzeczywistych projektach, moim celem było pokazanie, że AngularJS przetwarza odpowiedź przed zastosowaniem interceptorów.
Obietnice Obietnica to sposób wyrażenia zainteresowania czymś, co wydarzy się w przyszłości, na przykład odpowiedzią otrzymaną z serwera na żądanie Ajax. Obietnice nie są unikalne dla AngularJS; znajdziesz je w wielu różnych bibliotekach, między innymi jQuery. Między poszczególnymi implementacjami istnieją pewne rozbieżności wynikające z różnic w filozofii projektu lub preferencjach programistów. Obietnica wymaga dwóch obiektów. Pierwszy to promise, używany do otrzymywania powiadomień o przyszłym wyniku. Drugi to deferred, używany do wysyłania powiadomień. Najłatwiejszy sposób ustalania obietnicy wiąże się zazwyczaj z określonego rodzaju zdarzeniem. Obiekt deferred jest używany do wysyłania zdarzeń za pomocą obiektów promise i dotyczących wyniku pewnego zadania lub czynności.
516
Rozdział 20. Usługi dla technologii Ajax i obietnic
Obietnica może być użyta do przedstawienia czegokolwiek, co wydarzy się w przyszłości. Najlepszy sposób pokazania tej elastyczności to analiza przykładu. Jednak zamiast pokazywać kolejne żądanie Ajax, zachowamy prostotę i wykorzystamy kliknięcia przycisków. Na listingu 20.11 przedstawiono zawartość pliku promises.html, który należy dodać do katalogu angularjs. To jest początkowa implementacja aplikacji, do której dodamy obietnice. Obecnie jest to więc po prostu zwykła aplikacja AngularJS. Listing 20.11. Zawartość pliku promises.html
Obietnice
Początek Koniec Przerwij Wynik:
Ta niezwykle prosta aplikacja zawiera przyciski Początek, Koniec i Przerwij, a także polecenie osadzonego dołączania danych dla właściwości outcome. Obiekty deferred i promise wykorzystamy do powiązania przycisków w taki sposób, że kliknięcie dowolnego z nich spowoduje uaktualnienie właściwości outcome. Przy okazji dowiesz się, dlaczego obietnice nie są jak zwykłe zdarzenia. Na rysunku 20.3 pokazano dokument promises.html wyświetlony w przeglądarce internetowej.
Rysunek 20.3. Początkowy stan przykładowej aplikacji AngularJS oferuje usługę $q przeznaczoną do pobierania obietnic i zarządzania nimi, co odbywa się za pośrednictwem metod wymienionych w tabeli 20.8. W kolejnym punkcie na podstawie budowanej aplikacji dowiesz się, jak działa usługa $q.
517
AngularJS. Profesjonalne techniki
Tabela 20.8. Metody zdefiniowane przez usługę $q Nazwa
Opis
all(obietnice)
Zwraca obietnicę uwzględnianą po uwzględnieniu wszystkich obietnic we wskazanej tablicy lub odrzuceniu dowolnej z nich.
defer()
Tworzy obiekt deferred.
reject(powód)
Zwraca obietnicę, która zawsze będzie odrzucana.
when(wartość)
Opakowuje wartość w obietnicy, która zawsze jest uwzględniana (wskazana wartość jest wynikiem).
Pobieranie i użycie obiektu deferred W tym przykładzie zobaczysz obie strony obietnicy. Trzeba utworzyć obiekt deferred przeznaczony do informowania o wyniku kliknięcia dowolnego przycisku przez użytkownika. Obiekt ten jest tworzony wywołaniem metody $q.defer() i definiuje właściwości oraz metody wymienione w tabeli 20.9. Tabela 20.9. Elementy składowe zdefiniowane w obiekcie deferred Nazwa
Opis
resolve(wynik)
Sygnalizuje, że odroczone zadanie zostało zakończone wraz z określoną wartością.
reject(powód)
Sygnalizuje, że odroczone zadanie nie zostało zakończone sukcesem z określonego powodu.
notify(wynik)
Dostarcza tymczasowy wynik odroczonego zadania.
promise
Zwraca obiekt promise, który otrzymuje sygnały od innych metod.
Podstawowy sposób pracy polega na pobraniu obiektu deferred, a następnie wywołaniu metody resolve() lub reject() w celu zasygnalizowania wyniku czynności. Opcjonalnie można dostarczyć wynik tymczasowy za pomocą metody notify(). Na listingu 20.12 przedstawiono zmodyfikowaną wersję przykładu — dodano dyrektywę używającą obiektu deferred. Listing 20.12. Praca z obiektami deferred w pliku promises.html
Obietnice
Początek Koniec Przerwij Wynik:
Nowa dyrektywa nosi nazwę promiseWorker i opiera się na usłudze $q. W funkcji fabryki następuje wywołanie metody $q.defer() w celu pobrania nowego obiektu deferred, do którego będziemy się odwoływać z poziomu funkcji link i compiler. Funkcja link używa jqLite w celu wyszukania elementów , a następnie rejestruje funkcję wywołania zwrotnego dla zdarzenia click. W procedurze obsługi zdarzenia sprawdzamy tekst klikniętego elementu, a następnie wywołujemy odpowiednią metodę obiektu deferred — resolve() dla przycisków Początek lub Koniec i reject() dla przycisku Przerwij. Kontroler definiuje właściwość promise mapującą właściwość o tej samej nazwie w obiekcie deferred. Zanim wymieniona właściwość zostanie udostępniona za pośrednictwem kontrolera, można pozwolić innym dyrektywom na pobieranie obiektu promise powiązanego z obiektem deferred oraz otrzymywanie sygnałów dotyczących wyniku. Wskazówka Obiekt promise powinien być udostępniany tylko innym fragmentom aplikacji, natomiast deferred powinien pozostać poza zasięgiem innych komponentów. W przeciwnym razie skutkiem będzie nieoczekiwane uwzględnianie lub odrzucanie obietnic. To m.in. dlatego obiekt deferred na listingu 20.12 został przypisany w funkcji fabryki, a właściwość promise została dostarczona jedynie przez kontroler.
Użycie obietnicy Omawiana aplikacja działa w taki sposób, że obiekt deferred jest wykorzystywany do sygnalizowania wyniku kliknięcia przycisku przez użytkownika, ale jeszcze żaden komponent nie otrzymuje tych sygnałów. Kolejnym krokiem jest więc dodanie dyrektywy monitorującej wynik; dokonamy tego za pomocą obietnicy utworzonej w poprzednim przykładzie i przez uaktualnienie elementu . Na listingu 20.13 przedstawiono zmodyfikowaną wersję dokumentu promises.html, w którym dodano nową dyrektywę o nazwie promiseObserver. Listing 20.13. Przykład użycia obietnicy w pliku promises.html
Obietnice
519
AngularJS. Profesjonalne techniki
Początek Koniec Przerwij Wynik:
Nowa dyrektywa używa definicji właściwości require w celu pobrania kontrolera z innej dyrektywy oraz obiektu promise. Ten obiekt promise definiuje metody wymienione w tabeli 20.10. Wskazówka Zwróć uwagę na brak definiowania metod success() i error() przez obiekty promise używane we wcześniejszych przykładach żądań Ajax w rozdziale. Dzięki metodom wygodnym korzystanie z usługi $http jest łatwiejsze.
520
Rozdział 20. Usługi dla technologii Ajax i obietnic
Tabela 20.10. Metody zdefiniowane przez obiekt obietnicy Nazwa
Opis
then(sukces, błąd, powiadomienie)
Rejestruje funkcję wywoływaną w odpowiedzi na metody resolve(), reject() i notify() obiektu deferred. Funkcje przekazywane jako argumenty są nazywane metodami obiektu deferred.
catch(błąd)
Rejestruje funkcję obsługi błędów. Funkcja przekazywana jako argument jest nazywana metodą reject() obiektu deferred.
finally(funkcja)
Rejestruje funkcję wywoływaną niezależnie od tego, czy obietnica została uwzględniona, czy odrzucona. Funkcja przekazywana jako argument jest nazywana metodą resolve() lub reject() obiektu deferred.
Na listingu zastosowaliśmy metodę then() do zarejestrowania funkcji wywoływanych w odpowiedzi na wywołanie metod resolve() i reject() powiązanych z obiektem deferred. Obie wymienione funkcje uaktualniają zawartość elementu, w którym zastosowano dyrektywę. Ogólny efekt wprowadzonych zmian można zobaczyć po wczytaniu dokumentu promises.html w przeglądarce internetowej i kliknięciu dowolnego przycisku, jak pokazano na rysunku 20.4.
Rysunek 20.4. Użycie obiektu deferred i obietnic
Dlaczego obietnice nie są zwykłymi zdarzeniami? Na tym etapie być może zastanawiasz się, dlaczego zadaliśmy sobie tyle trudu w celu utworzenia obiektów deferred i promise, osiągając coś, co można łatwo zrobić za pomocą zwykłej procedury obsługi JavaScript.
To prawda, że obietnice pełnią tę samą podstawową funkcję — pozwalają komponentowi wskazać, że oczekuje on na powiadomienia o pewnych zdarzeniach w przyszłości, takich jak kliknięcie przycisku lub nadejście z serwera odpowiedzi na żądanie Ajax. Obietnice i zwykłe zdarzenia oferują możliwości pozwalające na zarejestrowanie funkcji wywoływanych po wystąpieniu czegoś w przyszłości (ale nie wcześniej). Oczywiście w omawianym powyżej przykładzie kliknięcia przycisków można obsłużyć za pomocą zwykłych zdarzeń lub nawet dyrektywy ng-click opierającej się na zwykłych zdarzeniach, choć ukrywającej związane z tym szczegóły. Kiedy zaczniesz zagłębiać się w różnice między obietnicami i zdarzeniami, to role odgrywane przez nie w aplikacji AngularJS staną się bardziej widoczne. W poniższych punktach dowiesz się, na czym polegają różnice między obietnicami i zdarzeniami.
Użyj raz i odrzuć Obietnica przedstawia pojedynczy egzemplarz czynności. Uwzględniona lub odrzucona obietnica nie może być ponownie użyta. Możesz się o tym przekonać, wczytując dokument promises.html w przeglądarce internetowej, klikając przycisk Początek i później Koniec. Kliknięcie pierwszego przycisku spowoduje
521
AngularJS. Profesjonalne techniki
wyświetlenie wyniku w postaci ciągu tekstowego Początek. Kliknięcie drugiego przycisku nie wywołuje żadnego efektu, ponieważ w tym przykładzie obietnica została już uwzględniona i nie może być użyta ponownie. Zdefiniowana obietnica pozostaje niezmienna. To jest bardzo ważne — sygnał wysyłany do obserwatora oznacza: „To jest pierwsze kliknięcie przycisku Początek, Koniec lub Przerwij”. Jeżeli użyjemy zwykłego zdarzenia click JavaScript, to każde zdarzenie oznacza „użytkownik kliknął przycisk” bez kontekstu wskazującego, które to jest kliknięcie przycisku, a ponadto nie wiadomo, co to kliknięcie oznacza w kategoriach decyzji podejmowanych przez użytkownika. To niezwykle ważna różnica, która sprawia, że obietnice są szczególnie przydatne do sygnalizowania wyniku określonych czynności, podczas gdy zdarzenia sygnalizują wynik, który może się powtórzyć lub być inny. Innymi słowy, obietnica jest znacznie precyzyjniejsza, gdyż sygnalizuje wynik pojedynczej czynności, którą może być decyzja podjęta przez użytkownika, lub uzyskanie odpowiedzi na określone żądanie Ajax.
Sygnalizacja wyniku Zdarzenia pozwalają na wysłanie sygnału po wydarzeniu się czegoś, na przykład gdy zostanie kliknięty przycisk. Obietnica może być używana w ten sam sposób, a także może być wykorzystywana do zasygnalizowania braku wyniku. Wspomniany brak wyniku może być skutkiem niezakończenia czynności lub zakończenia jej niepowodzeniem, gdy nastąpi wywołanie metody reject() obiektu deferred. W takim przypadku zostanie wykonana funkcja wywołania zwrotnego zarejestrowana w obiekcie promise. Możesz się o tym przekonać w omawianym przykładzie — kliknięcie przycisku Przerwij powoduje wywołanie reject(), co z kolei wyświetla komunikat informujący o braku decyzji użytkownika. Możliwość zasygnalizowania, że czynność nie nastąpiła lub zakończyła się niepowodzeniem, oznacza zachowanie wpływu na wygląd wyniku, co jest ważne w czynnościach takich jak wykonywanie żądań Ajax, gdy chcesz poinformować użytkownika o problemie.
Łączenie obietnic ze sobą Zachowanie wpływu na wygląd odpowiedzi, nawet jeśli czynność nie została wykonana, prowadzi nas do jednej z najlepszych funkcji obietnic, jaką jest możliwość ich łączenia w celu przygotowania złożonych wyników. To jest możliwe, ponieważ metody definiowane przez obiekt promise, na przykład then(), zwracają inny obiekt promise, uwzględniany, gdy zostanie zakończone działanie funkcji wywołania zwrotnego. Na listingu 20.14 przedstawiono prosty przykład zastosowania metody then() do połączenia obietnic ze sobą. Listing 20.14. Przykład łączenia obietnic ze sobą w pliku promises.html ...
...
W funkcji link dyrektywy promiseObserver pobieramy obietnicę, a następnie wywołujemy metodę then() w celu rejestracji funkcji wywołania zwrotnego wykonywanej po uwzględnieniu obietnicy. Wartością zwrotną metody then() jest inny obiekt promise, który będzie uwzględniony po wykonaniu funkcji wywołania zwrotnego. Metoda then() jest używana ponownie do rejestracji funkcji wywołania zwrotnego drugiego obiektu promise. Wskazówka Aby zachować prostotę, listing nie zawiera procedury obsługi dla sytuacji, gdy obietnica zostanie odrzucona. Oznacza to, że ten przykład reaguje tylko na kliknięcia przycisków Początek lub Koniec.
Zwróć uwagę, że pierwsza funkcja wywołania zwrotnego zwraca wynik w następujący sposób: ... ctrl.promise.then(function (result) { return "Sukces (" + result + ")"; }).then(function(result) { element.text(result); }); ...
Podczas łączenia obietnic można operować wynikiem przekazywanym do kolejnej obietnicy w łańcuchu. W omawianym przykładzie przeprowadziliśmy proste formatowanie ciągu tekstowego wyniku, a następnie przekazujemy ten wynik następnej funkcji wywołania zwrotnego w łańcuchu. Oto sekwencja występująca po kliknięciu przez użytkownika przycisku Początek: 1. Funkcja link dyrektywy promiseWorker wywołuje metodę resolve() obiektu deferred i przekazuje wynik w postaci ciągu tekstowego Początek. 2. Obietnica zostaje uwzględniona, następuje wywołanie jej funkcji success() i przekazanie wartości Początek. 3. Funkcja wywołania zwrotnego formatuje wartość Początek i zwraca sformatowany ciąg tekstowy. 4. Następuje uwzględnienie drugiej obietnicy, wywołanie jej funkcji success() i przekazanie sformatowanego ciągu tekstowego funkcji wywołania zwrotnego. 5. Funkcja wywołania zwrotnego wyświetla w elemencie HTML sformatowany ciąg tekstowy. To jest bardzo ważne, jeśli chcesz otrzymać efekt domina czynności, gdy każda czynność w łańcuchu zależy od wyniku poprzedniej. Przedstawiony tutaj przykład formatowania ciągu tekstowego nie jest
523
AngularJS. Profesjonalne techniki
szczególnie wymagający pod tym względem, ale wyobraź sobie wykonywanie żądania Ajax w celu pobrania adresu URL usługi, przekazania go jako wyniku do następnej obietnicy w łańcuchu, której funkcja wywołania zwrotnego używa otrzymanego adresu URL do pobrania pewnych danych.
Grupowanie obietnic Łańcuchy obietnic są użyteczne podczas przeprowadzania sekwencji czynności. Zdarzają się jednak sytuacje, w których daną czynność trzeba odroczyć aż do chwili otrzymania kilku innych wyników. W takim przypadku można wykorzystać metodę $q.all(), która akceptuje tablicę obietnic i zwraca obietnicę nieuwzględnioną aż do chwili uwzględnienia wszystkich obietnic danych wejściowych. Na listingu 20.15 przedstawiono omawianą aplikację rozbudowaną o użycie metody all(). Listing 20.15. Grupowanie obietnic w pliku promises.html
Obietnice
Początek Koniec Przerwij
Tak Nie Przerwij
Wynik:
W omawianym przykładzie mamy dwie grupy przycisków pozwalających użytkownikowi na otrzymanie wyniku Początek/Koniec i Tak/Nie. W dyrektywie promiseWorker tworzymy tablicę obiektów deferred oraz tablicę odpowiadających im obiektów promise. Obiekt promise, który jest udostępniany przez kontroler, jest określany za pomocą następującego wywołania metody $q.all(): ... this.promise = $q.all(promises).then(function (results) { return results.join(); }); ...
Wartością zwrotną metody all() jest obiekt promise, który nie będzie uwzględniony aż do chwili uwzględnienia wszystkich obietnic danych wejściowych (to zbiór wszystkich obiektów promise w tablicy promises); ale wartość ta zostanie odrzucona w przypadku odrzucenia dowolnej z obietnic danych wejściowych. Obiekt promise będzie pobierany przez dyrektywę promiseObserver i obserwowany przez rejestrację funkcji wywołań zwrotnych success() i error(). Aby zobaczyć efekt wprowadzonych zmian, wczytaj dokument promises.html w przeglądarce internetowej, kliknij przycisk Początek lub Koniec, a następnie przycisk Tak lub Nie. Po dokonaniu drugiego wyboru nastąpi wyświetlenie wyniku, jak pokazano na rysunku 20.5.
Rysunek 20.5. Grupowanie obietnic Obietnica utworzona za pomocą wywołania metody $q.all() przekazuje tablicę funkcji success() zawierającej wyniki z poszczególnych elementów . Wyniki są w takiej samej kolejności jak obietnice. To oznacza, że ciąg tekstowy Początek/Koniec zawsze będzie pojawiał się jako pierwszy. W omawianym przykładzie używamy standardowej metody JavaScript o nazwie join() do konkatenacji wyników i przekazania ich do kolejnego ogniwa w łańcuchu. Jeżeli dokładnie spojrzysz na kod listingu, dostrzeżesz istnienie pięciu obietnic. 525
AngularJS. Profesjonalne techniki
1. 2. 3. 4. 5.
Obietnica uwzględniona, gdy użytkownik kliknie przycisk Początek lub Koniec. Obietnica uwzględniona, gdy użytkownik kliknie przycisk Tak lub Nie. Obietnica uwzględniona po uwzględnieniu obietnic wymienionych w punktach 1. i 2. Obietnica, której wywołanie zwrotne używa metody join() w celu konkatenacji wyników. Obietnica, której wywołanie zwrotne wyświetla w elemencie HTML zebrane wyniki.
Warto w tym miejscu dodać, że skomplikowane łańcuchy obietnic mogą spowodować wiele zamieszania. Poniżej przedstawiono przykład sekwencji czynności odnoszących się do poprzedniej listy obietnic. (Przyjęto założenie, że najpierw użytkownik klika przycisk Początek lub Koniec, choć sekwencja będzie taka sama, jeśli na początku zostanie kliknięty przycisk Tak lub Nie). 1. Użytkownik klika przycisk Początek lub Koniec, następuje uwzględnienie obietnicy 1. 2. Użytkownik klika przycisk Tak lub Nie, następuje uwzględnienie obietnicy 2. 3. Uwzględnienie obietnicy 3. następuje bez konieczności jakiejkolwiek akcji ze strony użytkownika. Do funkcji wywołania zwrotnego success() przekazywana jest tablica zawierająca wyniki wcześniejszych obietnic. 4. W funkcji success() metoda join() zostaje użyta do przygotowania pojedynczego wyniku. 5. Uwzględniona zostaje obietnica 4. 6. Uwzględniona zostaje obietnica 5. 7. Wywołanie zwrotne success() obietnicy 5. uaktualnia element HTML. W ten sposób zobaczyłeś, jak prosty przykład może doprowadzić do powstania skomplikowanych połączeń i łańcuchów obietnic. Na początku to może wydawać się przygniatające, ale gdy przywykniesz do pracy z obietnicami, to szybko docenisz oferowaną przez nie precyzję i elastyczność, co jest szczególnie cenne w skomplikowanych aplikacjach.
Podsumowanie W tym rozdziale omówiono usługi $http i $q używane do, odpowiednio, wykonywania żądań Ajax i zarządzania obietnicami. Obie wymienione usługi są ściśle ze sobą związane, co wynika z asynchronicznej natury żądań Ajax. Ponadto stanowią one podstawę dla pewnych usług działających na wysokim poziomie, które poznasz w kolejnych rozdziałach. Dotyczy to między innymi usługi zapewniającej dostęp do usług typu RESTful, czym zajmiemy się w następnym rozdziale.
526
ROZDZIAŁ 21
Usługi dla REST W tym rozdziale zobaczysz, jak AngularJS obsługuje pracę z usługami sieciowymi typu RESTful. Representational State Transfer (REST) to styl API operującego na żądaniach HTTP; z tym API spotkałeś się już w rozdziale 3. Adres URL żądania wskazuje dane, na których będą przeprowadzane operacje, natomiast metoda HTTP określa rodzaj wykonywanej operacji. REST to styl API, a nie zdefiniowana specyfikacja. Istnieją więc pewne rozbieżności w zakresie tego, co można, a czego nie można określić mianem RESTful. Samo wyrażenie jest używane do wskazania API stosującego styl REST. AngularJS oferuje dużą elastyczność w zakresie sposobu użycia usług sieciowych typu RESTful. W tym rozdziale zobaczysz, jak dostosować AngularJS do pracy z określonymi implementacjami REST. Nie przejmuj się, jeśli nie znasz REST lub nie miałeś wcześniej okazji pracować z usługą sieciową typu RESTful. Na początku zbudujemy prostą usługę REST, a następnie omówimy wiele przykładów pokazujących sposoby jej użycia. Podsumowanie materiału zamieszczonego w rozdziale przedstawiono w tabeli 21.1. Tabela 21.1. Podsumowanie materiału przedstawionego w rozdziale Problem
Rozwiązanie
Listing
Jak użyć API RESTful za pomocą jawnych żądań Ajax?
W celu wykonania żądania dotyczącego danych z serwera i przeprowadzania na nich operacji użyj usługi $http.
od 1 do 8
Jak wykorzystać API RESTful bez użycia żądań Ajax?
Użyj usługi $resource.
od 9 do 14
Jak dopasować żądania Ajax używane przez usługę $resource?
Zdefiniuj własne akcje lub przedefiniuj domyślne.
15 i 16
Jak utworzyć komponenty, które mogą współpracować z danymi typu RESTful?
Upewnij się o włączeniu opcjonalnej możliwości pracy z usługą $resource. Akcjom, które muszą być użyte, nie zapomnij umożliwić przeprowadzenia konfiguracji, gdy komponent jest stosowany.
17 i 18
AngularJS. Profesjonalne techniki
Kiedy i dlaczego używać usług typu REST? Usług omówionych w rozdziale należy używać podczas przeprowadzania operacji na danych za pomocą API RESTful. Początkowo do wykonywania żądań Ajax możesz preferować wykorzystanie usługi $http, zwłaszcza jeśli masz doświadczenie w pracy z biblioteką jQuery. Dlatego też użycie $http zostanie przedstawione na początku rozdziału, a następnie przejdziemy do ograniczeń tego rozwiązania, gdy jest stosowane w połączeniu z REST, i zalet rozwiązania alternatywnego w postaci usługi $resource.
Przygotowanie przykładowego projektu Aby przedstawić różne sposoby użycia AngularJS do wykorzystania usługi sieciowej typu RESTful, konieczne jest przygotowanie samej usługi. Ponownie wykorzystamy więc serwer Deployd. Jeżeli jeszcze nie pobrałeś i nie zainstalowałeś Deployd, zapoznaj się z informacjami przedstawionymi w rozdziale 1. Ostrzeżenie Ponownie wykorzystamy nazwę products dla tworzonej kolekcji danych, podobnie jak w części I, w której budowaliśmy aplikację SportsStore. Jeżeli więc wcześniej utworzyłeś aplikację SportsStore, upewnij się o usunięciu katalogu deployd przed wykonaniem poleceń przedstawionych w rozdziale.
Utworzenie usługi typu RESTful W celu przygotowania nowej usługi utwórz katalog deployd, a następnie wydaj w nim poniższe polecenie: dpd create products
Aby uruchomić nową usługę, należy wydać poniższe polecenia: dpd -p 5500 products\app.dpd dashboard
Panel serwera Deployd powinien zostać wyświetlony w przeglądarce internetowej, jak pokazano na rysunku 21.1.
Rysunek 21.1. Początkowy stan panelu Deployd
Utworzenie struktury danych Po utworzeniu usługi można przystąpić do przygotowania struktury danych. W panelu Deployd kliknij duży zielony przycisk, z rozwijanego menu wybierz opcję Collection. Jako nazwę dla nowej kolekcji podaj /products, jak pokazano na rysunku 21.2, a następnie kliknij przycisk Create.
528
Rozdział 21. Usługi dla REST
Rysunek 21.2. Utworzenie kolekcji products Serwer Deployd pozwoli teraz na zdefiniowanie właściwości, jakie mają mieć obiekty w kolekcji. Podaj właściwości wymienione w tabeli 21.2. Tabela 21.2. Właściwości wymagane dla kolekcji products Nazwa
Typ
Wymagana?
name
string
Tak
category
string
Tak
price
number
Tak
Po zakończeniu wprowadzania właściwości panel powinien wyglądać tak, jak pokazano na rysunku 21.3. Upewnij się o prawidłowym podaniu nazw właściwości i wyborze odpowiedniego typu.
Rysunek 21.3. Zbiór właściwości w panelu Deployd
Dodanie danych początkowych W tym miejscu wstawimy do serwera Deployd pewne dane początkowe, aby tym samym ułatwić sobie przygotowanie przykładu. Kliknij łącze Data w sekcji Resources panelu, a następnie za pomocą edytora tabeli wprowadź dane wymienione w tabeli 21.3.
529
AngularJS. Profesjonalne techniki
Tabela 21.3. Elementy danych początkowych Nazwa
Kategoria
Cena
Jabłka
Owoce
1.20
Banany
Owoce
2.42
Brzoskwinie
Owoce
2.02
Tuńczyk
Ryby
20.45
Łosoś
Ryby
17.93
Pstrąg
Ryby
12.93
Po wprowadzeniu danych panel powinien wyglądać tak, jak pokazano na rysunku 21.4.
Rysunek 21.4. Dodawanie danych
Przetestowanie API Jeżeli klikniesz łącze API w panelu Deployd, to wyświetlisz tabelę zawierającą listę adresów URL i metod HTTP, które można wykorzystać do przeprowadzania operacji na danych. To praktycznie esencja usługi typu RESTful. W rozdziale zobaczysz różne możliwości oferowane przez AngularJS pozwalające na łączenie wspomnianych adresów URL i metod w celu dostarczania aplikacji odpowiednich danych. W tabeli 21.4 przedstawiono kluczowe szczegóły pochodzące z tabeli wyświetlanej po kliknięciu łącza API. Tabela 21.4. Metody HTTP i adresy URL obsługujące usługę RESTful Zadanie
Metoda
Adres URL
Akceptuje
Zwraca
Lista produktów
GET
/products
Nic
Tablica obiektów
Utworzenie obiektu
POST
/products
Pojedynczy obiekt
Zachowany obiekt
Pobranie obiektu
GET
/products/
Nic
Pojedynczy obiekt
Uaktualnienie obiektu
PUT
/products/
Pojedynczy obiekt
Zachowany obiekt
Usunięcie obiektu
DELETE
/products/
Pojedynczy obiekt
Nic
530
Rozdział 21. Usługi dla REST
Wskazówka Zawsze warto sprawdzić API dostarczane przez usługę typu RESTful, ponieważ nie istnieje spójny sposób łączenia metod HTTP z adresami URL w celu zapewnienia możliwości operowania na danych. Na przykład do uaktualnienia pojedynczych właściwości obiektu pewne usługi uwzględniają wykorzystanie metody PATCH, podczas gdy inne — w tym także Deployd — do tego celu używają metody PUT.
Polecenie wykorzystane do uruchomienia serwera Deployd ustawiło używany port (5500). To oznacza możliwość ręcznego wyświetlenia produktów przez uruchomienie przeglądarki internetowej i przejście pod wskazany adres URL przy założeniu, że serwer Deployd działa na komputerze lokalnym: http://localhost:5500/products
Gdy zostanie wykonane żądanie do podanego adresu URL, serwer Deployd zwraca ciąg tekstowy JSON zawierający szczegóły wprowadzone w serwerze na podstawie informacji w tabeli 21.3. Jeżeli używasz przeglądarki internetowej Google Chrome, to dane JSON zostaną wyświetlone bezpośrednio w oknie przeglądarki. Natomiast w przypadku innych przeglądarek, w tym także Internet Explorera, zostaniesz poproszony o zapis pliku JSON na dysku. Dane JSON wygenerowane przez serwer Deployd są podobne do danych JSON utworzonych przez nas ręcznie w rozdziale 20. Między nimi istnieje tylko jedna różnica: ponieważ dane są przechowywane w bazie danych, każdy obiekt produktu jest przypisany do unikalnego klucza we właściwości o nazwie id. Wartość właściwości id jest używana do identyfikacji obiektów poszczególnych produktów w adresie URL usługi typu RESTful, jak pokazano w tabeli 21.4. Poniżej przedstawiono fragment danych JSON, które serwer Deployd wykorzystał do przedstawienia tylko jednego obiektu produktu: ... {"name":"Jabłka", "category":"Owoce", "price":1.2, "id":"0d1f0bb77475fbe3" } ...
Wartość 0d1f0bb77475fbe3 właściwości id unikalnie identyfikuje obiekt produktu, którego właściwość name ma wartość Jabłka. Aby usunąć ten obiekt za pomocą REST, należy zastosować metodę HTTP DELETE w następującym adresie URL: http://localhost:5500/products/0d1f0bb77475fbe3
Utworzenie aplikacji AngularJS Po przygotowaniu API RESTful i wprowadzeniu danych możemy przystąpić do utworzenia szkieletu aplikacji. Zadaniem budowanej aplikacji jest wyświetlanie zawartości i umożliwienie użytkownikowi dodawania, modyfikowania i usuwania obiektów produktów. Rozpoczynamy od usunięcia dotychczasowej zawartości katalogu angularjs, następnie umieszczamy w nim pliki AngularJS i Bootstrap zgodnie z opisem zaprezentowanym w rozdziale 1. Teraz tworzymy dokument HTML o nazwie products.html i zawartości przedstawionej na listingu 21.1. Listing 21.1. Zawartość pliku products.html
Produkty
531
AngularJS. Profesjonalne techniki
Produkty
Aplikacja zostanie podzielona na kilka mniejszych plików, podobnie jak ma to miejsce w rzeczywistych projektach. Plik products.html zawiera element
Produkty
540
Rozdział 21. Usługi dla REST
Ponadto w pliku products.js dodajemy zależność od nowego modułu, jak przedstawiono na listingu 21.11. Listing 21.11. Dodanie w pliku products.js zależności od modułu increment angular.module("exampleApp", ["increment"]) .constant("baseUrl", "http://localhost:5500/products/") .controller("defaultCtrl", function ($scope, $http, baseUrl) { ...
Pozostało już tylko zastosowanie dyrektywy w widoku tableView.html, aby każdy wiersz tabeli zawierał przycisk pozwalający na zwiększenie ceny, jak przedstawiono na listingu 21.12. Listing 21.12. Zastosowanie dyrektywy increment w pliku tableView.html ... {{item.name}} | {{item.category}} | {{item.price | currency}} | Usuń Edytuj |
...
Efekt wprowadzonych zmian pokazano na rysunku 21.8. Kliknięcie przycisku + powoduje zwiększenie o 1 wartości właściwości price w odpowiednim obiekcie product.
Rysunek 21.8. Podniesienie ceny produktu
541
AngularJS. Profesjonalne techniki
Problem można dostrzec po kliknięciu przycisku Odśwież, który lokalne dane produktów zastępuje nowymi, pobranymi z serwera. Podczas zmiany wartości właściwości price dyrektywa increment nie wykonała żądania Ajax wymaganego do uaktualnienia danych w serwerze, a więc dane lokalne oraz w serwerze nie są dłużej synchronizowane. Przykład może wydawać się nieco naciągany, ale sam problem często pojawia się podczas stosowania dyrektyw opracowanych przez innych programistów lub dostarczanych przez firmy trzecie. Nawet jeśli autor dyrektywy increment będzie wiedział o konieczności wykonania żądania Ajax, to i tak nie może go zaimplementować, ponieważ cała logika przeprowadzania uaktualnień za pomocą technologii Ajax znajduje się w kontrolerze. Pozostaje więc niedostępna dla dyrektywy, zwłaszcza pochodzącej z innego modułu. Rozwiązaniem tego problemu jest upewnienie się, że wszystkie zmiany w danych lokalnych automatycznie powodują wygenerowanie odpowiednich żądań Ajax. To jednak oznacza, że każdy komponent pracujący z danymi musi wiedzieć, kiedy dane wymagają synchronizacji ze zdalnym serwerem oraz jak wykonać żądania Ajax odpowiedzialne za wprowadzenie odpowiednich uaktualnień. AngularJS oferuje częściowe rozwiązanie problemu za pomocą usługi $resource ułatwiającej pracę z danymi typu RESTful w aplikacji przez ukrycie szczegółów żądań Ajax i formatu adresów URL. Przykład rozwiązania opartego na usłudze $resource zostanie przedstawiony w kolejnych punktach.
Instalacja modułu ngResource Usługa $resource jest zdefiniowana w module opcjonalnym ngResource, który należy pobrać i umieścić w katalogu angularjs. Przejdź do witryny https://angularjs.org/, kliknij przycisk Download, wybierz wersję (gdy powstawała ta książka, była to wersja 1.2.22), a następnie kliknij łącze Browse additional modules w lewym dolnym rogu, jak pokazano na rysunku 21.9.
Rysunek 21.9. Pobieranie opcjonalnego modułu Pobierz plik angular-resource.js i umieść w katalogu angularjs. Na listingu 21.13 przedstawiono dodanie w dokumencie products.html elementu
542
Rozdział 21. Usługi dla REST
...
Użycie usługi $resource Na listingu 21.14 przedstawiono przykład użycia usługi $resource w pliku products.js. Wymieniona usługa jest wykorzystywana do zarządzania danymi, które zostały pobrane z serwera, bez konieczności bezpośredniego tworzenia żądań Ajax. Listing 21.14. Użycie usługi $resource w pliku products.js angular.module("exampleApp", ["increment", "ngResource"]) .constant("baseUrl", "http://localhost:5500/products/") .controller("defaultCtrl", function ($scope, $http, $resource, baseUrl) { $scope.displayMode = "list"; $scope.currentProduct = null; $scope.productsResource = $resource(baseUrl + ":id", { id: "@id" }); $scope.listProducts = function () { $scope.products = $scope.productsResource.query(); } $scope.deleteProduct = function (product) { product.$delete().then(function () { $scope.products.splice($scope.products.indexOf(product), 1); }); $scope.displayMode = "list"; } $scope.createProduct = function (product) { new $scope.productsResource(product).$save().then(function(newProduct) { $scope.products.push(newProduct); $scope.displayMode = "list"; }); } $scope.updateProduct = function (product) { product.$save(); $scope.displayMode = "list"; } $scope.editOrCreateProduct = function (product) { $scope.currentProduct = product ? product : {}; $scope.displayMode = "edit"; } $scope.saveEdit = function (product) { if (angular.isDefined(product.id)) { $scope.updateProduct(product); } else { $scope.createProduct(product); } }
543
AngularJS. Profesjonalne techniki $scope.cancelEdit = function () { if ($scope.currentProduct && $scope.currentProduct.$get) { $scope.currentProduct.$get(); } $scope.currentProduct = {}; $scope.displayMode = "list"; } $scope.listProducts(); });
Sygnatury funkcji zdefiniowanych przez kontroler pozostają takie same. To dobre rozwiązanie, ponieważ użycie usługi $resource nie wymaga wprowadzenia żadnych zmian w elementach HTML. Zmianie uległy jednak implementacje wszystkich funkcji, co wiąże się nie tylko ze zmianą sposobu pobierania danych, ale również z przyjęciem założenia, że natura danych może być różna. Na listingu naprawdę wiele się dzieje, lecz sposób działania samej usługi $resource może być niezrozumiały. Dlatego też w kolejnych punktach omówimy działanie listingu krok po kroku.
Konfiguracja usługi $resource Pierwszym zadaniem jest konfiguracja usługi $resource i wskazanie jej sposobu współpracy z usługą typu RESTful serwera Deployd. Oto odpowiednie polecenie: ... $scope.productsResource = $resource(baseUrl + ":id", { id: "@id" }); ...
Obiekt usługi $resource jest funkcją wykorzystywaną do określenia adresu URL pozwalającego na użycie usługi typu RESTful. Segmenty adresu URL, zmieniające się w poszczególnych obiektach, są poprzedzane dwukropkiem. Jeżeli ponownie spojrzysz na tabelę 21.4, to zobaczysz, że nasza usługa zawiera tylko jedną zmienną część adresu URL, czyli identyfikator obiektu product wymagany podczas usuwania lub modyfikowania obiektu. W przypadku pierwszego argumentu łączymy wartość stałej baseUrl z :id, wskazując tym samym zmienny segment adresu URL i tworząc wartość przedstawioną poniżej: http://localhost:5500/products/:id
Drugim argumentem jest obiekt konfiguracyjny, którego właściwości wskazują miejsce pochodzenia wartości zmiennej segmentu adresu URL. Każda właściwość musi odpowiadać zmiennej segmentu z pierwszego argumentu, wartość może być ustalona na stałe lub, jak w omawianym przykładzie, dołączona do właściwości obiektu danych przez poprzedzenie nazwy właściwości znakiem @. Wskazówka Większość aplikacji wymaga wielu segmentów wyrażających skomplikowane kolekcje danych. Adres URL przekazywany usłudze $resource może zawierać dowolną wymaganą liczbę zmiennych segmentów.
Wynikiem wywołania funkcji usługi $resource jest tak zwany obiekt dostępu, który może być wykorzystywany do pobierania i modyfikowania danych w serwerze za pomocą metod wymienionych w tabeli 21.5. Wskazówka Metody delete() i remove() są identyczne i mogą być stosowane wymiennie.
Zwróć uwagę, że przedstawione w tabeli 21.5 połączenie metod HTTP i adresów URL jest podobne (choć nie identyczne) do API zdefiniowanego przez serwer Deployd i wymienionego w tabeli 21.4. Na szczęście Deployd charakteryzuje się wystarczającą elastycznością, aby zniwelować różnice. Jednak w dalszej części rozdziału dowiesz się, jak konfigurację usługi $resource dostosować do własnych potrzeb, aby wspomniane połączenie metod i adresów było takie same jak w Deployd.
544
Rozdział 21. Usługi dla REST
Tabela 21.5. Akcje domyślne zdefiniowane przez obiekt dostępu Nazwa
HTTP
URL
Opis
delete(parametry, produkt)
DELETE
/products/
Usuwa obiekty o określonym identyfikatorze.
get(id)
GET
/products/
Pobiera obiekt o określonym identyfikatorze.
query()
GET
/products
Pobiera wszystkie obiekty jako tablicę.
remove(parametry, produkt)
DELETE
/products/
Usuwa obiekt o określonym identyfikatorze.
save(produkt)
POST
/products/
Zachowuje modyfikacje w obiekcie o określonym identyfikatorze.
Wskazówka Z tabeli wynika, że metody delete() i remove() wymagają argumentu parametry. Jest to obiekt zawierający dodatkowe parametry przeznaczone do umieszczenia w adresie URL przekazywanym serwerowi. Wszystkie metody wymienione w tabeli mogą być użyte z obiektem początkowym, ale z powodu dziwactw w kodzie usługi $resource metody delete() i remove() muszą być wywoływane w podany sposób, nawet jeśli obiekt parametry nie zawiera właściwości i wartości.
Nie przejmuj się, jeśli w tym momencie jeszcze nie rozumiesz roli akcji. Już wkrótce wszystko stanie się jasne.
Wyświetlanie danych REST Zwrócony przez wywołanie obiektu usługi $resource obiekt dostępu jest przypisywany zmiennej o nazwie productResource. Następnie wymieniona zmienna jest używana do pobrania początkowej migawki danych z serwera. Poniżej przedstawiono definicję funkcji listProducts(): ... $scope.listProducts = function () { $scope.products = $scope.productsResource.query(); } ...
Obiekt dostępu zapewnia możliwość pobierania danych z serwera oraz ich modyfikowania, ale automatycznie nie przeprowadza żadnej z tych akcji. Dlatego też konieczne jest wywołanie metody query() w celu pobrania początkowych danych dla aplikacji. Metoda query() wykonuje żądanie do adresu URL /products podanego przez usługę Deployd i pobiera wszystkie dostępne obiekty danych. Wynikiem działania metody query() jest początkowo kolekcja w postaci pustej tablicy. Usługa $resource tworzy tablicę wynikową, a następnie używa usługi $http w celu wykonania żądania Ajax. Po zakończeniu żądania Ajax dane pobrane z serwera zostają umieszczone w kolekcji. To punkt tak ważny, że zostanie powtórzony jako ostrzeżenie. Ostrzeżenie Tablica zwrócona przez metodę query() jest początkowo pusta i będzie wypełniona dopiero po zakończeniu asynchronicznego żądania HTTP do serwera.
Odpowiedź na operację wczytywania danych W przypadku wielu aplikacji asynchroniczne wczytywanie danych sprawdza się doskonale, a zmiany wprowadzone w zakresie przez otrzymane dane gwarantują prawidłowe działanie aplikacji. Wprawdzie przykład przedstawiony w rozdziale jest prosty, ale pokazuje strukturę wielu, o ile nie większości aplikacji AngularJS — otrzymanie danych powoduje zmiany w zakresie, które odświeżają operacje dołączania danych i prowadzą do wyświetlenia w tabeli uaktualnionych danych.
545
AngularJS. Profesjonalne techniki
Czasami jednak zachodzi potrzeba udzielenia bardziej bezpośredniej odpowiedzi w chwili otrzymania danych. W tym celu usługa $resource dodaje właściwość $promise do kolekcji zwróconej przez metodę query(). Obietnica jest uwzględniana po zakończeniu żądania Ajax pobierającego dane. Poniżej przedstawiono przykład rejestracji procedury obsługi wraz z obietnicą: ... $scope.listProducts = function () { $scope.products = $scope.productsResource.query(); $scope.products.$promise.then(function (data) { // Dowolne operacje na danych. }); } ...
Obietnica będzie spełniona po wypełnieniu tablicy wynikowej. To oznacza możliwość uzyskania dostępu do danych za pomocą tablicy lub argumentu przekazanego funkcji success(). Informacje szczegółowe o działaniu obietnic przedstawiono w rozdziale 20.
Asynchroniczne dostarczanie danych sprawdza się doskonale w połączeniu z poleceniami dołączania danych, ponieważ pozwala na automatyczne uaktualnianie danych po ich otrzymaniu i umieszczeniu w tablicy kolekcji.
Modyfikacja obiektów danych Metoda query() wypełnia tablicę kolekcji obiektami Resource, które definiują wszystkie właściwości określone w danych otrzymywanych z serwera, a także pewne metody pozwalające na przeprowadzanie operacji na tych danych bez konieczności użycia tablicy kolekcji. Metody zdefiniowane przez obiekt Resource wymieniono w tabeli 21.6. Tabela 21.6. Metody obsługiwane przez obiekt Resource Nazwa
Opis
$delete()
Usuwa obiekt z serwera; odpowiednik wywoływania $remove().
$get()
Odświeża obiekt z serwera, pozbywa się wszelkich niezatwierdzonych zmian lokalnych.
$remove()
Usuwa obiekt z serwera; odpowiednik wywoływania $delete().
$save()
Zachowuje obiekt w serwerze.
Najłatwiejsza praca jest z metodą $save(). Poniżej przedstawiono zastosowanie tej metody w funkcji updateProduct(): ... $scope.updateProduct = function (product) { product.$save(); $scope.displayMode = "list"; } ...
Wszystkie metody obiektu Resource przeprowadzają asynchroniczne żądania i zwracają obiekty promise, które można wykorzystać do otrzymywania powiadomień o zakończeniu żądania sukcesem lub niepowodzeniem. Uwaga W celu zachowania prostoty w omawianym przykładzie przyjęliśmy beztroskie założenie, że wszystkie żądania Ajax będą się kończyły powodzeniem. Jednak w rzeczywistych projektach należy zwrócić uwagę na obsługę błędów.
546
Rozdział 21. Usługi dla REST
Praca z metodą $get() również jest całkiem łatwa. W przykładzie wykorzystaliśmy ją do powrotu z porzuconej operacji edycji w funkcji cancelEdit(): ... $scope.cancelEdit = function () { if ($scope.currentProduct && $scope.currentProduct.$get) { $scope.currentProduct.$get(); } $scope.currentProduct = {}; $scope.displayMode = "list"; } ...
Przed wywołaniem metody $get() sprawdzamy, czy jest dostępna do wywołania. Efektem wywołania jest wyzerowanie edytowanego obiektu i przywrócenia mu stanu zapisanego w serwerze. To jest inne podejście do edycji obiektu względem zastosowanego podczas użycia usługi $http, gdzie powieliliśmy dane lokalne w celu przygotowania punktu odniesienia, do którego można powrócić po przerwaniu operacji edycji.
Usuwanie obiektu danych Metody $delete() i $remove() generują te same żądania do serwera i są identyczne pod każdym względem. Wadą ich stosowania jest to, że wysyłają żądanie usunięcia obiektu serwera, ale nie z tablicy kolekcji. To jest rozsądne podejście, ponieważ wynik wykonania żądania nie będzie znany aż do chwili otrzymania odpowiedzi. Aplikacja pozostałaby rozsynchronizowana, gdyby nastąpiło usunięcie lokalnej kopii danych, a żądanie usunięcia ich w serwerze ciągle zwracałoby błąd. Rozwiązaniem jest użycie obiektu promise z wymienionymi metodami i zarejestrowanie procedury obsługi zapewniającej synchronizację danych lokalnych po zakończonej powodzeniem operacji usunięcia danych w serwerze przez funkcję deleteProduct(): ... $scope.deleteProduct = function (product) { product.$delete().then(function () { $scope.products.splice($scope.products.indexOf(product), 1); }); $scope.displayMode = "list"; } ...
Utworzenie nowego obiektu Użycie słowa kluczowego new w obiekcie dostępu pozwala na zastosowanie metod usługi $resource na obiektach danych w taki sposób, aby zachować je w serwerze. Techniki tej używamy w funkcji createProduct(), aby móc wykorzystać metodę $save() i zapisać nowe obiekty w bazie danych: ... $scope.createProduct = function (product) { new $scope.productsResource(product).$save().then(function (newProduct) { $scope.products.push(newProduct); $scope.displayMode = "list"; }); } ...
W przeciwieństwie do metody $delete() metoda $save() nie uaktualnia tablicy kolekcji po zapisaniu w serwerze nowego obiektu. Wykorzystujemy więc obiekt promise zwracany przez metodę $save() w celu dodania obiektu do tablicy kolekcji, jeśli żądanie Ajax zakończy się sukcesem.
547
AngularJS. Profesjonalne techniki
Konfiguracja akcji usługi $resource Dostępne w tablicy kolekcji metody get(), save(), query(), remove() i delete() oraz ich odpowiedniki poprzedzone znakiem $ w poszczególnych obiektach Resource noszą nazwę akcji. Domyślnie usługa $resource definiuje akcje wymienione w tabeli 21.5; są one łatwe w konfiguracji i odpowiadają API dostarczanemu przez serwer. Na listingu 21.15 pokazano zmiany wprowadzone w akcjach, aby dopasować je do API serwera Deployd wymienionego w tabeli 21.4. Listing 21.15. Modyfikacja akcji usługi $resource w pliku products.js ... $scope.productsResource = $resource(baseUrl + ":id", { id: "@id" }, { create: { method: "POST" }, save: { method: "PUT" }}); ...
Funkcja obiektu usługi $resource może być wywołana wraz z trzecim argumentem definiującym akcje. Wspomniane akcje są wyrażone w postaci właściwości obiektu o nazwach odpowiadających definiowanym akcjom lub ponownie definiowanym, ponieważ istnieje możliwość zastąpienia akcji domyślnych. Każda właściwość akcji ma ustawiony obiekt konfiguracyjny. Dla akcji używaliśmy tylko jednej właściwości (method), która wskazywała metodę HTTP wykorzystywaną przez daną akcję. Efektem wprowadzonej powyżej zmiany jest zdefiniowanie nowej akcji o nazwie create, stosującej metodę POST, oraz ponowne zdefiniowanie akcji save, która teraz używa metody PUT. Dzięki tym zmianom akcje obsługiwane przez obiekt dostępu productsResources są bardziej spójne z API serwera Deployd, a żądania dotyczące tworzenia nowych obiektów są oddzielone od żądań modyfikacji obiektów istniejących. W tabeli 27.1 wymieniono zbiór właściwości konfiguracyjnych, które można wykorzystać do zdefiniowania lub ponownego zdefiniowania akcji. Tabela 21.7. Konfiguracja właściwości używanych podczas obsługi akcji Nazwa
Opis
method
Określa metodę HTTP, która zostanie użyta w danym żądaniu Ajax.
params
Określa wartości zmiennych segmentu w adresie URL przekazywanym jako pierwszy argument funkcji usługi $resource.
url
Nadpisuje domyślny adres URL dla danej akcji.
isArray
Wartość true tej właściwości oznacza, że odpowiedź będzie w postaci tablicy danych JSON. Wartość domyślna (false) oznacza, że odpowiedzią na żądanie najczęściej będzie jeden obiekt.
Ponadto można użyć następujących właściwości do skonfigurowania żądania Ajax wygenerowanego przez akcję (efekt działania wymienionych opcji omówiono w rozdziale 20.): transformRequest, transformResponse, cache, timeout, withCredentials, responseType i interceptor. Akcje zdefiniowane w taki sposób są jak wartości domyślne, mogą być wywoływane w tablicy kolekcji oraz w poszczególnych obiektach Resource. Na listingu 21.16 przedstawiono uaktualnioną wersję funkcji createProduct() wykorzystującą nową akcję create. (Nie trzeba wprowadzać żadnych zmian w innych akcjach, ponieważ zmiana dotyczy metody HTTP używanej przez istniejącą akcję save). Listing 21.16. Użycie własnej akcji w pliku products.js ... $scope.createProduct = function (product) { new $scope.productsResource(product).$create().then(function (newProduct) { $scope.products.push(newProduct); $scope.displayMode = "list"; }); } ...
548
Rozdział 21. Usługi dla REST
Utworzenie komponentu gotowego do użycia z usługą $resource Użycie usługi $resource pozwala na tworzenie komponentów operujących na danych typu RESTful bez konieczności zagłębiania się w szczegóły żądań Ajax wymaganych do operacji na danych. Na listingu 21.17 przedstawiono uaktualnioną wersję dyrektywy increment, która teraz może być skonfigurowana do użycia danych pochodzących z usługi $resource. Listing 21.17. Praca z danymi typu RESTful w pliku increment.js angular.module("increment", []) .directive("increment", function () { return { restrict: "E", scope: { item: "=item", property: "@propertyName", restful: "@restful", method: "@methodName" }, link: function (scope, element, attrs) { var button = angular.element("").text("+"); button.addClass("btn btn-primary btn-xs"); element.append(button); button.on("click", function () { scope.$apply(function () { scope.item[scope.property]++; if (scope.restful) { scope.item[scope.method](); } }) }) }, } })
Uniknięcie pułapki danych asynchronicznych Usługa $resource zapewnia tylko częściowe rozwiązanie w zakresie przekazywania danych REST w aplikacji. Ukrywa szczegóły żądań Ajax, ale nadal wymaga, aby komponenty używające danych wiedziały, że dane są typu RESTful, i należy nimi operować za pomocą metod takich jak $save() i $delete(). Na tym etapie możesz się zastanawiać nad sposobami zakończenia procesu oraz nad tym, jak korzystać z funkcji monitorujących i procedur obsługi zdarzeń w celu utworzenia opakowania dla danych typu RESTful, które będzie monitorowało je pod kątem zmian i automatycznie wprowadzało zmiany w serwerze. Nie próbuj tego robić; to jest pułapka. Tego rodzaju rozwiązanie nie działa — w rzeczywistości nawet nie powinno działać prawidłowo, ponieważ oznacza próbę ukrycia przed komponentami używającymi danych asynchronicznej natury żądań Ajax będących fundamentem REST. Jeżeli nic nie wiadomo o użyciu danych typu REST, to przyjmowane jest założenie, że wszystkie operacje są przeprowadzane natychmiast, a dane w przeglądarce internetowej są decydującym odniesieniem. Oba założenia są nieprawidłowe, gdy w tle będą wykonywane żądania Ajax. Sprawy mają się jeszcze gorzej, gdy serwer zwraca błąd, który zostanie przekazany przeglądarce internetowej długo po przeprowadzeniu operacji synchronicznej na danych i aplikacja wykonuje już kolejny kod. Nie ma sensownego sposobu obsługi błędów — nie można rozwinąć operacji bez ryzyka wprowadzenia niespójności w stanie aplikacji (ponieważ kontynuowane jest wykonywanie kodu synchronicznego), brakuje
549
AngularJS. Profesjonalne techniki
możliwości ponownego wykonania pierwotnego kodu (ponieważ to wymaga wiedzy o przeprowadzaniu żądań Ajax). Najlepszym rozwiązaniem jest porzucenie stanu aplikacji i ponowne wczytanie danych z serwera, co okaże się przykrą niespodzianką dla użytkownika. Lepiej zaakceptuj to, że komponenty powinny być utworzone lub zaadaptowane do obsługi metod, które usługa $resource dodaje do obiektów danych. Takie rozwiązanie przedstawiono w uaktualnionej wersji dyrektywy increment. Nie zapominaj o możliwości konfiguracji tych metod.
Podczas tworzenia komponentów, które mogą operować na danych dostarczanych przez usługę $resource, trzeba dostarczyć opcje konfiguracyjne nie tylko włączające obsługę typu RESTful, ale również wskazujące metodę lub metody wymagane do uaktualnienia danych w serwerze. W omawianym przykładzie wartość atrybutu o nazwie restful wykorzystujemy do konfiguracji obsługi typu REST, a wartość atrybutu method do wskazania nazwy metody, która powinna być wywoływana podczas inkrementacji wartości. Na listingu 21.18 przedstawiono zmiany wprowadzone w pliku tableView.html. Listing 21.18. Dodanie atrybutów konfiguracji w pliku tableView.html
Nazwa | Kategoria | Cena | |
{{item.name}} | {{item.category}} | {{item.price | currency}} | Usuń Edytuj |
Odśwież Nowy
Jeżeli teraz klikniesz przycisk + w wierszu tabeli, wartość lokalna zostanie uaktualniona, a metoda $save() zostanie wywołana w celu uaktualnienia danych w serwerze.
550
Rozdział 21. Usługi dla REST
Podsumowanie W tym rozdziale dowiedziałeś się, jak pracować z usługami typu RESTful. Na początku przedstawiono ręczne wykonywanie żądań Ajax za pomocą usługi $http i wyjaśniono, dlaczego takie rozwiązanie może powodować problemy w przypadku danych używanych poza komponentem, który je utworzył. Następnie przeszliśmy do wykorzystania usługi $resource w celu ukrycia szczegółów żądań Ajax. Otrzymałeś ostrzeżenie dotyczące niebezpieczeństwa w trakcie próby ukrycia asynchronicznej natury danych typu RESTful przed komponentami, które operują na nich. W kolejnym rozdziale zostaną omówione usługi zapewniające routing adresów URL.
551
AngularJS. Profesjonalne techniki
552
ROZDZIAŁ 22
Usługi dla widoków W tym rozdziale zostaną omówione usługi, które AngularJS oferuje w pracy z widokami. Widoki wprowadzono w rozdziale 10., w którym dowiedziałeś się, jak korzystać z dyrektywy ng-include w celu importu widoków do aplikacji. Tutaj dowiesz się, jak używać routingu adresów URL, który wykorzystuje widoki do umożliwienia zaawansowanej nawigacji po aplikacji. Routing adresów URL może być trudny do zrozumienia. Dlatego też kolejne koncepcje są wprowadzane stopniowo; powoli modyfikujemy przykładową aplikację, omawiając przy okazji poszczególne funkcje routingu. Podsumowanie materiału zamieszczonego w rozdziale przedstawiono w tabeli 22.1. Tabela 22.1. Podsumowanie materiału zamieszczonego w rozdziale Problem
Rozwiązanie
Listing
Jak umożliwić nawigację w obrębie aplikacji?
Zdefiniuj trasy URL za pomocą $routeProvider.
od 1 do 4
Jak wyświetlić widok aktywnej trasy?
Zastosuj dyrektywę ng-view.
5
Jak zmienić aktywny widok?
Użyj metody $location.path() lub elementu, którego wartość atrybutu href odpowiada ścieżce trasy.
6i7
Jak przekazać informacje za pomocą ścieżki?
Użyj parametrów trasy w adresie URL trasy. Dostęp do parametrów odbywa się za pośrednictwem usługi $routeParams.
od 8 do 10
Jak połączyć kontroler z widokiem wyświetlanym przez aktywną trasę?
Użyj właściwości konfiguracyjnej controller.
11
Jak zdefiniować zależności dla kontrolera? Użyj właściwości konfiguracyjnej resolve.
12 i 13
Kiedy i dlaczego używać usług widoku? Usługi omówione w tym rozdziale są przydatne podczas upraszczania skomplikowanych aplikacji przez umożliwienie wielu komponentom kontrolowania zawartości wyświetlanej użytkownikowi. W małych lub prostych aplikacjach nie potrzebujesz przedstawionych tutaj usług.
AngularJS. Profesjonalne techniki
Przygotowanie przykładowego projektu W tym rozdziale będziemy kontynuować pracę z przykładem utworzonym w rozdziale 21. do zademonstrowania różnych sposobów, w jakie aplikacje AngularJS mogą wykorzystywać API RESTful. W poprzednim rozdziale skoncentrowaliśmy się na zarządzaniu żądaniami Ajax pobierającymi dane typu RESTful. Dlatego też mogłeś nie zauważyć pewnej sztuczki, która zostanie omówiona przed przedstawieniem rozwiązania pozwalającego na pozbycie się jej.
Istota problemu Aplikacja zawiera dwa pliki widoków tableView.html i editorView.html, które za pomocą dyrektywy ng-include są importowane w dokumencie products.html. Plik tableView.html zawiera domyślny widok aplikacji i wyświetla w elemencie dane pobrane z serwera. Przejście do zawartości widoku editorView.html następuje, gdy użytkownik utworzy nowy produkt lub będzie edytował istniejący. Po zakończeniu (lub przerwaniu) operacji następuje ponownie przejście do zawartości pliku tableView.html. Problem dotyczy sposobu zarządzania widocznością plików widoku. Na listingu 22.1 przedstawiono zawartość pliku products.html. Listing 22.1. Zawartość pliku products.html
Produkty
Produkty
Problem stanowi użycie dyrektywy ng-show do kontrolowania widoczności elementów. W celu ustalenia, czy zawartość widoku powinna być wyświetlona użytkownikowi, następuje sprawdzenie wartości zmiennej zakresu o nazwie displayMode i porównanie jej z dosłowną wartością, jak przedstawiono poniżej: ...
...
Wartość zmiennej displayMode jest ustawiana w funkcji kontrolera zdefiniowanej w pliku products.js i pozwala na wyświetlenie żądanej zawartości. Na listingu 22.2 przedstawiono ustawienie zmiennej displayMode w pliku products.js w celu przechodzenia między widokami.
554
Rozdział 22. Usługi dla widoków
Listing 22.2. Ustawienie wartości zmiennej displayMode w pliku products.js angular.module("exampleApp", ["increment", "ngResource"]) .constant("baseUrl", "http://localhost:5500/products/") .controller("defaultCtrl", function ($scope, $http, $resource, baseUrl) { $scope.displayMode = "list"; $scope.currentProduct = null; $scope.productsResource = $resource(baseUrl + ":id", { id: "@id" }, { create: { method: "POST" }, save: { method: "PUT" } }); $scope.listProducts = function () { $scope.products = $scope.productsResource.query(); } $scope.deleteProduct = function (product) { product.$delete().then(function () { $scope.products.splice($scope.products.indexOf(product), 1); }); $scope.displayMode = "list"; } $scope.createProduct = function (product) { new $scope.productsResource(product).$create().then(function (newProduct) { $scope.products.push(newProduct); $scope.displayMode = "list"; }); } $scope.updateProduct = function (product) { product.$save(); $scope.displayMode = "list"; } $scope.editOrCreateProduct = function (product) { $scope.currentProduct = product ? product : {}; $scope.displayMode = "edit"; } $scope.saveEdit = function (product) { if (angular.isDefined(product.id)) { $scope.updateProduct(product); } else { $scope.createProduct(product); } } $scope.cancelEdit = function () { if ($scope.currentProduct && $scope.currentProduct.$get) { $scope.currentProduct.$get(); } $scope.currentProduct = {}; $scope.displayMode = "list"; } $scope.listProducts(); });
555
AngularJS. Profesjonalne techniki
Przedstawione rozwiązanie działa, ale powoduje następujący problem: każdy komponent przeprowadzający zmianę wyglądu aplikacji musi mieć dostęp do zmiennej displayMode, która jest ustawiana w zakresie kontrolera. To nie będzie aż tak duży kłopot w prostej aplikacji, gdzie widoki są zarządzane przez jeden kontroler. Jednak po dodaniu kolejnych komponentów kontrolujących zawartość wyświetlaną użytkownikowi problem staje się poważny. Potrzebujemy więc możliwości oddzielenia wyboru widoku od kontrolera, aby zawartość aplikacji mogła pochodzić z jej dowolnego fragmentu. Odpowiednie rozwiązanie będzie przedstawione w rozdziale.
Użycie routingu URL AngularJS obsługuje tak zwany routing adresów URL, który używa wartości zwrotnej metody $location.path() w celu wczytania i wyświetlenia plików widoków bez konieczności osadzania dosłownych wartości w kodzie i znacznikach HTML aplikacji. W kolejnych punktach dowiesz się, jak zainstalować i wykorzystywać usługę $route dostarczającą funkcję routingu adresów URL.
Instalacja modułu ngRoute Usługa $route jest zdefiniowana w module opcjonalnym ngRoute, który należy pobrać i umieścić w katalogu angularjs. Przejdź do witryny https://angularjs.org/, kliknij przycisk Download, wybierz wersję (gdy powstawała ta książka, była to wersja 1.2.22), a następnie kliknij łącze Browse additional modules w lewym dolnym rogu, jak pokazano na rysunku 22.1.
Rysunek 22.1. Pobieranie opcjonalnego modułu Pobierz plik angular-route.js i umieść go w katalogu angularjs. Na listingu 22.3 przedstawiono dodanie w dokumencie products.html elementu
556
Rozdział 22. Usługi dla widoków
Produkty
Definiowanie adresów URL tras Sercem funkcjonalności oferowanej przez usługę $route jest zbiór mapowań między adresami URL i nazwami plików widoków. Te mapowania są nazywane trasami URL lub po prostu trasami. Kiedy wartość zwrócona przez metodę $location.path() zostanie dopasowana do jednego z mapowań, nastąpi wczytanie i wyświetlenie odpowiedniego pliku widoku. Mapowania są definiowane za pomocą dostawcy usługi $route, czyli $routeProvider. Na listingu 22.4 przedstawiono zdefiniowane trasy w przykładowej aplikacji. Listing 22.4. Definiowanie tras w pliku product.js angular.module("exampleApp", ["increment", "ngResource", "ngRoute"]) .constant("baseUrl", "http://localhost:5500/products/") .config(function ($routeProvider, $locationProvider) { $locationProvider.html5Mode(true); $routeProvider.when("/list", { templateUrl: "/tableView.html" }); $routeProvider.when("/edit", { templateUrl: "/editorView.html" }); $routeProvider.when("/create", { templateUrl: "/editorView.html" }); $routeProvider.otherwise({ templateUrl: "/tableView.html" }); }) .controller("defaultCtrl", function ($scope, $http, $resource, baseUrl) { // … pominięto w celu zachowania zwięzłości … });
Dodaliśmy zależność od modułu ngRoute oraz funkcję config() definiującą trasy. W funkcji config() zadeklarowano zależności od dostawców usług $route i $location; druga z wymienionych usług jest używana do włączenia adresów URL obsługiwanych przez standard HTML5. 557
AngularJS. Profesjonalne techniki
Wskazówka W rozdziale będziemy stosować adresy URL standardu HTML5, ponieważ są przejrzyste i proste, a ponadto wiemy, że przeglądarki internetowe obsługują API History wprowadzone w HTML5. W rozdziale 19. znajdziesz więcej informacji na temat obsługi HTML5 oferowanej przez usługę $location, sprawdzania, czy przeglądarka internetowa oferuje wymagane funkcje, a także informacje o potencjalnych problemach.
Trasy są definiowane za pośrednictwem metody $routeProvider.when(). Pierwszy argument to adres URL, do którego będzie miała zastosowanie trasa. Drugi to obiekt konfiguracyjny trasy. Zdefiniowane w przykładzie trasy są najprostsze z możliwych, ponieważ adresy URL są statyczne i dostarczyliśmy minimalną ilość informacji konfiguracyjnych. W dalszej części rozdziału poznasz znacznie bardziej skomplikowane przykłady tras. Opcje konfiguracyjne również będą omówione w dalszej części rozdziału. Teraz wystarczy wiedzieć, że opcja templateUrl wskazuje plik widoku, który powinien zostać użyty po dopasowaniu ścieżki bieżącego adresu URL w przeglądarce do pierwszego argumentu przekazanego funkcji when(). Wskazówka Wartość opcji templateUrl zawsze należy podawać wraz ze znakiem / na początku. Jeżeli go pominiesz, to adres URL będzie uznany jako względny w stosunku do wartości zwróconej przez metodę $location.path(). Zmiana wspomnianej wartości to kluczowa czynność wymagana podczas użycia routingu. Pomijając znak /, bardzo szybko wygenerujesz błąd Nie znaleziono podczas nawigacji po aplikacji.
Metoda otherwise() służy do zdefiniowania trasy, która będzie użyta w przypadku braku dopasowania do ścieżki bieżącego adresu URL. Dobrą praktyką jest podawanie tego rodzaju trasy. W tabeli 22.2 podsumowano ogólny efekt wszystkich tras zdefiniowanych w przykładowej aplikacji. Tabela 22.2. Efekt zdefiniowania tras w pliku products.js Adres URL ścieżki
Plik widoku
/list
tableView.html
/edit
editorView.html
/create
editorView.html
Wszystkie pozostałe adresy URL
tableView.html
Wskazówka Tak naprawdę nie ma potrzeby definiowania trasy dla /list, ponieważ trasa zdefiniowana w metodzie otherwise() wyświetli widok tableView.html w przypadku braku dopasowania do bieżącej ścieżki. Osobiście wolę ją zdefiniować, ponieważ trasy mogą być całkiem skomplikowane i jeśli istnieje sposób na ułatwienie odczytu i zrozumienie tras, warto go zastosować.
Wyświetlanie wybranego widoku Moduł ngRoute zawiera dyrektywę o nazwie ng-view przeznaczoną do wyświetlenia zawartości pliku wskazanego przez trasę dopasowaną do bieżącej ścieżki adresu URL zwróconej przez usługę $location. Na listingu 22.5 przedstawiono sposób użycia dyrektywy ng-view do zastąpienia problematycznych elementów w dokumencie products.html, co pozwala na usunięcie tak bardzo nielubianych przeze mnie dosłownych wartości. Listing 22.5. Użycie dyrektywy ng-view w pliku products.html
Produkty
558
Rozdział 22. Usługi dla widoków
Produkty
Gdy zostanie zmieniona wartość zwracana przez metodę path() usługi $location, usługa $route przeanalizuje trasy zdefiniowane przez dostawcę i zmieni zawartość elementu, w którym zastosowano dyrektywę ng-view.
Połączenie kodu i znaczników HTML Pozostało nam już tylko uaktualnienie kodu i znaczników HTML, aby zmiana wyglądu aplikacji odbywała się po zmianie adresu URL, a nie wartości zmiennej displayMode. W kodzie JavaScript oznacza to użycie metody path() dostarczanej przez usługę $location. Odpowiednie zmiany przedstawiono na listingu 22.6. Listing 22.6. Użycie usługi $location w pliku products.js w celu zmiany wyświetlanych widoków angular.module("exampleApp", ["increment", "ngResource", "ngRoute"]) .constant("baseUrl", "http://localhost:5500/products/") .config(function ($routeProvider, $locationProvider) { $locationProvider.html5Mode(true); $routeProvider.when("/list", { templateUrl: "/tableView.html" }); $routeProvider.when("/edit", { templateUrl: "/editorView.html" }); $routeProvider.when("/create", { templateUrl: "/editorView.html" }); $routeProvider.otherwise({ templateUrl: "/tableView.html" }); }) .controller("defaultCtrl", function ($scope, $http, $resource, $location, baseUrl) { $scope.currentProduct = null; $scope.productsResource = $resource(baseUrl + ":id", { id: "@id" }, { create: { method: "POST" }, save: { method: "PUT" } }); $scope.listProducts = function () { $scope.products = $scope.productsResource.query(); }
559
AngularJS. Profesjonalne techniki $scope.deleteProduct = function (product) { product.$delete().then(function () { $scope.products.splice($scope.products.indexOf(product), 1); }); $location.path("/list"); } $scope.createProduct = function (product) { new $scope.productsResource(product).$create().then(function (newProduct) { $scope.products.push(newProduct); $location.path("/list"); }); } $scope.updateProduct = function (product) { product.$save(); $location.path("/list"); } $scope.editProduct = function (product) { $scope.currentProduct = product; $location.path("/edit"); } $scope.saveEdit = function (product) { if (angular.isDefined(product.id)) { $scope.updateProduct(product); } else { $scope.createProduct(product); } $scope.currentProduct = {}; } $scope.cancelEdit = function () { if ($scope.currentProduct && $scope.currentProduct.$get) { $scope.currentProduct.$get(); } $scope.currentProduct = {}; $location.path("/list"); } $scope.listProducts(); });
To nie są duże zmiany. Dodaliśmy zależność od usługi $location i zastąpiliśmy wywołania modyfikujące wartość zmiennej displayMode odpowiednimi wywołaniami metody $location.path(). Mamy jeszcze jedną interesującą zmianę: zastąpiliśmy funkcję editOrCreateProduct() funkcją editProduct(), która jest nieco prostsza od poprzedniczki. Oto kod funkcji editOrCreateProduct(): ... $scope.editOrCreateProduct = function (product) { $scope.currentProduct = product ? product : {}; $scope.displayMode = "edit"; } ...
A oto zastępująca ją funkcja:
560
Rozdział 22. Usługi dla widoków ... $scope.editProduct = function (product) { $scope.currentProduct = product; $location.path("/edit"); } ...
Poprzednia funkcja stanowiła punkt wyjścia dla procesu zarówno edycji, jak i tworzenia, które były rozróżniane dzięki argumentowi product. Jeżeli wartość argumentu product była inna niż null, to obiekt był wykorzystywany do ustawienia zmiennej currentProduct wypełniającej pola w widoku editorView.html. Wskazówka Istnieje jeszcze jedna zmiana podkreślona na listingu. Uaktualniliśmy funkcję saveEdit(), aby wartość zmiennej currentProduct była zerowana. Bez tej zmiany wartości wprowadzone podczas operacji edycji byłyby wyświetlane użytkownikowi w trakcie kolejnej operacji tworzenia nowego produktu. To problem jedynie tymczasowy i będzie rozwiązany po rozbudowie obsługi routingu w aplikacji.
Powodem, dla którego można uprościć funkcję, jest to, że funkcja routingu pozwala na zainicjowanie procesu tworzenia nowego obiektu przez zwykłą zmianę adresu URL. Na listingu 22.7 przedstawiono zmiany wprowadzone w pliku tableView.html. Listing 22.7. Dodawanie obsługi tras w pliku tableView.html
Nazwa | Kategoria | Cena | |
{{item.name}} | {{item.category}} | {{item.price | currency}} | Usuń Edytuj |
Odśwież Nowy
561
AngularJS. Profesjonalne techniki
Element wraz z dyrektywą ng-click wywołującą funkcję editOrCreateProduct() zastąpiliśmy elementem , którego atrybut href określa adres URL dopasowujący trasę wyświetlającą widok editorView.html. Framework Bootstrap pozwala na nadanie elementom i stylów, dzięki którym wyglądają tak samo. Z punktu widzenia użytkownika nie ma żadnej różnicy w wyglądzie aplikacji. Jednak kliknięcie elementu powoduje zmianę adresu URL na /create i wyświetlenie widoku editorView.html, jak pokazano na rysunku 22.2.
Rysunek 22.2. Nawigacja w ramach aplikacji Aby zobaczyć efekt wprowadzonych zmian, wczytaj dokument products.html w przeglądarce internetowej, a następnie kliknij przycisk Nowy. Adres URL wyświetlany przez przeglądarkę internetową zmieni się z http://localhost:5000/products.html na http://localhost:5000/create. Tak działa magia adresów URL w standardzie HTML5 zarządzanych przez nowe API History. Na ekranie zobaczysz wyświetloną zawartość widoku editorView.html. Wprowadź informacje o nowym produkcie i kliknij przycisk Zapisz (lub Anuluj). Na ekranie ponownie będzie wyświetlona zawartość widoku tableView.html, a bieżący adres URL będzie miał postać http://localhost:5000/list. Ostrzeżenie Routing działa, kiedy aplikacja zmienia adres URL, natomiast nie działa po ręcznej modyfikacji adresu URL przez użytkownika. Każdy adres URL wprowadzany przez użytkownika jest przez przeglądarkę internetową traktowany jako dosłowne żądanie pliku i następuje próba wykonania żądania mającego na celu pobranie z serwera wskazanej zawartości.
Użycie parametrów trasy Adresy URL użyte do zdefiniowania tras w poprzednim podrozdziale były statyczne, czyli wartość przekazywana metodzie $location.path() lub ustawiona w atrybucie href elementu dokładnie odpowiadała wartości podanej w metodzie $routeProvider.when(). Dla przypomnienia przedstawiono poniżej jedną z tego typu tras: ... $routeProvider.when("/create", { templateUrl: "editorView.html" }); ...
562
Rozdział 22. Usługi dla widoków
Wymieniona trasa będzie aktywowana tylko wtedy, gdy komponent ścieżki adresu URL dopasuje /create. To jest najprostszy rodzaj adresów URL, które mogą być używane, a co za tym idzie, charakteryzuje się największymi ograniczeniami. Adres URL trasy może zawierać tak zwane parametry trasy, które dopasowują co najmniej jeden segment w ścieżce wyświetlanej przez przeglądarkę internetową. Segment to zbiór znaków znajdujących się między dwoma znakami /. Na przykład segmentami w adresie URL http://localhost:5000/users/adam/details są users, adam i details. Istnieją dwa rodzaje parametrów trasy: klasyczne i zachłanne. Pierwsze dopasowują tylko jeden segment, podczas gdy drugie dopasowują maksymalną liczbę segmentów. Aby zademonstrować działanie parametrów, modyfikujemy trasy zdefiniowane w pliku products.js, jak przedstawiono na listingu 22.8. Listing 22.8. Definiowanie tras za pomocą parametrów tras w pliku products.js ... .config(function ($routeProvider, $locationProvider) { $locationProvider.html5Mode(true); $routeProvider.when("/list", { templateUrl: "/tableView.html" }); $routeProvider.when("/edit/:id", { templateUrl: "/editorView.html" }); $routeProvider.when("/edit/:id/:data*", { templateUrl: "/editorView.html" }); $routeProvider.when("/create", { templateUrl: "/editorView.html" }); $routeProvider.otherwise({ templateUrl: "/tableView.html" }); }) ...
Pierwsza pogrubiona trasa, /edit/:id, zawiera klasyczny parametr trasy. Zmienna jest wskazywana przez dwukropek, a następnie jej nazwę; w omawianym przykładzie to id. Trasa spowoduje dopasowanie ścieżek takich jak /edit/1234 i przypisanie wartości 1234 parametrowi trasy o nazwie id. (Dostęp do zmiennych tras odbywa się za pomocą usługi $routeParams, która zostanie wkrótce omówiona). Trasy używające tylko statycznych segmentów i klasycznych parametrów tras dopasują jedynie te ścieżki, które zawierają taką samą liczbę segmentów jak ich adresy URL. W przypadku adresu URL w postaci /edit/:id dopasowany będzie jedynie adres URL zawierający dwa segmenty, z których pierwszy to edit. Ścieżki zawierające mniejszą lub większą liczbę segmentów nie będą dopasowane, podobnie jak ścieżki, których pierwszym segmentem nie jest edit. Zakres ścieżek dopasowywanych przez routing można rozszerzyć przez użycie parametru zachłannego, jak przedstawiono poniżej: ... $routeProvider.when("/edit/:id/:data*", { ...
Parametr zachłanny ma postać dwukropka, nazwy i gwiazdki. Przedstawiona powyżej trasa spowoduje dopasowanie każdej ścieżki składającej się z przynajmniej trzech segmentów, z których pierwszy to edit.
563
AngularJS. Profesjonalne techniki
Drugi segment zostanie przypisany parametrowi id, natomiast pozostałe segmenty będą przypisane parametrowi data. Wskazówka Nie przejmuj się, jeśli zmienne segmentu i parametry trasy w tym momencie są dla Ciebie niezrozumiałe. Zobaczysz, jak działają podczas analizy przykładów przedstawionych w kolejnych punktach.
Uzyskanie dostępu do tras i parametrów tras Adresy URL użyte w poprzednim punkcie przetwarzały ścieżki i przypisywały zawartość segmentów parametrom trasy, do których dostęp można uzyskać w kodzie. W tym punkcie zobaczysz, jak uzyskać dostęp do tych wartości za pomocą usług $route i $routeParams. Obie wymienione usługi są dostarczane przez moduł ngRoute. Pierwszym krokiem jest zmiana znajdującego się w widoku tableView.html przycisku pozwalającego na przeprowadzenie edycji obiektów produktów. Odpowiednie zmiany przedstawiono na listingu 22.9. Listing 22.9. Użycie routingu w celu umożliwienia edycji w pliku tableView.html
Nazwa | Kategoria | Cena | |
{{item.name}} | {{item.category}} | {{item.price | currency}} | Usuń Edytuj |
Odśwież Nowy
Element został zastąpiony przez element , którego atrybut href odpowiada jednemu z adresów URL tras zdefiniowanych na listingu 22.8. W tym celu wykorzystujemy standardowe, osadzone wyrażenie dołączania danych i dyrektywę ng-repeat. Oznacza to, że każdy wiersz w tabeli będzie zawierał element podobny do poniższego: Edytuj
564
Rozdział 22. Usługi dla widoków
Po kliknięciu łącza parametr trasy o nazwie id zdefiniowany na listingu 22.8 będzie miał przypisaną wartość 18d5f4716c6b1acf odpowiadającą właściwości id obiektu produktu, który użytkownik chce edytować. Na listingu 22.10 przedstawiono uaktualnioną wersję kontrolera w pliku products.js wykorzystującego wprowadzoną zmianę. Listing 22.10. Uzyskanie w pliku products.js dostępu do parametru trasy ... .controller("defaultCtrl", function ($scope, $http, $resource, $location, $route, $routeParams, baseUrl) { $scope.currentProduct = null; $scope.$on("$routeChangeSuccess", function () { if ($location.path().indexOf("/edit/") == 0) { var id = $routeParams["id"]; for (var i = 0; i < $scope.products.length; i++) { if ($scope.products[i].id == id) { $scope.currentProduct = $scope.products[i]; break; } } } }); $scope.productsResource = $resource(baseUrl + ":id", { id: "@id" }, { create: { method: "POST" }, save: { method: "PUT" } }); $scope.listProducts = function () { $scope.products = $scope.productsResource.query(); } $scope.deleteProduct = function (product) { product.$delete().then(function () { $scope.products.splice($scope.products.indexOf(product), 1); }); $location.path("/list"); } $scope.createProduct = function (product) { new $scope.productsResource(product).$create().then(function (newProduct) { $scope.products.push(newProduct); $location.path("/list"); }); } $scope.updateProduct = function (product) { product.$save(); $location.path("/list"); } $scope.saveEdit = function (product) { if (angular.isDefined(product.id)) { $scope.updateProduct(product); } else { $scope.createProduct(product); } $scope.currentProduct = {}; }
565
AngularJS. Profesjonalne techniki $scope.cancelEdit = function () { if ($scope.currentProduct && $scope.currentProduct.$get) { $scope.currentProduct.$get(); } $scope.currentProduct = {}; $location.path("/list"); } $scope.listProducts(); }); ...
W przedstawionym listingu zmodyfikowany kod wprowadza wiele nowego, dlatego poszczególne zmiany zostaną omówione w poniższych punktach. Uwaga Z kontrolera usunęliśmy funkcję editProduct(), która wcześniej była wywoływana w celu zainicjowania procesu edycji i wyświetlenia widoku editorView.html. Ta funkcja nie jest dłużej potrzebna, ponieważ edycja nie będzie już inicjowana przez system routingu.
Reakcja na zmiany trasy Usługa $route, od której na listingu 22.10 zadeklarowano zależność, może być wykorzystywana do zarządzania aktualnie wybraną trasą. W tabeli 22.3 wymieniono metody i właściwości definiowane przez usługę $route. Tabela 22.3. Metody i właściwości zdefiniowane przez usługę $route Nazwa
Opis
current
Zwraca obiekt dostarczający informacje o aktywnej trasie. Obiekt zwrócony przez tę właściwość definiuje właściwość controller zwracającą kontroler powiązany z trasą (patrz punkt „Użycie kontrolerów z trasami”) i właściwość locals dostarczającą zbiór zależności kontrolera (patrz punkt „Dodanie zależności do tras”). Kolekcja zwrócona przez właściwość locals zawiera także właściwości $scope i $template, które dostarczają, odpowiednio, zakres dla kontrolera i zawartość widoku.
reload()
Ponownie wczytuje widok, nawet jeśli adres URL ścieżki nie został zmieniony.
routes
Zwraca kolekcję tras zdefiniowanych za pomocą $routeProvider.
W przykładzie nie używamy żadnego z elementów składowych wymienionych w tabeli 22.3. Opieramy się za to na innym aspekcie usługi $route, jakim jest zbiór zdarzeń wykorzystywanych do sygnalizowania zmian w aktywnej trasie. Wspomniane zdarzenia wymieniono w tabeli 22.4. Procedury obsługi tych zdarzeń są rejestrowane za pomocą metody $on() omówionej w rozdziale 15. Tabela 22.4. Zdarzenia zdefiniowane przez usługę $route Nazwa
Opis
$routeChangeStart
Wywoływane przed zmianą trasy.
$routeChangeSuccess
Wywoływane po zmianie trasy.
$routeUpdate
Wywoływane podczas odświeżania trasy. To zdarzenie jest powiązane z właściwością konfiguracyjną reloadOnSearch, która będzie omówiona w podrozdziale „Konfiguracja tras”.
$routeChangeError
Wywoływane, jeśli trasa nie może być zmieniona.
566
Rozdział 22. Usługi dla widoków
Większość zdarzeń usługi $route nie jest aż tak użyteczna. Zwykle interesujące są jedynie informacje o dwóch rzeczach: wystąpienie zmiany trasy oraz nowa ścieżka. Metoda $routeChangeSuccess() informuje o zmianie trasy, natomiast usługa $location (nie $route) podaje nową ścieżkę, jak przedstawiono w poniższym fragmencie kodu pokazującym kluczowe polecenia z pliku products.js: ... $scope.$on("$routeChangeSuccess", function () { if ($location.path().indexOf("/edit/") == 0) { // … miejsce na polecenia reagujące na trasę /edit … } }); ...
Rejestrujemy funkcję obsługi wywoływaną po zmianie bieżącej trasy. Metodę $location.path() wykorzystujemy w celu określenia stanu, w jakim znajduje się aplikacja. Jeżeli ścieżka rozpoczyna się od /edit/, to wiadomo, że kod odpowiada na operację edycji.
Pobieranie parametrów trasy Podczas pracy ze ścieżką rozpoczynającą się od /edit/ wiadomo, że trzeba pobrać wartość parametru trasy id, aby mieć możliwość wypełnienia pól w pliku editorView.html. Wartości parametrów trasy są dostępne za pomocą usługi $routeParams. Wartości są przedstawiane w postaci kolekcji zindeksowanej według nazw, jak pokazano poniżej: ... $scope.$on("$routeChangeSuccess", function () { if ($location.path().indexOf("/edit/") == 0) { var id = $routeParams["id"]; for (var i = 0; i < $scope.products.length; i++) { if ($scope.products[i].id == id) { $scope.currentProduct = $scope.products[i]; break; } } } }); ...
Pobieramy wartość parametru id, a następnie używamy jej do ustalenia obiektu, który użytkownik chce edytować. Ostrzeżenie Aby zachować prostotę, w omawianym przykładzie przyjęto założenie, że wartość parametru id będzie w poprawnym formacie i będzie odpowiadać wartości id obiektu znajdującego się w tablicy danych. W rzeczywistym projekcie należy zachować większą ostrożność i sprawdzać otrzymywane wartości.
Konfiguracja tras Zdefiniowane dotąd w rozdziale trasy mają ustawioną tylko jedną właściwość konfiguracyjną templateUrl wskazującą adres URL pliku widoku, który powinien być wyświetlony przez daną trasę. To tylko jedna z wielu dostępnych opcji konfiguracyjnych. W tabeli 22.5 wymieniono wszystkie, natomiast dwie najważniejsze, controller i resolve, zostaną omówione w kolejnych punktach.
567
AngularJS. Profesjonalne techniki
Tabela 22.5. Opcje konfiguracji tras Nazwa
Opis
controller
Określa nazwę kontrolera powiązanego z widokiem wyświetlanym przez trasę. Więcej informacji na ten temat znajdziesz w punkcie „Użycie kontrolerów z trasami”.
controllerAs
Określa alias użyty dla kontrolera.
template
Określa zawartość widoku. Wartość opcji może być wyrażona w postaci dosłownego ciągu tekstowego HTML lub jako funkcja zwracająca kod HTML.
templateUrl
Określa adres URL pliku widoku wyświetlanego po dopasowaniu trasy. Wartość opcji może być wyrażona w postaci dosłownego ciągu tekstowego lub jako funkcja zwracająca ciąg tekstowy.
resolve
Określa zbiór zależności dla kontrolera. Więcej informacji na ten temat znajdziesz w punkcie „Dodanie zależności do tras”.
redirectTo
Określa ścieżkę, do której przeglądarka internetowa powinna być przekierowana po dopasowaniu trasy. Wartość opcji może być wyrażona w postaci dosłownego ciągu tekstowego lub jako funkcja zwracająca ciąg tekstowy.
reloadOnSearch
Wartość domyślna (true) oznacza, że trasa będzie ponownie wczytana tylko wtedy, gdy zmianie ulegną wartości zwracane przez metody search() i hash() usługi $location.
caseInsensitiveMatch
Wartość domyślna (true) oznacza, że trasy są dopasowywane do adresów URL bez uwzględniania wielkości liter (na przykład /Edit i /edit są uznawane za takie same).
Użycie kontrolerów z trasami Jeżeli aplikacja zawiera wiele widoków, to zarządzanie nimi z poziomu jednego kontrolera (jak to miało miejsce w przedstawionych dotąd przykładach w rozdziale) jest trudne, podobnie jak przeprowadzanie testów. Opcja konfiguracyjna controller pozwala na wskazanie zarejestrowanego za pomocą metody Module.controller() kontrolera dla widoku. Efektem jest oddzielenie logiki kontrolera unikalnej dla poszczególnych widoków, jak przedstawiono na listingu 22.11. Listing 22.11. Użycie kontrolera w widoku w pliku products.js angular.module("exampleApp", ["increment", "ngResource", "ngRoute"]) .constant("baseUrl", "http://localhost:5500/products/") .config(function ($routeProvider, $locationProvider) { $locationProvider.html5Mode(true); $routeProvider.when("/edit/:id", { templateUrl: "/editorView.html", controller: "editCtrl" }); $routeProvider.when("/create", { templateUrl: "/editorView.html", controller: "editCtrl" }); $routeProvider.otherwise({ templateUrl: "/tableView.html" }); }) .controller("defaultCtrl", function ($scope, $http, $resource, $location, baseUrl) {
568
Rozdział 22. Usługi dla widoków $scope.productsResource = $resource(baseUrl + ":id", { id: "@id" }, { create: { method: "POST" }, save: { method: "PUT" } }); $scope.listProducts = function () { $scope.products = $scope.productsResource.query(); } $scope.createProduct = function (product) { new $scope.productsResource(product).$create().then(function (newProduct) { $scope.products.push(newProduct); $location.path("/list"); }); } $scope.deleteProduct = function (product) { product.$delete().then(function () { $scope.products.splice($scope.products.indexOf(product), 1); }); $location.path("/list"); } $scope.listProducts(); }) .controller("editCtrl", function ($scope, $routeParams, $location) { $scope.currentProduct = null; if ($location.path().indexOf("/edit/") == 0) { var id = $routeParams["id"]; for (var i = 0; i < $scope.products.length; i++) { if ($scope.products[i].id == id) { $scope.currentProduct = $scope.products[i]; break; } } } $scope.cancelEdit = function () { if ($scope.currentProduct && $scope.currentProduct.$get) { $scope.currentProduct.$get(); } $scope.currentProduct = {}; $location.path("/list"); } $scope.updateProduct = function (product) { product.$save(); $location.path("/list"); } $scope.saveEdit = function (product) { if (angular.isDefined(product.id)) { $scope.updateProduct(product); } else { $scope.createProduct(product); } $scope.currentProduct = {}; } });
569
AngularJS. Profesjonalne techniki
Zdefiniowaliśmy nowy kontroler, o nazwie editCtrl, i przenieśliśmy do niego kod z kontrolera defaultCtrl, który to kod jest przeznaczony do obsługi jedynie widoku editorView.html. Następnie za pomocą właściwości konfiguracyjnej controller powiązaliśmy ten kontroler z trasami wyświetlającymi widok editorView.html. Nowy egzemplarz kontrolera editCtrl będzie tworzony w trakcie każdego wyświetlenia widoku editorView.html, co oznacza brak konieczności użycia zdarzeń usługi $route do informowania o zmianie widoku. Można opierać się jedynie na fakcie wykonywania funkcji kontrolera. Jednym z miłych aspektów użycia kontrolera w ten sposób jest stosowanie standardowych reguł dziedziczenia omówionych w rozdziale 13. Kontroler editCtrl jest zagnieżdżony w defaultCtrl, a więc ma dostęp do danych i funkcji zdefiniowanych w zakresie defaultCtrl. Dlatego też najczęściej używane dane i funkcje można zdefiniować w kontrolerze najwyższego poziomu, natomiast funkcje dotyczące poszczególnych widoków — w zagnieżdżonych kontrolerach.
Dodanie zależności do tras Właściwość konfiguracyjna resolve pozwala na wskazanie zależności, które będą wstrzyknięte do kontrolera podanego we właściwości controller. Wspomnianymi zależnościami mogą być usługi, choć właściwość resolve jest znacznie użyteczniejsza podczas wykonywania zadań niezbędnych do zainicjowania widoku. Wynika to z możliwości zwrócenia obiektów obietnic jako zależności, a trasa nie zainicjuje kontrolera, dopóki zależności nie zostaną rozwiązane. Na listingu 22.12 przedstawiono dodanie nowego kontrolera do omawianej aplikacji oraz użycie właściwości resolve w celu wczytania danych z serwera. Listing 22.12. Przykład użycia właściwości konfiguracyjnej resolve w pliku products.js angular.module("exampleApp", ["increment", "ngResource", "ngRoute"]) .constant("baseUrl", "http://localhost:5500/products/") .factory("productsResource", function ($resource, baseUrl) { return $resource(baseUrl + ":id", { id: "@id" }, { create: { method: "POST" }, save: { method: "PUT" } }); }) .config(function ($routeProvider, $locationProvider) { $locationProvider.html5Mode(true); $routeProvider.when("/edit/:id", { templateUrl: "/editorView.html", controller: "editCtrl" }); $routeProvider.when("/create", { templateUrl: "/editorView.html", controller: "editCtrl" }); $routeProvider.otherwise({ templateUrl: "/tableView.html", controller: "tableCtrl", resolve: { data: function (productsResource) { return productsResource.query(); } } }); }) .controller("defaultCtrl", function ($scope, $location, productsResource) { $scope.data = {}; $scope.createProduct = function (product) {
570
Rozdział 22. Usługi dla widoków new productsResource(product).$create().then(function (newProduct) { $scope.data.products.push(newProduct); $location.path("/list"); }); } $scope.deleteProduct = function (product) { product.$delete().then(function () { $scope.data.products.splice($scope.data.products.indexOf(product), 1); }); $location.path("/list"); } }) .controller("tableCtrl", function ($scope, $location, $route, data) { $scope.data.products = data; $scope.refreshProducts = function () { $route.reload(); }
}) .controller("editCtrl", function ($scope, $routeParams, $location) { $scope.currentProduct = null; if ($location.path().indexOf("/edit/") == 0) { var id = $routeParams["id"]; for (var i = 0; i < $scope.data.products.length; i++) { if ($scope.data.products[i].id == id) { $scope.currentProduct = $scope.data.products[i]; break; } } } $scope.cancelEdit = function () { $location.path("/list"); } $scope.updateProduct = function (product) { product.$save(); $location.path("/list"); } $scope.saveEdit = function (product) { if (angular.isDefined(product.id)) { $scope.updateProduct(product); } else { $scope.createProduct(product); } $scope.currentProduct = {}; } });
Na listingu wprowadzono wiele zmian, więc omówimy je po kolei. Najważniejsza zmiana dotyczy definicji trasy /list, która obecnie zawiera ustawione właściwości controller i resolve, jak przedstawiono poniżej: ... $routeProvider.otherwise({ templateUrl: "/tableView.html", controller: "tableCtrl", resolve: {
571
AngularJS. Profesjonalne techniki data: function (productsResource) { return productsResource.query(); } } }); ...
Określiliśmy, że trasa powinna zainicjować kontrolera o nazwie tableCtrl, i użyliśmy właściwości resolve w celu utworzenia zależności o nazwie data. Właściwość data ma przypisaną funkcję wykonywaną przed utworzeniem kontrolera tableCtrl, a wynik jej działa jest przekazywany jako argument dla data. W omawianym przykładzie do pobrania danych z serwera używamy obiektu dostępu $resource. Oznacza to, że kontroler nie będzie zainicjowany aż do chwili wczytania danych. Konsekwencją jest opóźnienie wyświetlenia widoku tableView.html aż do chwili pobrania danych z serwera. Aby z poziomu zależności uzyskać dostęp do wspomnianego obiektu dostępu, konieczne jest utworzenie nowej usługi, jak przedstawiono poniżej: ... .factory("productsResource", function ($resource, baseUrl) { return $resource(baseUrl + ":id", { id: "@id" }, { create: { method: "POST" }, save: { method: "PUT" } }); }) ...
To jest ten sam kod, który w poprzednich listingach był wykorzystywany do utworzenia w kontrolerze obiektu productResource, ale za pomocą omówionej w rozdziale 18. metody factory() został po prostu przeniesiony
do usługi i tym samym jest dostępny w większej części aplikacji. Kontroler tableCtrl jest dość prosty: ... .controller("tableCtrl", function ($scope, $location, $route, data) { $scope.data.products = data; $scope.refreshProducts = function () { $route.reload(); } }) ...
Informacje o produkcie są otrzymywane z serwera za pośrednictwem argumentu data i po prostu przypisywane właściwości $scope.data.products. Jak wyjaśniono w poprzednich punktach, omówione w rozdziale 13. reguły dziedziczenia kontrolerów i zakresów mają zastosowanie podczas użycia kontrolerów z trasami. Dlatego też trzeba dodać obiekt zawierający właściwość data, a dane produktu staną się dostępne we wszystkich kontrolerach aplikacji, a nie jedynie w zakresie należącym do kontrolera tableCtrl. Efektem dodania zależności w trasie jest możliwość usunięcia z kontrolera defaultCtrl funkcji listProducts(). Tym samym przycisk Odśwież w widoku tableView.html został pozbawiony możliwości wymuszenia ponownego wczytania danych. Definiujemy więc nową funkcję o nazwie refreshProducts() wykorzystującą wymienioną w tabeli 22.3 metodę $route.reload(). Ostatnią zmianą w kodzie JavaScript jest uproszczenie funkcji cancelEdit(), która nie musi dłużej ponownie wczytywać pojedynczego obiektu z serwera po anulowaniu operacji edycji, ponieważ wszystkie dane zostaną odświeżone po aktywacji trasy /list: ... $scope.cancelEdit = function () { $scope.currentProduct = {}; $location.path("/list"); } ...
572
Rozdział 22. Usługi dla widoków
Aby odzwierciedlić zmiany wprowadzone w kontrolerze, uaktualniamy widok tableView.html, jak przedstawiono na listingu 22.13. Listing 22.13. Uaktualnienie pliku tableView.html w celu odzwierciedlenia zmian wprowadzonych w kontrolerze
Nazwa | Kategoria | Cena | |
{{item.name}} | {{item.category}} | {{item.price | currency}} | Usuń Edytuj |
Odśwież Nowy
Na listingu wprowadzono dwie drobne zmiany. Pierwsza polega na modyfikacji dyrektywy ng-repeat w celu odzwierciedlenia nowej struktury danych przeznaczonej do obsługi hierarchii zakresów. Druga zmiana to uaktualnienie kodu przycisku Odśwież, aby jego kliknięcie spowodowało wywołanie funkcji refreshProducts() zamiast listProducts(). Ogólny efekt wprowadzonych zmian jest taki, że dane są automatycznie pobierane z serwera po aktywacji trasy /list, co pozwala na uproszczenie kodu aplikacji.
Podsumowanie W tym rozdziale poznałeś wbudowane usługi AngularJS przeznaczone do obsługi routingu. Jest to zaawansowana technika, która najbardziej przydatna okazuje się w skomplikowanych i dużych aplikacjach, ponieważ ułatwia pracę z nimi. W następnym rozdziale zajmiemy się usługami zapewniającymi obsługę animacji zawartości oraz obsługę zdarzeń dotknięć.
573
AngularJS. Profesjonalne techniki
574
ROZDZIAŁ 23
Usługi dla animacji i dotknięć W tym rozdziale zajmiemy się usługami, które AngularJS udostępnia w celu animacji zmiany zawartości w modelu DOM oraz przeznaczone do obsługi zdarzeń dotknięć. Podsumowanie materiału zamieszczonego w rozdziale przedstawiono w tabeli 23.1. Tabela 23.1. Podsumowanie materiału zamieszczonego w rozdziale Problem
Rozwiązanie
Listing
W jaki sposób animować przejścia między widokami?
Zadeklaruj zależność od modułu ngAnimate, użyj specjalnej struktury nazw w celu zdefiniowania stylów CSS zawierających animacje lub przejścia. Przygotowane klasy zastosuj w dyrektywach, które zarządzają zawartością.
od 1 do 4
Jak wykryć gest machnięcia?
Użyj dyrektyw ng-swipe-left i ng-swipe-right.
5
Przygotowanie przykładowego projektu W tym rozdziale będziemy kontynuować pracę z aplikacją tworzoną w rozdziale 22. Aplikacja ta pobiera dane za pomocą API RESTful dostarczanego przez serwer Deployd. Omawiane tutaj usługi nie są ograniczone ani nawet nie są powiązane z danymi typu RESTful lub żądaniami Ajax. Jednak sama aplikacja zapewnia wygodną bazę do zademonstrowania nowych funkcji.
Animacja elementów Usługa $animate pozwala na dostarczenie efektów przejść podczas dodawania, usuwania lub przenoszenia elementów w modelu DOM. Usługa $animate nie definiuje żadnych animacji, ale opiera się na animacjach CSS3 i przejściach. Szczegółowe omówienie animacji CSS3 i przejść wykracza poza zakres tematyczny tej książki. Więcej informacji znajdziesz w mojej książce HTML5. Przewodnik encyklopedyczny, wydanej przez Helion. Uwaga Niestety, natura animacji uniemożliwia pokazanie ich na statycznych rysunkach w książce. Aby zrozumieć sposób ich działania, musisz zobaczyć, co jest ich efektem. Na szczęście nie trzeba przepisywać całego przedstawionego tu kodu. Przykłady znajdziesz w archiwum, które możesz pobrać ze strony internetowej towarzyszącej książce: http://helion.pl/ksiazki/angupt.htm.
AngularJS. Profesjonalne techniki
Kiedy i dlaczego używać usługi animacji? Animacje mogą być użyteczne, ponieważ przyciągają uwagę użytkownika na ważne zmiany zachodzące w układzie aplikacji, a tym samym powodują, że przejście między stanami w aplikacji staje się mniej irytujące. Liczni programiści traktują animacje jako sposób wyrażenia własnej frustracji wynikającej z niespełnionych ambicji graficznych i umieszczają je praktycznie wszędzie. Rezultat takiego podejścia może być irytujący, zwłaszcza jeśli użytkownik ogląda efekty specjalne za każdym razem, gdy wykonuje dane zadanie. W przypadku aplikacji biznesowej, gdzie użytkownik będzie codziennie wykonywał ten sam zestaw czynności, jego wrażenia mogą być fatalne. Animacje powinny być subtelne, krótkie i szybkie. Ich celem jest zwrócenie uwagi użytkownika na zachodzącą zmianę. Dlatego też z animacji korzystaj spójnie, ostrożnie i przede wszystkim sporadycznie.
Instalacja modułu ngAnimation Usługa $animation jest zdefiniowana w module opcjonalnym ngAnimate, który należy pobrać i umieścić w katalogu angularjs. Przejdź do witryny https://angularjs.org/, kliknij przycisk Download, wybierz wersję (gdy powstawała ta książka, była to wersja 1.2.22), a następnie kliknij łącze Browse additional modules w lewym dolnym rogu, jak pokazano na rysunku 23.1.
Rysunek 23.1. Pobieranie modułu opcjonalnego Pobierz plik angular-animate.js i umieść go w katalogu angularjs. Na listingu 23.1 przedstawiono dodanie w dokumencie products.html elementu
576
Rozdział 23. Usługi dla animacji i dotknięć
Produkty
Na listingu 23.2 przedstawiono zależności modułu zdefiniowane w pliku products.js i uwzględniające ngAnimate.
Listing 23.2. Dodawanie zależności modułu w pliku products.js angular.module("exampleApp", ["increment", "ngResource", "ngRoute", "ngAnimate"]) .constant("baseUrl", "http://localhost:5500/products/") .factory("productsResource", function ($resource, baseUrl) { return $resource(baseUrl + ":id", { id: "@id" }, { create: { method: "POST" }, save: { method: "PUT" } }); }) .config(function ($routeProvider, $locationProvider) { ...
Definiowanie i stosowanie animacji W celu zastosowania animacji nie trzeba pracować bezpośrednio z usługą $animate. Animacje i przejścia są definiowane za pomocą CSS w oparciu o specjalną konwencję nazw, a następnie wspomniane nazwy są stosowane jako klasy w elementach, w których użyto dyrektyw AngularJS. Najlepszym sposobem wyjaśnienia będzie zapoznanie się z przykładem. Na listingu 23.3 przedstawiono zmiany, jakie wprowadzono w pliku products.html, aby animować przejście między widokami. Listing 23.3. Animowanie przejść między widokami w pliku products.html
Produkty
Produkty
577
AngularJS. Profesjonalne techniki
Kluczem do zrozumienia tego, co się dzieje w omawianym przykładzie, jest wiedza, że kilka wbudowanych dyrektyw obsługuje animacje podczas zmiany zawartości. W tabeli 23.2 wymieniono dyrektywy oraz nazwy nadawane zmianom w celu zastosowania animacji. Tabela 23.2. Wbudowane dyrektywy obsługujące animacje oraz nazwy z nimi związane Dyrektywa
Nazwy
ng-repeat
enter, leave, move
ng-view
enter, leave
ng-include
enter, leave
ng-switch
enter, leave
ng-if
enter, leave
ng-class
add, remove
ng-show
add, remove
ng-hide
add, remove
Nazwa enter jest używana, gdy zawartość jest wyświetlana użytkownikowi; nazwa leave — gdy zawartość jest przed nim ukrywana. Z kolei nazwa move jest wyświetlana, gdy zawartość jest przenoszona w modelu DOM. Nazwy add i remove są wykorzystywane podczas dodawania i usuwania zawartości z modelu DOM. Odwołując się do informacji przedstawionych w tabeli 23.2, możesz łatwo określić przeznaczenie kodu elementu ...
opacity: 0; }
Zdefiniowaliśmy dwie klasy CSS o nazwach ngFade.ng-enter i ngFade.ng-enter-active. Nazwy tych klas są ważne. Pierwsza część nazwy — w omawianym przykładzie ngFade — to nazwa używana do zastosowania animacji lub przejścia w elemencie, na przykład: ...
...
Wskazówka Nie trzeba poprzedzać nazwy klasy najwyższego poziomu prefiksem ng. Takie rozwiązanie zastosowano w omawianym przykładzie, aby uniknąć konfliktów z innymi klasami CSS. Przejście zdefiniowane w przykładzie powoduje pojawianie się elementów w widoku i dlatego możesz być kuszony chęcią użycia nazwy fade. Jednak framework Bootstrap wykorzystywany w przykładzie również zawiera definicję klasy CSS o nazwie fade i tego rodzaju konflikt nazw może spowodować problemy. Spotykałem się z nimi tak często, że postanowiłem stosować prefiks ng dla klas animacji AngularJS i tym samym zagwarantować unikalność nazw w aplikacji.
Druga część nazwy wskazuje bibliotece AngularJS przeznaczenie danego stylu CSS. W omawianym przykładzie mamy dwie nazwy: ng-enter i ng-enter-active. Tutaj prefiks ng- jest wymagany, a AngularJS nie przetworzy animacji bez wymienionego prefiksu. Kolejna część to nazwa odpowiadająca szczegółom
578
Rozdział 23. Usługi dla animacji i dotknięć
przedstawionym w tabeli 23.2. Używamy dyrektywy ng-view, która będzie odtwarzać animacje podczas wyświetlania widoku użytkownikowi oraz w trakcie ukrywania widoku przed użytkownikiem. W stylach zastosowano prefiks ng-enter, co nakazuje AngularJS odtwarzanie animacji podczas wyświetlania widoku użytkownikowi. Dwa style definiują punkty początkowy i końcowy przejścia, jakie ma być wykorzystywane przez dyrektywę ng-view. W stylu ng-enter zdefiniowano punkt początkowy i szczegóły przejścia. Określono, że początkowa wartość właściwości CSS opacity wynosi 0 (widok jest przezroczysty, a więc niewidoczny dla użytkownika), a przejście ma trwać 0,1 sekundy (naprawdę nie żartowałem, mówiąc wcześniej, że animacje powinny być krótkie). Z kolei styl ng-enter-active definiuje punkt końcowy przejścia. Określono, że wartość właściwości CSS opacity ma wynosić 1, czyli widok będzie nieprzejrzysty i całkowicie widzialny dla użytkownika. Ogólny efekt jest taki, że podczas zmiany widoku dyrektywa ng-view będzie stosowała wymienione klasy CSS w nowym widoku, który tym samym przejdzie od całkowicie przezroczystego do nieprzezroczystego, czyli wyłoni się nowy widok.
Uniknięcie niebezpieczeństwa w postaci jednoczesnych animacji Naturalne wydaje się przyjęcie założenia o konieczności animacji usunięcia poprzedniej zawartości i pojawienia się nowej, ale to może stanowić kłopot. Problem polega na tym, że w normalnych warunkach dyrektywa ng-view dodaje nowy widok do modelu DOM, a następnie usuwa z niego stary. Jeżeli spróbujesz animować pojawienie się nowej zawartości oraz ukrycie poprzedniej, skutkiem najczęściej będzie wyświetlenie obu widoków. Na listingu 23.4 przedstawiono zmiany wprowadzone w pliku products.html, które mają pokazać omówiony problem. Listing 23.4. Dodawanie do pliku products.html animacji usuwania starej zawartości
Produkty
Produkty
Skutkiem działania przedstawionego kodu jest to, że przez krótką chwilę oba widoki są widoczne dla użytkownika, co jest niezbyt zachęcające i może wprowadzić zamieszanie. Dyrektywie ng-view nie przeszkadza próba umieszczenia jednego widoku na drugim; nowa zawartość zostaje wyświetlona pod dotychczasową, jak pokazano na rysunku 23.2. 579
AngularJS. Profesjonalne techniki
Rysunek 23.2. Efekt uboczny jednoczesnego odtwarzania dwóch animacji Zawartość jest częściowo przezroczysta, ponieważ rysunek został utworzony w połowie przejścia, a wartość opacity dla obu widoków wynosi 0.5. Lepszym rozwiązaniem jest po prostu animacja jedynie nowego widoku z wykorzystaniem enter. Efekt będzie subtelny, ale samo przejście między widokami będzie mniej irytujące
i zwróci uwagę użytkownika.
Obsługa zdarzeń dotknięć Moduł ngTouch zawiera usługę $swipe, którą można wykorzystać do poprawy obsługi aplikacji w urządzeniach wyposażonych w ekrany dotykowe. Aplikacja będzie wzbogacona o dodatkowe zdarzenia poza podstawowymi, które wymieniono w rozdziale 11. Zdarzenia modułu ngTouch dostarczają powiadomienia o gestach machnięcia oraz zamiennik dla dyrektywy ng-click, co rozwiązuje najczęstszy problem ze zdarzeniami w aplikacji uruchomionej w urządzeniach z ekranami dotykowymi.
580
Rozdział 23. Usługi dla animacji i dotknięć
Kiedy i dlaczego używać zdarzeń dotknięć? Gest machnięcia staje się przydatny, jeżeli chcesz usprawnić działanie aplikacji w urządzeniach wyposażonych w ekrany dotykowe. Zdarzenia ngTouch mogą być używane do wykrywania gestów machnięcia od lewej do prawej lub od prawej do lewej strony. Aby uniknąć wprawienia użytkownika w zakłopotanie, trzeba koniecznie się upewnić, że akcje wykonywane w odpowiedzi na gesty są spójne z pozostałą częścią platformy lub przynajmniej zgodne ze sposobem działania przeglądarki internetowej na danej platformie. Na przykład machnięcie od prawej do lewej strony w przeglądarce internetowej najczęściej oznacza „wróć”. Bardzo ważne jest, aby w aplikacji nie interpretować gestów w inny sposób. Zamiennik dyrektywy ng-click jest użyteczny w urządzeniach wyposażonych w ekrany dotykowe, ponieważ pozwala na syntezę zdarzeń click i zapewnia ich zgodność z kodem JavaScript utworzonym pod kątem zdarzeń dla myszy. Przeglądarka internetowa w urządzeniu z ekranem dotykowym najczęściej czeka przez 300 milisekund od chwili dotknięcia ekranu, aby sprawdzić, czy nastąpi kolejne dotknięcie. Jeżeli dotknięcie nie wystąpi, to przeglądarka internetowa generuje zdarzenie touch przedstawiające naciśnięcie oraz zdarzenie click w celu symulacji myszy. Jednak nawet 300-milisekundowe opóźnienie jest zauważalne przez użytkownika i może spowodować, że odbierze on aplikację jako wolno reagującą na jego działania. Oferowany przez moduł ngTouch zamiennik dla ng-click nie oczekuje na drugie dotknięcie ekranu i szybciej emituje zdarzenie click.
Instalacja modułu ngTouch Moduł ngTouch musi być pobrany z witryny https://angularjs.org/. Przeprowadź taką samą procedurę jak w przypadku modułu ngAnimate wcześniej w rozdziale, ale tym razem pobierz plik angular-touch.js i umieść go w katalogu angularjs.
Obsługa gestu machnięcia Aby zademonstrować gest machnięcia, w katalogu angularjs tworzymy nowy plik HTML o nazwie swipe.html i umieszczamy w nim zawartość przedstawioną na listingu 23.5. Listing 23.5. Zawartość pliku swipe.html
Zdarzenia machnięcia
Machnij tutaj
Machnięcie: {{swipeType}}
Na początku deklarujemy zależność od modułu ngTouch. Procedura obsługi zdarzeń jest zastosowana za pomocą dyrektyw ng-swipe-left i ng-swipe-right. Wymienione dyrektywy wykorzystano w elemencie i zdefiniowano dla nich wywołanie funkcji kontrolera odpowiedzialnej za uaktualnienie właściwości zakresu, która jest wyświetlana przez osadzone wyrażenie dołączania danych. Gest machnięcia będzie wykryty w urządzeniach wyposażonych w ekrany dotykowe lub po wykonaniu takiego gestu myszą. Najlepszym sposobem przetestowania zdarzeń dotknięć jest oczywiście użycie urządzenia z ekranem dotykowym. Jeżeli nie masz takiego pod ręką, skorzystaj z przeglądarki Google Chrome, która może symulować dotknięcia. Kliknij ikonę emulacji urządzeń mobilnych wyświetlaną w lewym górnym rogu okna narzędzi F12 (ikona przedstawia smartfona), następnie przejdź na kartę Emulate, kliknij Sensors i zaznacz opcję Emulate touch screen. Firma Google nieustannie zmienia układ narzędzi F12, więc możesz być zmuszony do odszukania odpowiedniej opcji. Po włączeniu symulacji dotknięć możesz używać myszy do generowania gestów machnięcia w lewo lub w prawo, a przeglądarka internetowa wygeneruje odpowiednie zdarzenia dotknięć, jak pokazano na rysunku 23.3.
Rysunek 23.3. Wykrywanie gestów machnięć
Użycie zamiennika dla dyrektywy ng-click Nie przedstawię tutaj użycia zamiennika dla dyrektywy ng-click, ponieważ tego rodzaju rozwiązanie zostało omówione w rozdziale 11.
Podsumowanie W tym rozdziale przedstawiono usługi oferowane przez AngularJS do animacji elementów oraz wykrywania gestów. W kolejnym rozdziale zajmiemy się pewnymi usługami, które są używane wewnętrznie przez AngularJS, ale stanowią podstawę dla sposobu działania funkcji dotyczących testów jednostkowych.
582
ROZDZIAŁ 24
Usługi rejestracji komponentów i ich wstrzykiwania W tym rozdziale zostaną omówione usługi, które AngularJS wykorzystuje w tle do rejestracji komponentów oraz ich wstrzykiwania w celu rozwiązania zależności. Wprawdzie nie są to funkcje używane w każdym projekcie, ale i tak pozostają interesujące, ponieważ dostarczają informacji na temat wewnętrznego sposobu działania AngularJS. Ponadto są przydatne w trakcie przeprowadzania testów jednostkowych, które będą tematem rozdziału 25. Podsumowanie materiału zamieszczonego w rozdziale przedstawiono w tabeli 24.1. Tabela 24.1. Podsumowanie materiału zamieszczonego w rozdziale Problem
Rozwiązanie
Listing
Jak udekorować usługę?
Użyj metody $provide.decorator().
1
Jak wykryć zależności zadeklarowane przez funkcję?
Użyj usługi $injector.
od 2 do 5
W jaki sposób uzyskać dostęp do usługi $injector bez zadeklarowania zależności?
Użyj metody $rootElement.injector().
6
Kiedy i dlaczego używać usług rejestracji komponentów i ich wstrzykiwania? Z tych usług nie musisz korzystać bezpośrednio, ponieważ ich funkcjonalność jest udostępniana za pośrednictwem metod modułu Module (omówionego w rozdziale 18.), ponadto są przez AngularJS używane w tle. Mimo wszystko zdecydowałem się na ich omówienie, ponieważ ta wiedza pomaga w zrozumieniu możliwości AngularJS oraz może być użyteczna podczas przeprowadzania testów jednostkowych.
Przygotowanie przykładowego projektu Na potrzeby materiału omawianego w rozdziale usuwamy zawartość katalogu angularjs, a następnie umieszczamy w nim podstawowe pliki AngularJS i Bootstrap, zgodnie z opisem przedstawionym w rozdziale 1.
AngularJS. Profesjonalne techniki
Rejestracja komponentów AngularJS Usługa $provide jest używana do rejestracji komponentów, takich jak usługi, aby mogły być wstrzykiwane i tym samym zapewnić możliwość spełnienia zależności. (Rzeczywistą operację wstrzykiwania przeprowadza usługa $injector, którą omówimy w podrozdziale „Zarządzanie wstrzykiwaniem zależności”, w dalszej części rozdziału). W większości przypadków metody definiowane przez usługę $provide są udostępniane za pomocą typu Module. Jednak istnieje pewna metoda niedostępna przez Module i oferująca użyteczną, choć niszową funkcję. W tabeli 24.2 wymieniono metody definiowane przez usługę $provide. Tabela 24.2. Metody zdefiniowane przez usługę $provide Nazwa
Opis
constant(nazwa, wartość)
Definiuje stałą, jak przedstawiono w rozdziale 9.
decorator(nazwa, usługa)
Definiuje dekorator usługi, co zostanie wyjaśnione w dalszej części rozdziału.
factory(nazwa, usługa)
Definiuje usługę, jak przedstawiono w rozdziale 18.
provider(nazwa, usługa)
Definiuje usługę, jak przedstawiono w rozdziale 18.
service(nazwa, dostawca)
Definiuje usługę, jak przedstawiono w rozdziale 18.
value(nazwa, wartość)
Definiuje stałą, jak przedstawiono w rozdziale 9.
Metoda, która nie jest udostępniana za pomocą typu Module, to decorator(). Ta metoda jest wykorzystywana do przechwytywania żądań do usługi w celu dostarczenia innej lub dodatkowej funkcjonalności. Na listingu 24.1 przedstawiono zastosowanie metody decorator() do zmiany zachowania usługi $log w nowym pliku HTML o nazwie components.html, który należy umieścić w katalogu angularjs. Listing 24.1. Zawartość pliku components.html
Komponenty
584
Rozdział 24. Usługi rejestracji komponentów i ich wstrzykiwania Naciśnij mnie!
Omawiana aplikacja składa się z przycisku wykorzystującego dyrektywę ng-click do wywołania funkcji zakresu o nazwie handleClick(), wyświetlającą w konsoli komunikat za pomocą usługi $log omówionej w rozdziale 19. Ważny fragment listingu został pogrubiony — to omówiona w rozdziale 9. metoda Module.config(). Przygotowana tutaj funkcja konfiguracyjna deklaruje zależność od usługi $provide, co pozwala na wywołanie metody decorator(). Argumentami metody decorator() są nazwa usługi przeznaczonej do udekorowania (podana w postaci dosłownego ciągu tekstowego) i funkcja dekoratora, która musi deklarować zależność od usługi $delegate używanej do przekazania pierwotnej usługi do naszej funkcji. Wskazówka Pierwszym argumentem metody decorator() musi być ciąg tekstowy, taki jak "$log", a nie $log. Ten argument wskazuje bibliotece AngularJS, która usługa ma zostać udekorowana, i nie jest wykorzystywany do zadeklarowania zależności.
W omawianym przykładzie pierwszy argument ma wartość "$log". Tym samym nakazujemy AngularJS udekorowanie usługi $log omówionej dokładnie w rozdziale 19. AngularJS utworzy więc egzemplarz obiektu usługi $log i przekaże go jako argument $delegate funkcji dekoratora. W funkcji dekoratora można wprowadzić dowolne zmiany w obiekcie $delegate, a wartość zwrotna będzie użyta do rozwiązania zależności w usłudze $log, gdy będzie wymagana w innych fragmentach aplikacji. Wskazówka Wartością zwrotną funkcji dekoratora musi być obiekt przeznaczony do rozwiązywania zależności dla wskazanej usługi. Jeżeli funkcja nie zwróci wartości, to zależności będą rozwiązane za pomocą wartości JavaScript — undefined.
Oto sposób udekorowania usługi w przykładowej aplikacji: ... $provide.decorator("$log", function ($delegate) { $delegate.originalLog = $delegate.log; $delegate.log = function (message) { $delegate.originalLog("Udekorowano: " + message); } return $delegate; }); ...
Nazwę metody log() zmieniamy na originalLog() oraz dodajemy nową metodę, która słowem Udekorowano poprzedza komunikat wyświetlany w konsoli. Efekt możesz zobaczyć po uruchomieniu aplikacji i naciśnięciu przycisku; w konsoli JavaScript zostaną wyświetlone następujące dane wyjściowe: Udekorowano: naciśnięto przycisk
Usługę można zmienić w dowolny sposób. Należy jednak pamiętać, że obiekt zwracany przez funkcję dekoratora będzie przekazywany komponentom, które mają pewne oczekiwania dotyczące natury obiektu usługi. Na przykład nie ma sensu zmiana nazwy metody log() w usłudze $log, aby to była nazwa detailedLog(), ponieważ wszystkie komponenty deklarujące zależność od usługi $log będą oczekiwały metody o nazwie log() i nadal będą używać pierwotnej nazwy metody. Dlatego też dekorowanie usług
585
AngularJS. Profesjonalne techniki
jest najbardziej użyteczne podczas wprowadzania drobnych zmian. Najczęściej dotyczy to komunikatów wyświetlanych w konsoli JavaScript podczas wywoływania metod usługi, co będzie niezwykle przydatne w trakcie debugowania skomplikowanych problemów.
Zarządzanie wstrzykiwaniem zależności Usługa $injector jest odpowiedzialna za ustalanie i rozwiązywanie zależności deklarowanych przez funkcje. Metody obsługiwane przez usługę $injector wymieniono w tabeli 24.3. Tabela 24.3. Metody zdefiniowane przez usługę $injector Nazwa
Opis
annotate(funkcja)
Pobiera argumenty wskazanej funkcji, między innymi te, które nie odpowiadają usługom.
get(nazwa)
Pobiera obiekt usługi dla podanej nazwy usługi.
has(nazwa)
Zwraca wartość true, jeśli istnieje usługa dla podanej nazwy.
invoke(funkcja, wartość_this, argumenty)
Wywołuje wskazaną funkcję, używając przy tym podanej wartości dla this oraz podanych wartości argumentów innych niż usługi.
Usługa $injector jest niezwykle ważna; w hierarchii zajmuje drugie miejsce, tuż po podstawowych komponentach biblioteki AngularJS. Bardzo rzadko będziesz z nią pracować bezpośrednio, ale wiedza dotycząca tej usługi pomaga w zrozumieniu sposobu działania AngularJS i dostosowania biblioteki do własnych potrzeb. Jednak wprowadzanie tego rodzaju modyfikacji powinno być dokładnie przemyślane i przetestowane. Wskazówka AngularJS zawiera jeszcze usługę o nazwie $controller, która tworzy egzemplarz kontrolerów. Jedyna sytuacja, w której musisz bezpośrednio tworzyć kontrolery, występuje podczas opracowywania testów jednostkowych. Dokładne omówienie usługi $controller znajdziesz w rozdziale 25.
Ustalenie zależności funkcji JavaScript to zmienny i dynamiczny język; zawiera wiele rekomendacji, ale nie posiada możliwości opisywania funkcji w celu zarządzania ich wykonywaniem i zachowaniem. W innych językach programowania, na przykład C#, obsługiwane są funkcje, takie jak atrybuty pozwalające na podawanie instrukcji lub metadanych dotyczących funkcji. Brak możliwości opisywania oznacza, że AngularJS musi posiadać dość rozbudowany mechanizm wstrzykiwania zależności obsługiwany przez dopasowywanie nazw argumentów funkcji do usług. Programista opracowujący funkcję zwykle nadaje nazwy argumentom, ale w AngularJS te nazwy mają znaczenie specjalne. Metoda annotate() zdefiniowana przez usługę $injector jest wykorzystywana w celu pobrania zależności deklarowanych przez funkcję, jak przedstawiono na listingu 24.2. Listing 24.2. Przykład pobrania w pliku components.html zależności funkcji
Komponenty
Naciśnij mnie!
W omawianym przykładzie zdefiniowaliśmy funkcję o nazwie logClick(), której działanie zależy od usług $log i $exceptionHandler, a także od zwykłego argumentu o nazwie message. Żadna z wymienionych usług nie została zadeklarowana przez funkcję fabryki kontrolera jako zależności. Ten przykład ma dostarczyć funkcję logClick() wraz z zależnościami, aby można było ją wykonać. Uwaga Przedstawione tutaj rozwiązanie nie jest stosowane w rzeczywistych projektach. Pokazane użycie usługi $injector ma przybliżyć wewnętrzny sposób działania AngularJS. Jeżeli chcesz koncentrować się na codziennych technikach, możesz jedynie przejrzeć zaprezentowane tu przykłady.
Pierwszym krokiem jest pobranie zależności z samej funkcji, co odbywa się za pośrednictwem metody $injector.annotate(), w przedstawiony poniżej sposób: ... var deps = $injector.annotate(logClick); for (var i = 0; i < deps.length; i++) { console.log("Zależności: " + deps[i]); } ...
Argumentem metody annotate() jest funkcja przeznaczona do przeanalizowania. Wartością zwrotną będzie tablica argumentów funkcji, które w omawianym przykładzie są po prostu wyświetlane w konsoli JavaScript, generując tym samym następujące dane wyjściowe: Zależności: $log Zależności: $exceptionHandler Zależności: message
587
AngularJS. Profesjonalne techniki
Jak widzisz w wyświetlonych danych wyjściowych, otrzymaliśmy listę wszystkich argumentów pobieranych przez funkcję. Oczywiście nie wszystkie są zależnościami w postaci usług, ale można użyć metody $injector.has() w celu sprawdzenia, czy dana usługa została zarejestrowana. Przykład takiego rozwiązania przedstawiono na listingu 24.3. Listing 24.3. Filtrowanie argumentów funkcji w celu znalezienia usług w pliku components.html ...
...
Wywołania metody has() informują o dostępności usług $log i $exceptionHandler(), natomiast argument message nie jest zależnością w postaci usługi, jak pokazano w poniższych danych wyjściowych: Zależności: $log Zależności: $exceptionHandler
Pobieranie egzemplarzy usługi Wymagany obiekt usługi można pobrać za pomocą metody $injector.get(). Argumentem tej metody jest nazwa usługi do pobrania, a wartością zwrotną obiekt usługi. Używając obiektów pobranych za pomocą metody get() i podając wartość argumentowi w postaci innej niż usługa, zyskujesz możliwość wywołania funkcji logClick(), jak przedstawiono na listingu 24.4. Listing 24.4. Pobieranie obiektów usług i wykonywanie funkcji w pliku components.html ...
...
Kod na listingu przygotowuje tablicę argumentów niezbędnych do wywołania funkcji, umieszcza w niej usługi i wartość dla argumentu message. Następnie wykorzystujemy przydatną metodę JavaScript o nazwie apply(), która pozwala na wywołanie funkcji z użyciem tablicy jej argumentów. Wskazówka Być może nie spotkałeś się wcześniej z metodą apply(), ponieważ pomimo swojej użyteczności nie jest ona zbyt często wykorzystywana. Pierwszym argumentem metody jest obiekt przypisywany this podczas wykonania funkcji, natomiast drugim jest tablica argumentów przekazywana funkcji.
Jeżeli w przeglądarce internetowej wczytasz dokument components.html i dwukrotnie naciśniesz przycisk, to w konsoli JavaScript zobaczysz dane wyjściowe wygenerowane przez usługi $log i $exceptionHandler: naciśnięto przycisk już naciśnięto
Uproszczenie procesu wywołania Musimy pokonać długą drogę, zanim uzyskamy możliwość wykonania funkcji, ponieważ metoda $injector.invoke() zajmuje się wyszukiwaniem usług i zarządzaniem wartościami dodatkowymi, które trzeba dostarczyć funkcji. Na listingu 24.5 przedstawiono użycie metody invoke() w omawianym przykładzie. Listing 24.5. Użycie metody invoke() w pliku components.html ...
...
Argumentami metody invoke() są funkcja przeznaczona do wywołania, wartość dla this oraz obiekt, którego właściwości odpowiadają argumentom funkcji i nie są zależnościami w postaci usług.
Pobranie usługi $injector z elementu głównego Usługa $rootElement zapewnia dostęp do elementu HTML, w którym zastosowano dyrektywę ng-app. To będzie element główny aplikacji AngularJS. Usługa $rootElement jest przedstawiana w postaci obiektu jqLite, co oznacza możliwość wykorzystania jqLite do wyszukiwania lub modyfikacji modelu DOM za pomocą metod jqLite omówionych w rozdziale 15. W tym rozdziale interesuje nas dodatkowa metoda usługi $rootElement o nazwie injector(). Wartością zwrotną tej metody jest obiekt usługi $injector. Na listingu 24.6 przedstawiono przykład zastąpienia zależności od usługi $injector usługą $rootElement. Listing 24.6. Użycie usługi $rootElement w pliku components.html ...
...
Wskazówka Nie znalazłem jeszcze wystarczająco ważnego powodu uzyskiwania dostępu do usługi $injector za pomocą usługi $rootElement, ale dla porządku zamieściłem w rozdziale informacje o tej możliwości.
590
Rozdział 24. Usługi rejestracji komponentów i ich wstrzykiwania
Podsumowanie W tym rozdziale przedstawiono usługi odpowiedzialne za zarządzanie usługami i wstrzykiwanie ich w funkcjach w celu rozwiązania zależności. Usługi te nie są wykorzystywane w każdym projekcie, ale dostarczają interesujących informacji o sposobie działania AngularJS. W kolejnym rozdziale poznasz możliwości, jakie AngularJS oferuje w zakresie testów jednostkowych.
591
AngularJS. Profesjonalne techniki
592
ROZDZIAŁ 25
Testy jednostkowe W tym rozdziale zostaną przedstawione możliwości, jakie AngularJS oferuje w zakresie testów jednostkowych. W szczególności przyjrzymy się usługom, które ułatwiają izolację fragmentu kodu od pozostałej części frameworka AngularJS, co pozwala na przeprowadzenie dokładnego i spójnego testu. Podsumowanie materiału zamieszczonego w rozdziale przedstawiono w tabeli 25.1. Tabela 25.1. Podsumowanie materiału zamieszczonego w rozdziale Problem
Rozwiązanie
Listing
Jak przygotować podstawowy test Jasmine?
Użyj funkcji: describe(), beforeEach(), it() i expect().
od 1 do 4
Jak przygotować test AngularJS?
W celu wczytania modułu przeznaczonego do przetestowania użyj metody angular.mock.module(), natomiast do rozwiązania zależności — metody angular.mock.inject().
5
W jaki sposób przygotować symulacje żądania HTTP?
Użyj usługi $httpBackend oferowanej przez moduł ngMocks.
6i7
W jaki sposób przygotować symulacje upływu czasu ważności oraz odstępów czasu?
Użyj usług $interval i $timeout oferowanych przez moduł ngMock.
8i9
W jaki sposób przetestować rejestrację danych?
Użyj usługi $log oferowanej przez moduł ngMock.
10 i 11
W jaki sposób przetestować filtr?
Utwórz egzemplarz filtru za pomocą usługi $filter.
12 i 13
Jak przetestować dyrektywę?
Użyj usługi $compile w celu wygenerowania funkcji, która wywołana wraz z argumentem zakresu będzie mogła być wykorzystana do wygenerowania zawartości HTML. Ta zawartość będzie następnie użyta wraz z jqLite.
14 i 15
Jak przetestować usługę?
Użyj metody angular.mock.inject() w celu rozwiązania zależności usługi przeznaczonej do przetestowania.
16 i 17
AngularJS. Profesjonalne techniki
Kiedy i dlaczego przeprowadzać testy jednostkowe? Testy jednostkowe to technika izolacji pojedynczej, niewielkiej funkcjonalności w celu jej przetestowania niezależnie od pozostałej części aplikacji AngularJS. Dzięki starannemu przeprowadzeniu testy jednostkowe mogą zmniejszyć liczbę defektów oprogramowania występujących na późniejszych etapach procesu tworzenia aplikacji, a zwłaszcza po jej udostępnieniu użytkownikom. Najlepiej, aby testy jednostkowe były przeprowadzane przez zespół posiadający duże umiejętności w zakresie projektowania oraz wiedzę dotyczącą przeznaczenia utworzonego oprogramowania. Bez wymienionych umiejętności i szerszej perspektywy zbyt wąski zakres testów jednostkowych może wywierać za duży nacisk na jakość poszczególnych komponentów kosztem ogólnej struktury budowanej aplikacji. Najgorsze z możliwych środowisk do przeprowadzania testów jednostkowych to niestety takie, z którym się najczęściej spotykam: ogromne projekty korporacyjne obsługiwane przez tysiące programistów. W tego rodzaju projektach programiści mają tylko niewielką wiedzę o nadrzędnych celach aplikacji, a zaliczanie testów jednostkowych bardzo szybko staje się jedyną miarą jakości. To wymaga od programistów przyjmowania założeń dotyczących zewnętrznych danych wejściowych dla tworzonego przez nich kodu, które okazują się niewłaściwe. W takich sytuacjach projekt z zaliczonymi testami jednostkowymi ugrzęźnie na testach integracji, ponieważ wspomniane wcześniej założenia zostają odkryte i okazują się nieadekwatne. Mimo wszystko testy jednostkowe mogą być narzędziem o dużych możliwościach, o ile będą stosowane z rozwagą. Upewnij się o umiejętności spożytkowania płynących z nich korzyści. Musisz także wiedzieć, że przeprowadzanie testów jednostkowych wywołuje u wielu programistów naturalną inklinację do kierowania ich uwagi do wewnątrz, a zaliczenie testów jednostkowych nie oznacza prawidłowego współdziałania budowanych komponentów. Testy jednostkowe potraktuj jako część większej strategii przeprowadzania testów E2E (ang. end-to-end). Projekt AngularJS zaleca oprogramowanie Protractor do przeprowadzania testów E2E; możesz je pobrać ze strony https://github.com/angular/protractor.
Przygotowanie przykładowego projektu Na potrzeby materiału omawianego w rozdziale zerujemy zawartość katalogu angularjs, a następnie umieszczamy w nim podstawowe pliki AngularJS i Bootstrap, zgodnie z opisem przedstawionym w rozdziale 1. Ostrzeżenie W poprzednich rozdziałach naprawdę nie miało znaczenia to, czy zignorowałeś sugestie dotyczące usunięcia zawartości katalogu angularjs. W tym rozdziale ma to duże znaczenie; nie osiągniesz oczekiwanych wyników, dopóki nie usuniesz z katalogu angularjs wcześniej utworzonych plików JavaScript.
Instalacja modułu ngMock AngularJS oferuje moduł opcjonalny o nazwie ngMock, dostarczający użyteczne narzędzia do przeprowadzania testów jednostkowych. Przejdź do witryny https://angularjs.org/, kliknij przycisk Download, wybierz wersję (gdy powstawała ta książka, była to wersja 1.2.22), a następnie kliknij łącze Browse additional modules w lewym dolnym rogu, jak pokazano na rysunku 25.1. Pobierz plik angular-mocks.js i umieść go w katalogu angularjs.
Utworzenie konfiguracji testowej Podczas przygotowań poczynionych w rozdziale 1. zainstalowaliśmy oprogramowanie Karma przeznaczone do przeprowadzania testów. To oprogramowanie musi być skonfigurowane dla każdego nowego projektu. Przejdź do katalogu angularjs, a następnie z poziomu wiersza poleceń wydaj następujące polecenie: karma init karma.config.js
594
Rozdział 25. Testy jednostkowe
Rysunek 25.1. Pobieranie opcjonalnego modułu W ten sposób rozpoczniesz proces konfiguracji oprogramowania Karma i będziesz musiał odpowiedzieć na kilka pytań. Pytania i odpowiedzi na nie wymieniono w tabeli 25.2. Tabela 25.2. Pytania pojawiające się podczas konfiguracji oprogramowania Karma Pytanie
Odpowiedź
Opis
Z którego frameworka testowania chcesz skorzystać?
Jasmine
Karma posiada wbudowaną obsługę trzech popularnych frameworków testowania: Jasmine, Mocha i QUnit. W tym rozdziale będziemy używać Jasmine, ale wszystkie trzy mają swoich fanów i użytkowników.
Czy chcesz użyć Require.js?
Nie
Require.js to użyteczna biblioteka przeznaczona do zarządzania sposobem wczytywania przez przeglądarkę internetową plików JavaScript i obsługi zależności między nimi. Bibliotekę Require.js dokładnie omówiłem w mojej książce Pro JavaScript for Web Apps, wydanej przez Apress.
Czy chcesz automatycznie przechwytywać dane w przeglądarce internetowej?
Chrome
Oprogramowanie Karma ma możliwość automatycznego uruchamiania kodu testującego w przeglądarce (przeglądarkach). W tym rozdziale będziemy używać jedynie Google Chrome, ale możliwość obsługi wielu przeglądarek internetowych jest użyteczna podczas wykrywania problemów z implementacjami, zwłaszcza w starszych wersjach przeglądarek internetowych.
Gdzie znajdują się pliki źródłowe i testowe?
angular.js
Odpowiedź na to pytanie wskazuje oprogramowaniu Karma, gdzie szukać kodu aplikacji i testów jednostkowych. Bardzo ważne jest podanie biblioteki AngularJS i pliku modułu ngMock przed użyciem znaku wieloznacznego pozwalającego na import innych plików. Otrzymasz ostrzeżenie o braku plików dopasowanych do wzorca tests/*.js, ale nie przejmuj się tym teraz. Katalog tests wkrótce utworzymy.
angularmocks.js *.js tests/*.js
595
AngularJS. Profesjonalne techniki
Tabela 25.2. Pytania pojawiające się podczas konfiguracji oprogramowania Karma — ciąg dalszy Pytanie
Odpowiedź
Opis
Czy jakiekolwiek pliki mają być wykluczone z testów?
Ta opcja pozwala na wskazanie plików, których oprogramowanie Karma nie będzie wczytywać. W tym rozdziale nie korzystamy z tej opcji.
Czy chcesz, aby oprogramowania Karma monitorowało wszystkie pliki i po wykryciu w nich zmian przeprowadzało testy?
Tak
Karma będzie monitorować pliki; po wykryciu w nich jakichkolwiek zmian będzie automatycznie przeprowadzać testy jednostkowe.
Proces konfiguracji tworzy plik o nazwie karma.config.js będący zwykłym plikiem JavaScript zawierającym opcje konfiguracyjne. Ten plik znajdziesz w archiwum materiałów dołączonych do książki, dostępnym na stronie internetowej http://helion.pl/ksiazki/angupt.htm. Dzięki temu zyskasz gwarancję zastosowania tej samej konfiguracji, z której korzystam w tym rozdziale.
Utworzenie przykładowej aplikacji Potrzebujemy przykładowej aplikacji do testowania w tym rozdziale. W katalogu angularjs utwórz więc nowy plik o nazwie app.html i o zawartości przedstawionej na listingu 25.1. Listing 25.1. Zawartość pliku app.html
Przykład
Licznik: {{counter}}
Inkrementacja
Jednym z ograniczeń systemu testującego przygotowywanego w tym rozdziale jest brak możliwości jego użycia do przetestowania zawartości elementów osadzonych w plikach HTML. Testy mogą być przeprowadzane tylko na plikach JavaScript i dlatego w dokumencie app.html nie znajduje się żaden kod AngularJS. Tak naprawdę nie stanowi to większego problemu, ponieważ kod HTML i JavaScript łączymy w tej książce jedynie w celu zachowania prostoty przykładów. W rzeczywistych projektach przekonasz się, że użycie oddzielnych plików JavaScript jest prostsze. Na listingu 25.2 przedstawiono zawartość pliku app.js dodanego do katalogu angularjs i zawierającego kod AngularJS omawianej tutaj aplikacji.
596
Rozdział 25. Testy jednostkowe
Listing 25.2. Zawartość pliku app.js angular.module("exampleApp", []) .controller("defaultCtrl", function ($scope) { $scope.counter = 0; $scope.incrementCounter = function() { $scope.counter++; } });
Jak przedstawiono na listingu, zaczynamy od bardzo prostej aplikacji. Kontroler definiuje zmienną o nazwie counter oraz funkcję incrementCounter() wywoływaną za pomocą dyrektywy ng-click zastosowanej w elemencie w pliku HTML. Uruchomioną aplikację pokazano na rysunku 25.2.
Rysunek 25.2. Przykładowa aplikacja
Praca z Karma i Jasmine Aby upewnić się o działaniu konfiguracji testowej, utworzymy prosty test jednostkowy, który w ogóle nie będzie używał AngularJS. W ten sposób przekonamy się o działaniu Karma i Jasmine zgodnie z oczekiwaniami, zanim jeszcze przejdziemy do wykorzystania możliwości oferowanych przez moduł ngMock. Pliki testowe można umieścić gdziekolwiek w projekcie, o ile w pliku konfiguracyjnym Karma podasz do nich ścieżkę dostępu. Osobiście pliki testów umieszczam w katalogu o nazwie tests, aby nie powodować bałaganu w plikach aplikacji. Takie podejście będzie zastosowane także w tym rozdziale. Pamiętaj, że nie jest to sztywno określona zasada i możesz stosować takie rozwiązanie, które jest dla Ciebie wygodne i ma sens. Testy Jasmine są definiowane za pomocą języka JavaScript. Na początek w katalogu angularjs tworzymy podkatalog tests i umieszczamy w nim plik firstTest.js, którego zawartość przedstawiono na listingu 25.3. Listing 25.3. Zawartość pliku firstTest.js describe("Pierwszy test", function () { // Przygotowanie scenariusza var counter; beforeEach(function () { counter = 0; }); it("inkrementacja wartości", function () { // Próba przeprowadzenia operacji
597
AngularJS. Profesjonalne techniki counter++; // Asercja (weryfikacja wyniku) expect(counter).toEqual(1); }) it("dekrementacja wartości", function () { // Próba przeprowadzenia operacji counter--; // Asercja (weryfikacja wyniku) expect(counter).toEqual(0); }) });
Wskazówka Spostrzegawczy Czytelnik dostrzeże celowo utworzony problem w przedstawionym teście jednostkowym, co pozwoli zobaczyć, jak oprogramowanie Karma przeprowadza testy Jasmine. Wspomniany problem został usunięty na listingu 25.4.
Podczas tworzenia testów jednostkowych kieruję się zasadą nazywaną przygotowanie/działanie/asercja. Etap przygotowanie odnosi się do kroku konfiguracji scenariusza dla testu. Etap działanie to przeprowadzenie samego testu, natomiast asercja oznacza sprawdzenie wyniku i upewnienie się o jego poprawności. Testy Jasmine są tworzone za pomocą funkcji JavaScript i dzięki temu opracowywanie testów można po prostu uznać za rozbudowę kodu aplikacji. W omawianym przykładzie użyto pięciu funkcji Jasmine wymienionych w tabeli 25.3. Tabela 25.3. Funkcje Jasmine wykorzystane w pliku firstTest.js Nazwa
Opis
describe()
Grupuje powiązane ze sobą testy (ta funkcja jest opcjonalna, ale ułatwia organizację kodu testu).
beforeEach()
Wykonuje funkcję przed każdym testem (ta funkcja jest często używana na etapie przygotowań do przeprowadzenia testu).
it()
Wykonuje funkcję test (ta funkcja jest używana na etapie działania).
expect()
Określa wynik testu (ta funkcja jest używana na etapie asercji).
toEqual()
Porównuje wynik testu z wartością oczekiwaną (to jest druga część etapu asercji).
Nie przejmuj się, jeśli dopiero poznajesz testy jednostkowe i nazwy wymienionych funkcji wydają Ci się niezrozumiałe. Wszystko powinno stać się jasne po analizie kilku przykładów. Należy zwrócić uwagę na ogólną sekwencję: funkcja it() wykonuje test, a więc funkcje expect() i equalTo() mogą być użyte do sprawdzenia wyniku. Funkcja toEqual() stanowi jedyny sposób, w jaki Jasmine może sprawdzić wynik testu. Inne dostępne funkcje wymieniono w tabeli 25.4. Tabela 25.4. Funkcje Jasmine odpowiedzialne za obliczanie wyników testu Nazwa
Opis
expect(x).toEqual(wartość)
Asercja, że x ma taką samą wartość jak wartość (ale to niekoniecznie będzie ten sam obiekt).
expect(x).toBe(obiekt)
Asercja, że x i obiekt to ten sam obiekt.
expect(x).toMatch(regexp)
Asercja, że x dopasowano do podanego wyrażenia regularnego.
expect(x).toBeDefined()
Asercja, że x jest zdefiniowane.
598
Rozdział 25. Testy jednostkowe
Tabela 25.4. Funkcje Jasmine odpowiedzialne za obliczanie wyników testu — ciąg dalszy Nazwa
Opis
expect(x).toBeUndefined()
Asercja, że x nie jest zdefiniowane.
expect(x).toBeNull()
Asercja, że x ma wartość null.
expect(x).toBeTruthy()
Asercja, że x ma wartość true lub będzie miało wartość true.
expect(x).toBeFalsy()
Asercja, że x ma wartość false lub będzie miało wartość false.
expect(x).toContain(y)
Asercja, że x to ciąg tekstowy i zawiera y.
expect(x).toBeGreaterThan(y)
Asercja, że x jest większe niż y.
Wskazówka Istnieje możliwość użycia not jako inwersji dla wymienionych metod. Na przykład expect(x).not. toEqual(wartość) oznacza asercję, że x nie ma takiej samej wartości jak wartość.
Przeprowadzanie testów Przygotowana we wcześniejszej części rozdziału konfiguracja oprogramowania Karma pozwala na monitorowanie plików JavaScript w katalogach angularjs i angularjs/tests w celu uruchomienia wszystkich testów po wykryciu zmiany w plikach. Aby uruchomić oprogramowanie Karma, przejdź do katalogu angularjs, a następnie z poziomu wiersza poleceń wydaj następujące polecenie: karma start karma.config.js
Karma wczyta plik konfiguracyjny, a następnie uruchomi egzemplarz przeglądarki internetowej Chrome. Ponadto uruchomione zostaną wszystkie znalezione testy Jasmine, co oznacza wygenerowanie danych wyjściowych podobnych do przedstawionych poniżej: C:\angularjs>karma start karma.config.js INFO [karma]: Karma v0.10.6 server started at http://localhost:9876/ INFO [launcher]: Starting browser Chrome INFO [Chrome 31.0.1650 (Windows)]: Connected on socket G7kAD8HkusX5AF4ZDQtb Chrome 31.0.1650 (Windows) Pierwszy test inkrementacja wartości FAILED Expected -1 to equal 0. Error: Expected -1 to equal 0. at null. (C:/angularjs/tests/firstTest.js:21:25) Chrome 31.0.1650 (Windows): Executed 2 of 2 (1 FAILED) (0.141 secs / 0.015 secs)
Wprawdzie otwarte jest okno przeglądarki internetowej, ale dane wyjściowe testów są wyświetlane w konsoli. Karma używa kolorowania kodu, aby jasno wskazać problem. Powinieneś więc zwrócić uwagę na tekst wyświetlony w konsoli kolorem czerwonym; wskazuje on źródło problemu.
Odkrycie problemu w teście Plik firstTest.js zawiera dwa testy jednostkowe. Pierwszy przeprowadza inkrementację licznika: ... it("inkrementacja wartości", function () { // Próba przeprowadzenia operacji counter++; // Asercja (weryfikacja wyniku) expect(counter).toEqual(1); }) ...
599
AngularJS. Profesjonalne techniki
Ten test nosi nazwę inkrementacja wartości (podana jako pierwszy argument funkcji it()) i używa operatora ++ do zwiększenia wartości zmiennej counter. Następnie funkcje expect() i toEqual() są używane do sprawdzenia, czy wartość wynosi 1. Drugi test jednostkowy przeprowadza dekrementację wartości: ... it("dekrementacja wartości", function () { // Próba przeprowadzenia operacji counter--; // Asercja (weryfikacja wyniku) expect(counter).toEqual(0); }) ...
Nazwa drugiego testu to dekrementacja wartości. Operator -- został użyty w celu zmniejszenia wartości zmiennej counter, a funkcje expect() i toEqual() sprawdzają, czy wynik wynosi 0. Problem — dość często spotykany — polega na użyciu funkcji beforeEach() do ustawienia wartości zmiennej counter, jak przedstawiono poniżej: ... beforeEach(function () { counter = 0; }); ...
Funkcja przekazana beforeEach() jest wykonywana przed każdym testem. Dlatego też wartość nie będzie przeniesiona z pierwszego testu do drugiego. Zamiast tego wartość zostanie wyzerowana przed przeprowadzeniem drugiego testu. To jest odzwierciedlone w danych wyjściowych Karma: ... Chrome 31.0.1650 (Windows) Pierwszy test dekrementacja wartości FAILED Expected -1 to equal 0. Error: Expected -1 to equal 0. ...
Dane wyjściowe zawierają nazwę testu, wartość oczekiwaną i otrzymaną. Dzięki temu możesz sprawdzić, który test lub które testy zakończyły się niepowodzeniem.
Usunięcie problemu Aby usunąć problem w teście, należy poprawić założenie dotyczące wartości początkowej zmiennej counter, jak przedstawiono na listingu 25.4. Listing 25.4. Rozwiązywanie problemu w pliku firstTest.js ... it("dekrementacja wartości", function () { // Próba przeprowadzenia operacji counter--; // Asercja (weryfikacja wyniku) expect(counter).toEqual(-1); }) ...
Po zapisaniu pliku oprogramowanie Karma automatycznie wykona wszystkie testy i wygeneruje następujące dane wyjściowe: Chrome 31.0.1650 (Windows): Executed 2 of 2 SUCCESS (11.999 secs / 7.969 secs)
600
Rozdział 25. Testy jednostkowe
Teraz już wiesz, jak można tworzyć proste testy Jasmine i uruchamiać je za pomocą oprogramowania Karma. Przechodzimy więc do oferowanych przez AngularJS możliwości w zakresie testowania komponentów aplikacji.
Poznajemy atrapę obiektu Stosowanie atrap (ang. mocking) to proces tworzenia obiektów zastępujących kluczowe komponenty aplikacji, aby umożliwić efektywne przeprowadzenie testów jednostkowych. Wyobraź sobie zadanie przetestowania funkcji kontrolera wykonującej żądania Ajax za pomocą usługi $http. Działanie funkcji zależy od wielu innych komponentów i systemów — modułu AngularJS zawierającego dany kontroler, usługi $http, serwera przetwarzającego żądanie, bazy danych zawierającej wskazane informacje itd. Kiedy test zakończy się niepowodzeniem, to tak naprawdę nie wiadomo, co jest źródłem problemu: testowana funkcja kontrolera czy inny komponent, na przykład awaria serwera lub brak możliwości połączenia z bazą danych. Komponenty wykorzystywane przez testowany komponent są zastępowane atrapami obiektów, które implementują API wymagane przez komponentu, ale generują podstawione, przewidywalne dane. Funkcje atrap obiektów można modyfikować, tworząc tym samym różne scenariusze do przetestowania kodu. Dzięki temu można bardzo łatwo przeprowadzić szeroką gamę testów bez konieczności nieustannej zmiany konfiguracji serwerów, baz danych, sieci itd.
API i obiekty testowe W tym punkcie zostaną wymienione atrapy obiektów oraz oferowane przez AngularJS pewne funkcje dodatkowe, które ułatwiają testowanie aplikacji. Wykorzystamy je w pozostałej części rozdziału, co pozwoli na dokładne wyjaśnienie sposobów ich użycia w celu przygotowania konkretnych i efektywnych testów jednostkowych. Moduł ngMock zawiera wiele atrap obiektów używanych do zastępowania komponentów AngularJS. W tabeli 25.5 wymieniono dostępne atrapy obiektów. Tabela 25.5. Atrapy obiektów oferowane przez moduł ngMock Nazwa
Opis
angular.mock
Używany do tworzenia modułów atrap i rozwiązywania zależności.
$exceptionHandler
Implementacja atrapy usługi $exceptionHandler, która ponownie zgłasza otrzymane wyjątki.
$interval
Implementacja atrapy usługi $interval pozwalającej na przejście do przodu w celu wywołania funkcji przeznaczonej do uruchomienia w przyszłości. Więcej informacji znajdziesz w punkcie „Symulacja czasu”.
$log
Implementacja atrapy usługi $log przekazującej otrzymane komunikaty za pomocą zbioru właściwości, po jednej dla każdej metody definiowanej przez rzeczywistą usługę. Więcej informacji znajdziesz w punkcie „Testowanie rejestracji danych”.
$timeout
Implementacja atrapy usługi $timeout pozwalającej na programowe wygaszenie ważności licznika czasu, aby tym samym wywołać powiązaną z nim funkcję. Więcej informacji znajdziesz w punkcie „Symulacja czasu”.
Wprawdzie dostępne atrapy obiektów są w większości dość proste, ale zapewniają solidne podstawy tworzenia testów jednostkowych. W kolejnych punktach zobaczysz, jak można wykorzystać atrapy obiektów do przetestowania różnego rodzaju komponentów AngularJS. Obiekt angular.mock oferuje metody odpowiedzialne za wczytywanie modułów oraz rozwiązywanie zależności w testach jednostkowych. Dostępne metody obiektu angular.mock wymieniono w tabeli 25.6.
601
AngularJS. Profesjonalne techniki
Tabela 25.6. Metody zdefiniowane przez obiekt angular.mock Nazwa
Opis
module(nazwa)
Wczytuje określony moduł. Więcej informacji znajdziesz w punkcie „Przygotowanie testu”.
inject(funkcja)
Rozwiązuje zależności i wstrzykuje je do funkcji. Więcej informacji znajdziesz w punkcie „Rozwiązywanie zależności”.
dump(obiekt)
Serializuje obiekt AngularJS (na przykład obiekt usługi).
Poza modułem ngMock AngularJS oferuje także pewne metody i usługi użyteczne podczas przeprowadzania testów jednostkowych. Wymieniono je w tabeli 25.7. Tabela 25.7. Dodatkowe metody i usługi używane podczas testów jednostkowych Nazwa
Opis
$rootScope.new()
Tworzy nowy zakres.
$controller(nazwa)
Tworzy egzemplarz wskazanego kontrolera.
$filter(nazwa)
Tworzy egzemplarz wskazanego filtru.
Testowanie kontrolera Na początek zobaczysz, jak można przetestować kontroler. To jest dość proste zadanie i pozwoli nam na wprowadzenie pewnych podstawowych funkcji oferowanych przez atrapy obiektów w AngularJS. W katalogu angularjs/tests tworzymy plik controllerTest.js wraz z zawartością przedstawioną na listingu 25.5. Listing 25.5. Zawartość pliku controllerTest.js describe("Test kontrolera", function () { // Przygotowanie var mockScope = {}; var controller; beforeEach(angular.mock.module("exampleApp")); beforeEach(angular.mock.inject(function ($controller, $rootScope) { mockScope = $rootScope.$new(); controller = $controller("defaultCtrl", { $scope: mockScope }); })); // Działanie i asercje it("utworzenie zmiennej", function () { expect(mockScope.counter).toEqual(0); }) it("inkrementacja licznika", function () { mockScope.incrementCounter(); expect(mockScope.counter).toEqual(1); }); });
602
Rozdział 25. Testy jednostkowe
Ponieważ to jest nasz pierwszy test funkcjonalności AngularJS, w kolejnych punktach dokładnie omówimy poszczególne kroki.
Co tak naprawdę zostanie przetestowane? Pamiętaj, że kontrolery dostarczają dane i funkcje widokom za pomocą zakresu, a cała konfiguracja jest przeprowadzana w funkcji fabryki kontrolera. Dlatego też utworzenie kontrolera odbywa się na etapie przygotowań do testu, podczas gdy etapy działania i asercji są wykonywane w zakresie kontrolera.
Przygotowanie testu Do przeprowadzenia testu potrzebujemy dwóch elementów — egzemplarza kontrolera oraz zakresu do przekazania funkcji fabryki. Konieczne jest więc poczynienie odpowiednich przygotowań. Przede wszystkim wczytujemy moduł zawierający kontroler, co odbywa się następująco: ... beforeEach(angular.mock.module("exampleApp")); ...
Standardowo wczytywany jest jedynie domyślny moduł AngularJS. To oznacza konieczność wywołania metody module() dla modułów wymaganych w trakcie testu, w tym również opcjonalnych modułów AngularJS, takich jak ngResource i ngAnimate omówionych w rozdziałach, odpowiednio, 21. i 23. W omawianym przykładzie testujemy jedynie kontroler zdefiniowany w module exampleApp, a więc to jest jedyny wczytywany moduł. Wskazówka Nie trzeba używać wywołania w pełnej postaci angular.mock.module(). Metody definiowane przez obiekt angular.mock są dostępne globalnie, co oznacza możliwość zastąpienia wywołania angular.mock.module("exampleApp") po prostu wywołaniem module("exampleApp"). Osobiście preferuję dłuższą formę, ponieważ wyraźnie wskazuje ona źródło wywoływanych metod.
Rozwiązywanie zależności Jak widziałeś we wcześniejszych rozdziałach, wstrzykiwanie zależności to bardzo ważny aspekt działania AngularJS. W celu prawidłowego funkcjonowania testy jednostkowe muszą więc mieć możliwość rozwiązywania zależności. Metoda angular.mock.inject() rozwiązuje zależności przekazywanej jej funkcji i zapewnia dostęp do usług niezbędnych podczas testu: ... beforeEach(angular.mock.inject(function ($controller, $rootScope) { mockScope = $rootScope.$new(); controller = $controller("defaultCtrl", { $scope: mockScope }); })); ... Funkcja przekazywana metodzie inject() deklaruje zależność od usług $controller i $rootScope. Ogólnie rzecz biorąc, metoda inject() jest używana w celu przygotowania testu jednostkowego, a przekazywana funkcja konfiguruje zmienne testu, które później będą używane w wywołaniach it() Jasmine.
Zadaniem przedstawionej wcześniej funkcji jest utworzenie nowego zakresu i przekazanie go egzemplarzowi kontrolera w przykładowej aplikacji, aby można było zdefiniować jego dane i funkcje. Usługa $rootScope definiuje metodę $new(), która tworzy nowy zakres, a usługa $controller jest funkcją używaną do tworzenia obiektów kontrolera. Argumentami funkcji usługi $controller są nazwa kontrolera (w omawianym przykładzie
603
AngularJS. Profesjonalne techniki
to defaultCtrl) i obiekt, którego właściwości będą wykorzystane do rozwiązania zależności zadeklarowanych przez funkcję fabryki kontrolera. Ten prosty kontroler wymaga tylko zakresu dla funkcji fabryki, ale bardziej rozbudowane mogą wymagać innych usług — pobierzesz je za pomocą metody inject(). Zanim zakończy się wykonywanie funkcji przekazanej metodzie incject(), kontroler zostanie utworzony, a jego funkcja fabryki będzie operowała na przygotowanym zakresie. Obiekt zakresu przypisaliśmy zmiennej o nazwie mockScope, którą następnie będziemy wykorzystywać na etapach działania i asercji testu.
Przeprowadzanie i sprawdzanie testów Ważnym krokiem omawianego testu jest konfiguracja utworzenia zakresu i kontrolera. Test sam w sobie jest całkiem prosty — sprawdzamy, czy obiekt zakresu ma właściwość o nazwie counter oraz czy wywołanie funkcji incrementCounter() prawidłowo zmienia wartość: ... it("utworzenie zmiennej", function () { expect(mockScope.counter).toEqual(0); }) it("inkrementacja licznika", function () { mockScope.incrementCounter(); expect(mockScope.counter).toEqual(1); }); ...
Po zapisaniu pliku controllerTest.js oprogramowanie Karma uruchomi testy i wygeneruje dane wyjściowe podobne do poniższych: Chrome 31.0.1650 (Windows): Executed 4 of 4 SUCCESS (25 secs / 17.928 secs)
Karma zgłasza wykonanie czterech testów, ponieważ nadal widzi plik firstTest.js i przeprowadza zdefiniowane w nim testy. Jeżeli chcesz otrzymać informacje jedynie o wykonaniu testów AngularJS, możesz usunąć wymieniony plik; nie będziemy go już używać w tym rozdziale. Wskazówka Jeżeli pojawi się błąd informujący o niepowodzeniu wykonania pewnych testów, prawdopodobnie zignorowałeś wcześniejsze ostrzeżenie o konieczności usunięcia z katalogu angularjs przykładów utworzonych w poprzednich rozdziałach.
Użycie atrap obiektów Po poznaniu sposobu przetestowania prostego kontrolera możemy przejść do innych atrap obiektów wymienionych w tabeli 25.5.
Symulacja odpowiedzi HTTP Usługa $httpBackend oferuje działające na niskim poziomie API używane przez usługę $http do wykonywania żądań Ajax (i przez usługę $resource opartą na $http). Udostępniana przez moduł ngMock atrapa usługi $httpBackend niezwykle ułatwia symulowanie odpowiedzi z serwera, co pozwala na odizolowanie fragmentu kodu od kaprysów rzeczywistych serwerów i sieci. Na listingu 25.6 przedstawiono uaktualnioną wersję pliku app.js; znajdujący się w nim kontroler wykonuje żądania Ajax.
604
Rozdział 25. Testy jednostkowe
Listing 25.6. Dodanie żądania Ajax do pliku app.js angular.module("exampleApp", []) .controller("defaultCtrl", function ($scope, $http) { $http.get("productData.json").success(function (data) { $scope.products = data; }); $scope.counter = 0; $scope.incrementCounter = function() { $scope.counter++; } });
Kontroler wykonuje żądanie do adresu URL wskazującego plik productData.js, używa funkcji success() w celu otrzymania odpowiedzi, a dane przypisuje zmiennej zakresu o nazwie products. Aby przetestować nową funkcję, rozbudowujemy plik tests/controllerTest.js, jak przedstawiono na listingu 25.7. Listing 25.7. Rozbudowa testu w pliku controllerTest.js describe("Test kontrolera", function () { // Przygotowanie var mockScope, controller, backend; beforeEach(angular.mock.module("exampleApp")); beforeEach(angular.mock.inject(function ($httpBackend) { backend = $httpBackend; backend.expect("GET", "productData.json").respond( [{ "name": "Jabłka", "category": "Owoce", "price": 1.20 }, { "name": "Banany", "category": "Owoce", "price": 2.42 }, { "name": "Brzoskwinie", "category": "Owoce", "price": 2.02 }]); })); beforeEach(angular.mock.inject(function ($controller, $rootScope, $http) { mockScope = $rootScope.$new(); $controller("defaultCtrl", { $scope: mockScope, $http: $http }); backend.flush(); })); // Działanie i asercje it("utworzenie zmiennej", function () { expect(mockScope.counter).toEqual(0); }) it("inkrementacja licznika", function () { mockScope.incrementCounter(); expect(mockScope.counter).toEqual(1); }); it("wykonanie żądania Ajax", function () { backend.verifyNoOutstandingExpectation(); });
605
AngularJS. Profesjonalne techniki it("przetworzenie danych", function () { expect(mockScope.products).toBeDefined(); expect(mockScope.products.length).toEqual(3); }); it("zachowanie kolejności danych", function () { expect(mockScope.products[0].name).toEqual("Jabłka"); expect(mockScope.products[1].name).toEqual("Banany"); expect(mockScope.products[2].name).toEqual("Brzoskwinie"); }); });
Atrapa usługi $httpBackend dostarcza API dopasowujące żądania wykonywane za pomocą usługi $http do spreparowanych wyników i kontroluje udzielaną odpowiedź. Metody definiowane przez atrapę usługi $httpBackend wymieniono w tabeli 25.8. Tabela 25.8. Metody zdefiniowane przez usługę $httpBackend Nazwa
Opis
expect(metoda, url, dane, nagłówki)
Definiuje oczekiwania dla żądania, które dopasowuje metodę i adres URL (opcjonalnie dopasowywane są dane i nagłówki).
flush() flush(licznik)
Odsyła oczekujące wyniki (opcjonalnie podaną liczbę odpowiedzi, jeśli podano wartość argumentu).
resetExpectations()
Zeruje zbiór oczekiwań.
verifyNoOutstandingExpectation()
Sprawdza, czy otrzymane zostały wszystkie oczekiwane żądania.
respond(dane)
Definiuje odpowiedź dla oczekiwanego żądania.
response(stan, dane, nagłówki)
Wskazówka Metoda respond() znalazła się w tabeli w celu dostarczenia pełnych informacji o dostępnych metodach, ale w rzeczywistości jest stosowana w wyniku metody expect().
Proces użycia atrapy usługi $httpBackend jest względnie prosty i składa się z wymienionych poniżej kroków: 1. Zdefiniowanie oczekiwanych żądań i odpowiedzi. 2. Udzielenie odpowiedzi. 3. Sprawdzenie, czy zostały wykonane wszystkie oczekiwane żądania. 4. Sprawdzenie wyników. Wymienione kroki zostaną omówione w kolejnych punktach.
Zdefiniowanie oczekiwanych żądań i odpowiedzi Metoda expect() jest używana w celu zdefiniowania żądania, które jak sądzisz, będzie wykonywane przez testowany komponent. Wymagane argumenty to metoda HTTP i adres URL, choć można podać także dane i nagłówki, co pozwoli na zawężenie dopasowywanego żądania: ... beforeEach(angular.mock.inject(function ($httpBackend) { backend = $httpBackend; backend.expect("GET", "productData.json").respond( [{ "name": "Jabłka", "category": "Owoce", "price": 1.20},
606
Rozdział 25. Testy jednostkowe { "name": "Banany", "category": "Owoce", "price": 2.42}, { "name": "Brzoskwinie", "category": "Owoce", "price": 2.02}]); })); ...
W przykładowym teście jednostkowym metodę inject() wykorzystaliśmy do pobrania usługi $httpBackend w celu wywołania metody expect(). Nie są wymagane żadne specjalne kroki do uzyskania atrapy obiektu, ponieważ zawartość modułu ngMock nadpisuje domyślną implementację usługi. Wskazówka Warto w tym miejscu podkreślić, że metoda expect() zdefiniowana przez atrapę usługi $httpBackend nie ma żadnego związku z metodą używaną przez Jasmine do sprawdzenia wyniku testu.
Usłudze $httpBackend wskazujemy wykonanie żądania HTTP GET na adres URL prowadzący do pliku productData.json, dopasowując tym samym żądanie wykonywane przez kontroler zdefiniowany w pliku app.js. Wynikiem wywołania metody expect() jest obiekt, w którym będzie wywołana metoda respond(). Wykorzystujemy podstawową postać tej metody, co oznacza pobranie jednego argumentu dla danych, które będą zwrócone w celu symulacji udzielenia odpowiedzi przez serwer. Wykorzystaliśmy tutaj dane pewnych produktów z wcześniejszej części książki. Zwróć uwagę na brak konieczności kodowania danych jako JSON, ponieważ ta operacja odbywa się automatycznie.
Udzielenie odpowiedzi Aby odzwierciedlić asynchroniczną naturę żądań Ajax, atrapa usługi $httpBackend nie udziela spreparowanej odpowiedzi aż do chwili wywołania metody flush(). Dzięki temu można przetestować efekt długiej zwłoki w udzieleniu odpowiedzi lub wystąpienie przekroczenia czasu oczekiwania. Jednak w omawianym teście chcemy otrzymać odpowiedź natychmiast, stąd wywołanie metody flush() tuż po zakończeniu działania funkcji fabryki kontrolera: ... beforeEach(angular.mock.inject(function ($controller, $rootScope, $http) { mockScope = $rootScope.$new(); $controller("defaultCtrl", { $scope: mockScope, $http: $http }); backend.flush(); })); ...
Wywołanie metody flush() oznacza spełnienie obietnicy złożonej przez usługę $http oraz wykonanie funkcji success() zdefiniowanej w kontrolerze. Zwróć uwagę na użycie metody inject() w celu pobrania usługi $http, aby za pomocą usługi $controller można było ją przekazać funkcji fabryki.
Sprawdzenie, czy zostały wykonane wszystkie oczekiwane żądania Usługa $httpBackend oczekuje otrzymania jednego żądania HTTP dla każdego użycia metody expect(), co znacznie ułatwia sprawdzenie, czy testowany kod wykonał wszystkie oczekiwane żądania. Wprawdzie w kodzie wykonujemy tylko jedno żądanie, ale mimo tego nadal sprawdzamy, czy zostały wykonane wszystkie oczekiwane żądania. Odbywa się to przez wywołanie metody verifyNoOutstandingExpectation() w funkcji it() Jasmine: ... it("wykonanie żądania Ajax", function () { backend.verifyNoOutstandingExpectation(); }); ...
607
AngularJS. Profesjonalne techniki
Metoda verifyNoOutstandingExpectation() zgłosi wyjątek, jeżeli nie zostały wykonane wszystkie oczekiwane żądania. Dlatego też nie ma konieczności użycia metody expect() Jasmine.
Sprawdzenie wyników Ostatnim krokiem jest sprawdzenie wyniku testów. Ponieważ testujemy kontroler, to testy są przeprowadzane w zakresie tworzonego obiektu: ... it("przetworzenie danych", function () { expect(mockScope.products).toBeDefined(); expect(mockScope.products.length).toEqual(3); }); it("zachowanie kolejności danych", function () { expect(mockScope.products[0].name).toEqual("Jabłka"); expect(mockScope.products[1].name).toEqual("Banany"); expect(mockScope.products[2].name).toEqual("Brzoskwinie"); }); ...
To są bardzo proste testy mające na celu sprawdzenie, czy kontroler nie zmienia ułożenia danych. W rzeczywistych projektach nacisk podczas testowania HTTP jest kładziony na żądania, a nie obsługę danych.
Symulacja czasu Atrapy usług $interval i $timeout definiują metody dodatkowe pozwalające na wyraźne wywoływanie funkcji wywołań zwrotnych zarejestrowane przez testowany kod. Na listingu 25.8 przedstawiono użycie rzeczywistych usług w pliku app.js. Listing 25.8. Zdefiniowanie czasu i odstępów czasu w pliku app.js angular.module("exampleApp", []) .controller("defaultCtrl", function ($scope, $http, $interval, $timeout) { $scope.intervalCounter = 0; $scope.timerCounter = 0; $interval(function () { $scope.intervalCounter++; }, 5000, 10); $timeout(function () { $scope.timerCounter++; }, 5000); $http.get("productData.json").success(function (data) { $scope.products = data; }); $scope.counter = 0; $scope.incrementCounter = function() { $scope.counter++; } });
608
Rozdział 25. Testy jednostkowe
W kodzie zdefiniowaliśmy dwie zmienne intervalCounter i timerCounter, których wartości są inkrementowane przez funkcje przekazane usługom $interval i $timeout. Wspomniane funkcje są wywoływane po pięciosekundowym opóźnieniu, co nie jest idealnym rozwiązaniem w testach jednostkowych, gdy idea polega na częstym i szybkim ich przeprowadzaniu. W tabeli 25.9 przedstawiono metody dodatkowe definiowane przez atrapy wymienionych usług. Tabela 25.9. Dodatkowe metody zdefiniowane przez atrapy usług $timeout i $interval Usługa
Metoda
Opis
$timeout
flush(milisekundy)
Przejście do przodu o podaną liczbę milisekund.
$timeout
verifyNoPendingTasks()
Sprawdza, czy są jeszcze jakiekolwiek wywołania zwrotne do wykonania.
$interval
flush(milisekundy)
Przejście do przodu o podaną liczbę milisekund.
Metoda flush() może być użyta w celu przejścia do przodu. Na listingu 25.9 przedstawiono zawartość pliku tests/controllerTest.js rozbudowanego o omówioną funkcję. Listing 25.9. Dodawanie testów do pliku controllerTest.js describe("Test kontrolera", function () { // Przygotowanie var mockScope, controller, backend, mockInterval, mockTimeout; beforeEach(angular.mock.module("exampleApp")); beforeEach(angular.mock.inject(function ($httpBackend) { backend = $httpBackend; backend.expect("GET", "productData.json").respond( [{ "name": "Jabłka", "category": "Owoce", "price": 1.20 }, { "name": "Banany", "category": "Owoce", "price": 2.42 }, { "name": "Brzoskwinie", "category": "Owoce", "price": 2.02 }]); })); beforeEach(angular.mock.inject(function ($controller, $rootScope, $http, $interval, $timeout) { mockScope = $rootScope.$new(); mockInterval = $interval; mockTimeout = $timeout; $controller("defaultCtrl", { $scope: mockScope, $http: $http, $interval: mockInterval, $timeout: mockTimeout }); backend.flush(); })); // Działanie i asercje it("utworzenie zmiennej", function () { expect(mockScope.counter).toEqual(0); }) it("inkrementacja licznika", function () { mockScope.incrementCounter(); expect(mockScope.counter).toEqual(1); });
609
AngularJS. Profesjonalne techniki it("wykonanie żądania Ajax", function () { backend.verifyNoOutstandingExpectation(); }); it("przetworzenie danych", function () { expect(mockScope.products).toBeDefined(); expect(mockScope.products.length).toEqual(3); }); it("zachowanie kolejności danych", function () { expect(mockScope.products[0].name).toEqual("Jabłka"); expect(mockScope.products[1].name).toEqual("Banany"); expect(mockScope.products[2].name).toEqual("Brzoskwinie"); }); it("ograniczenie liczby operacji do 10", function () { for (var i = 0; i < 11; i++) { mockInterval.flush(5000); } expect(mockScope.intervalCounter).toEqual(10); }); it("inkrementacja licznika zegara", function () { mockTimeout.flush(5000); expect(mockScope.timerCounter).toEqual(1); }); });
Testowanie rejestracji danych Atrapa usługi $log pozwala na obsługę otrzymywanych komunikatów i wyświetlanie ich za pomocą właściwości logs dodanej do metod rzeczywistej usługi: log.logs, debug.logs, warn.logs itd. Wymienione właściwości pozwalają na przetestowanie prawidłowości działania kodu odpowiedzialnego za rejestrację danych. Na listingu 25.10 przedstawiono zmodyfikowaną wersję pliku app.js uzupełnionego o obsługę usługi $log. Listing 25.10. Dodanie rejestracji danych do pliku app.js angular.module("exampleApp", []) .controller("defaultCtrl", function ($scope, $http, $interval, $timeout, $log) { $scope.intervalCounter = 0; $scope.timerCounter = 0; $interval(function () { $scope.intervalCounter++; }, 5, 10); $timeout(function () { $scope.timerCounter++; }, 5); $http.get("productData.json").success(function (data) { $scope.products = data; $log.log("Mamy " + data.length + " elementów"); }); $scope.counter = 0;
610
Rozdział 25. Testy jednostkowe $scope.incrementCounter = function() { $scope.counter++; } });
Komunikat jest zapisywany w trakcie każdej operacji rejestracji funkcji wywołania zwrotnego za pomocą wywołania usługi $interval. Na listingu 25.11 przedstawiono użycie atrapy usługi $log do sprawdzenia, czy zapisana została prawidłowa liczba komunikatów. Listing 25.11. Użycie atrapy usługi $log w pliku controllerTest.js describe("Test kontrolera", function () { // Przygotowanie var mockScope, controller, backend, mockInterval, mockTimeout, mockLog; beforeEach(angular.mock.module("exampleApp")); beforeEach(angular.mock.inject(function ($httpBackend) { backend = $httpBackend; backend.expect("GET", "productData.json").respond( [{ "name": "Jabłka", "category": "Owoce", "price": 1.20 }, { "name": "Banany", "category": "Owoce", "price": 2.42 }, { "name": "Brzoskwinie", "category": "Owoce", "price": 2.02 }]); })); beforeEach(angular.mock.inject(function ($controller, $rootScope, $http, $interval, $timeout, $log) { mockScope = $rootScope.$new(); mockInterval = $interval; mockTimeout = $timeout; mockLog = $log; $controller("defaultCtrl", { $scope: mockScope, $http: $http, $interval: mockInterval, $timeout: mockTimeout, $log: mockLog }); backend.flush(); })); // Działanie i asercje it("utworzenie zmiennej", function () { expect(mockScope.counter).toEqual(0); }); it("inkrementacja licznika", function () { mockScope.incrementCounter(); expect(mockScope.counter).toEqual(1); }); it("wykonanie żądania Ajax", function () { backend.verifyNoOutstandingExpectation(); }); it("przetworzenie danych", function () { expect(mockScope.products).toBeDefined(); expect(mockScope.products.length).toEqual(3);
611
AngularJS. Profesjonalne techniki }); it("zachowanie kolejności danych", function () { expect(mockScope.products[0].name).toEqual("Jabłka"); expect(mockScope.products[1].name).toEqual("Banany"); expect(mockScope.products[2].name).toEqual("Brzoskwinie"); }); it("ograniczenie liczby operacji do 10", function () { for (var i = 0; i < 11; i++) { mockInterval.flush(5000); } expect(mockScope.intervalCounter).toEqual(10); }); it("inkrementacja licznika zegara", function () { mockTimeout.flush(5000); expect(mockScope.timerCounter).toEqual(1); }); it("zapis komunikatów", function () { expect(mockLog.log.logs.length).toEqual(1); }); });
Funkcja fabryki kontrolera przekazuje komunikaty metodzie $log.log() po otrzymaniu odpowiedzi na żądanie Ajax. W teście jednostkowym odczytywana jest wielkość tablicy $log.log.logs, w której są przechowywane komunikaty zapisywane przez metodę $log.log(). Oprócz właściwości logs atrapa usługi $log definiuje metody wymienione w tabeli 25.10. Tabela 25.10. Metody zdefiniowane przez atrapę usługi $log Nazwa
Opis
assertEmpty()
Zgłasza wyjątek, jeśli został zapisany jakikolwiek komunikat procesu rejestracji danych.
reset()
Usunięcie zachowanych komunikatów.
Testowanie innych komponentów Wszystkie przedstawione dotąd testy jednostkowe były przeznaczone dla kontrolera, ale we wcześniejszych rozdziałach poznałeś wiele innych typów komponentów stosowanych w aplikacji AngularJS. W tym podrozdziale zobaczysz, jak przygotować prosty test jednostkowy dla poszczególnych komponentów.
Testowanie filtru Dostęp do egzemplarzy filtru można uzyskać za pomocą usługi $filter omówionej w rozdziale 14. Na listingu 25.12 przedstawiono dodanie obsługi filtru w pliku app.js. Listing 25.12. Dodanie filtru do pliku app.js angular.module("exampleApp", []) .controller("defaultCtrl", function ($scope, $http, $interval, $timeout, $log) { $scope.intervalCounter = 0; $scope.timerCounter = 0;
612
Rozdział 25. Testy jednostkowe $interval(function () { $scope.intervalCounter++; }, 5, 10); $timeout(function () { $scope.timerCounter++; }, 5); $http.get("productData.json").success(function (data) { $scope.products = data; $log.log("Mamy " + data.length + " elementów"); }); $scope.counter = 0; $scope.incrementCounter = function() { $scope.counter++; } }) .filter("labelCase", function () { return function (value, reverse) { if (angular.isString(value)) { var intermediate = reverse ? value.toUpperCase() : value.toLowerCase(); return (reverse ? intermediate[0].toLowerCase() : intermediate[0].toUpperCase()) + intermediate.substr(1); } else { return value; } }; });
To jest własny filtr, który utworzyliśmy w rozdziale 14. Na listingu 25.13 przedstawiono zawartość pliku tests/filterTest.js utworzonego w celu przetestowania filtru. Listing 25.13. Zawartość pliku filterTest.js describe("Test filtru", function () { var filterInstance; beforeEach(angular.mock.module("exampleApp")); beforeEach(angular.mock.inject(function ($filter) { filterInstance = $filter("labelCase"); })); it("zmiana wielkości liter", function () { var result = filterInstance("testowane wyrażenie"); expect(result).toEqual("Testowane wyrażenie"); }); it("odwrócenie zmiany", function () { var result = filterInstance("testowane wyrażenie", true); expect(result).toEqual("tESTOWANE WYRAŻENIE"); }); });
613
AngularJS. Profesjonalne techniki
W omawianym przykładzie metodę inject() wykorzystaliśmy do otrzymania egzemplarza usługi $filter użytej do pobrania egzemplarza filtru, który następnie przypisano zmiennej o nazwie filterInstance. Obiekt filtru jest pobierany w funkcji beforeEach(), czyli w każdym teście otrzymujemy nowy egzemplarz filtru.
Testowanie dyrektywy Przetestowanie dyrektywy jest nieco bardziej skomplikowane, co wynika ze sposobu stosowania dyrektyw, a ponadto operacja może zmodyfikować kod HTML. Dlatego też testy jednostkowe dyrektyw opierają się na jqLite i usłudze $compile, które omówiono w rozdziałach, odpowiednio, 15. i 19. Na listingu 25.14 przedstawiono dodanie dyrektywy do pliku app.js. Listing 25.14. Dodawanie dyrektywy do pliku app.js angular.module("exampleApp", []) .controller("defaultCtrl", function ($scope, $http, $interval, $timeout, $log) { $scope.intervalCounter = 0; $scope.timerCounter = 0; $interval(function () { $scope.intervalCounter++; }, 5, 10); $timeout(function () { $scope.timerCounter++; }, 5); $http.get("productData.json").success(function (data) { $scope.products = data; $log.log("There are " + data.length + " items"); }); $scope.counter = 0; $scope.incrementCounter = function () { $scope.counter++; } }) .filter("labelCase", function () { return function (value, reverse) { if (angular.isString(value)) { var intermediate = reverse ? value.toUpperCase() : value.toLowerCase(); return (reverse ? intermediate[0].toLowerCase() : intermediate[0].toUpperCase()) + intermediate.substr(1); } else { return value; } }; }) .directive("unorderedList", function () { return function (scope, element, attrs) { var data = scope[attrs["unorderedList"]]; if (angular.isArray(data)) { var listElem = angular.element(""); element.append(listElem); for (var i = 0; i < data.length; i++) { listElem.append(angular.element('- ').text(data[i].name));
614
Rozdział 25. Testy jednostkowe } } } });
Dyrektywa użyta w przykładzie pochodzi z rozdziału 15. Wykorzystuje tablicę wartości pobranych z zakresu i na ich podstawie generuje nieuporządkowaną listę. Na listingu 25.15 przedstawiono zawartość pliku tests/directiveTest.js służącego do przetestowania dyrektywy. Listing 25.15. Zawartość pliku directiveTest.js describe("Test dyrektywy", function () { var mockScope; var compileService; beforeEach(angular.mock.module("exampleApp")); beforeEach(angular.mock.inject(function($rootScope, $compile) { mockScope = $rootScope.$new(); compileService = $compile; mockScope.data = [ { name: "Jabłka", category: "Owoce", price: 1.20, expiry: 10 }, { name: "Banany", category: "Owoce", price: 2.42, expiry: 7 }, { name: "Brzoskwinie", category: "Owoce", price: 2.02, expiry: 6 }]; })); it("wygenerowanie listy elementów", function () { var compileFn = compileService(""); var elem = compileFn(mockScope); expect(elem.children("ul").length).toEqual(1); expect(elem.find("li").length).toEqual(3); expect(elem.find("li").eq(0).text()).toEqual("Jabłka"); expect(elem.find("li").eq(1).text()).toEqual("Banany"); expect(elem.find("li").eq(2).text()).toEqual("Brzoskwinie"); }); });
W omawianym przykładzie metodę inject() wykorzystaliśmy do pobrania usług $rootScope i $compile. Tworzymy nowy zakres i właściwości data przypisujemy dane, które będą używane przez dyrektywę. Pozostawiamy odniesienie do usługi $compile, aby móc jej użyć w teście. Opierając się na podejściu omówionym w rozdziale 19., kompilujemy fragment kodu HTML, do którego będzie zastosowana dyrektywa, i wskazujemy tablicę data jako źródło danych. W ten sposób otrzymujemy funkcję wywoływaną wraz z atrapą zakresu w celu uzyskania z dyrektywy danych wyjściowych w postaci kodu HTML. Aby zweryfikować wynik, wykorzystujemy jqLite do sprawdzenia struktury i kolejności elementów wygenerowanych przez dyrektywę.
Testowanie usługi Pobranie egzemplarza usługi do przetestowania jest łatwe, ponieważ można wykorzystać metodę inject(). Takie podejście zastosowano we wcześniejszych testach do pobrania usług i ich atrap. Na listingu 25.16 przedstawiono dodanie prostej usługi do pliku app.js.
615
AngularJS. Profesjonalne techniki
Listing 25.16. Dodawanie usługi do pliku app.js angular.module("exampleApp", []) .controller("defaultCtrl", function ($scope, $http, $interval, $timeout, $log) { $scope.intervalCounter = 0; $scope.timerCounter = 0; $interval(function () { $scope.intervalCounter++; }, 5, 10); $timeout(function () { $scope.timerCounter++; }, 5); $http.get("productData.json").success(function (data) { $scope.products = data; $log.log("Mamy " + data.length + " elementów"); }); $scope.counter = 0; $scope.incrementCounter = function () { $scope.counter++; } }) .filter("labelCase", function () { return function (value, reverse) { if (angular.isString(value)) { var intermediate = reverse ? value.toUpperCase() : value.toLowerCase(); return (reverse ? intermediate[0].toLowerCase() : intermediate[0].toUpperCase()) + intermediate.substr(1); } else { return value; } }; }) .directive("unorderedList", function () { return function (scope, element, attrs) { var data = scope[attrs["unorderedList"]]; if (angular.isArray(data)) { var listElem = angular.element(""); element.append(listElem); for (var i = 0; i < data.length; i++) { listElem.append(angular.element('- ').text(data[i].name)); } } } }) .factory("counterService", function () { var counter = 0; return { incrementCounter: function () { counter++; }, getCounter: function() { return counter; } } });
616
Rozdział 25. Testy jednostkowe
Omówioną w rozdziale 18. metodę factory() wykorzystaliśmy w celu zdefiniowania usługi obsługującej licznik oraz zdefiniowania metod, które inkrementują i zwracają wartość licznika. Nie jest to najużyteczniejsza usługa na świecie, ale pozwala zademonstrować proces testowania usługi. Na listingu 25.17 przedstawiono zawartość pliku tests/serviceTest.js. Listing 25.17. Zawartość pliku serviceTest.js describe("Test usługi", function () { beforeEach(angular.mock.module("exampleApp")); it("inkrementacja licznika", function () { angular.mock.inject(function (counterService) { expect(counterService.getCounter()).toEqual(0); counterService.incrementCounter(); expect(counterService.getCounter()).toEqual(1); }); }); });
Dla odmiany funkcję inject() wykorzystaliśmy do pobrania obiektu usługi w funkcji it() Jasmine. Następnie sprawdzamy wartość licznika, inkrementujemy ją i ponownie przeprowadzamy test. Narzędzia oferowane przez AngularJS do przeprowadzania testów jednostkowych są niezwykle silnie zorientowane na tworzenie egzemplarzy usług, co powoduje, że są proste i łatwe do przetestowania.
Podsumowanie W tym rozdziale poznałeś narzędzia, jakie AngularJS oferuje w celu ułatwienia przeprowadzania testów jednostkowych. Omówiono sposób ich użycia, a także zademonstrowano podstawowe podejścia w zakresie testowania najważniejszych komponentów aplikacji AngularJS. I to już wszystko, co chciałem Ci przekazać o AngularJS. Na początku zbudowaliśmy prostą aplikację, a następnie dość dokładnie omówiłem komponenty frameworka. Dowiedziałeś się, jak mogą być skonfigurowane, dostosowane do potrzeb lub całkowicie zastąpione. Życzę Ci wielu sukcesów podczas tworzenia własnych projektów AngularJS. Mam nadzieję, że lektura niniejszej książki dostarczyła Ci przynajmniej tyle radości, ile mnie dostarczyło jej napisanie.
617
AngularJS. Profesjonalne techniki
618
Skorowidz
A adres URL, 475, 478 Ajax, 55, 502 akcje, 548 akcje obiektu dostępu, 545 AngularJS w kontekście, 59 animacje, 215 CSS3, 575 jednoczesne, 579 animowanie elementów, 575 przejść, 577 API, 601 DOM, 471 Fluent, 220 History, 478 RESTful, 203, 531 aplikacja administracyjna, 194 Deployd, 132 SportsStore, 131, 157 aplikacje AngularJS, 211 dwukierunkowe, 60 atrapa usługi $httpBackend, 607 $interval, 608 $log, 611 $timeout, 608 atrapy obiektów, 601, 604 atrybut, 74 highlight, 223 ng-app, 214
ng-controller, 40, 219 ng-repeat, 41 required, 295 atrybuty boolowskie, 279, 281 dla pola wyboru, 308 elementów , 307 niestandardowe, 35 weryfikacji, 294
B biblioteka AngularJS, 26, 33 jqLite, 165 jQuery, 387 błędy Ajax, 159 Bootstrap CSS, 91
C CRUD, 66 CSS, Cascading Style Sheets, 77 cykl życiowy modułu, 232
D dane adresowe, 179 asynchroniczne, 549 JSON, 508 produkcyjne, 157 REST, 545 widoku, 65
Skorowidz
definiowanie adresów URL tras, 557 animacji, 577 dyrektywy, 221, 375 filtru, 223 funkcji JavaScript, 95 funkcji kontrolera, 189 funkcji z parametrami, 95 funkcji zwracającej wartość, 96 komponentów AngularJS, 214 kontrolera, 148, 215 kontrolera RESTful, 203 restrykcyjnych opcji, 403 skomplikowanych dyrektyw, 402 tras, 170, 563 usługi, 226 wartości, 228 widoku, 190, 205 widoku głównego, 198 widoku uwierzytelnienia, 197 dekrementacja wartości, 600 dodawanie atrybutów konfiguracji, 550 atrybutów niestandardowych, 35 biblioteki AngularJS, 33 danych, 134, 529 dyrektywy, 165, 375, 441, 614 dziedziczonych danych, 331 dziedziczonych funkcji, 331 elementów, 389 elementu , 293 elementu , 460, 540 filtru, 224, 612 formularza, 181 funkcji filtrowania, 51 funkcji monitorującej, 383 kontrolera, 199, 217, 219 nawigacji, 170 obsługi tras, 561 obsługiwanego atrybutu, 379 odniesień, 206 pól wyboru, 42, 43 produktu do koszyka, 170 przycisku, 168, 169 rejestracji danych, 610 stronicowania, 152 testów, 609 usługi, 616 widoku, 218 620
zależności do tras, 570 zależności modułu, 577 żądania Ajax, 605 dodatki AngularJS, 26 dokument HTML, 72 dołączanie danych, data binding, 39, 43, 235, 239, 286, 487 dwukierunkowe, 42, 241, 285, 426 jednokierunkowe, 239, 423 osadzone, 241 DOM, Document Object Model, 75, 263 domknięcie, 385 dosłowna tablica, 117 dosłowny obiekt, 102 dostarczanie danych lokalnych, 494, 495 dostawca $httpProvider, 514 dostęp do adresu URL, 475 API RESTful, 203, 204 funkcji AngularJS, 398 kolekcji, 192 obiektów globalnych, 471 obiektu document, 473 obiektu window, 472 parametrów tras, 564 tras, 564 dwukierunkowe dołączanie danych, 42, 241, 285, 426 modelu, 42 dwukropek, 103 dyrektywa, 41, 165, 236, 374 cartSummary, 166 disabled, 281 increment, 541 ng-app, 138 ng-bind-html, 487, 488 ng-class, 249, 270, 276 ng-class-even, 273 ng-class-odd, 273 ng-click, 50, 288, 581 ng-cloak, 260 ng-controller, 318 ng-disabled, 185, 281 ng-hide, 46, 161, 266, 268 ng-href, 281 ng-if, 267, 268 ng-include, 162, 200, 251–256 ng-model, 54, 286–290, 445 ng-repeat, 141, 244–248
Skorowidz
ng-repeat-end, 250 ng-repeat-start, 250 ng-show, 266 ng-src, 281 ng-srcset, 281 ng-style, 270, 272 ng-switch, 256–258 ng-transclude, 433 ng-view, 172, 579 promiseObserver, 525 dyrektywy atrybutu boolowskiego, 280 dołączania danych, 238 elementów, 265 jako atrybut, 405 jako element, 405 jako komentarz, 406 jako wartości atrybutu klasy, 406 obsługujące animacje, 578 skomplikowane, 401 szablonów, 243 zdarzeń, 274, 277 działania użytkownika, 48 działanie koszyka na zakupy, 163 dziedziczenie funkcjonalności, 102 kontrolerów, 319, 328, 330, 332
E edycja danych, 207 edytor tekstów, 24 elastyczny układ, 88 element, 73, 76 , 146, 562 , 185 , 160 , 182, 293, 294 , 33, 76 , 184, 286, 302, 306–308 - , 378, 499 , 88 , 390, 395 , 313, 314 , 311, 312 , 434 , 33, 93, 460, 540 , 310, 312 , 284
, 183, 578 , 80, 86 , 436 , 309 , 82 , 271, 436