Struktura aplikacji AngularJS (część 2 ‑ komponenty)

W poprzednim artykule z tej serii opowiadałem bardziej o osobistych doświadczeniach z początków pracy z AngularJS 1.0 oraz o drodze jaką przebyło środowisko AngularJS – w skrócie od bałaganu aż do komponentów. Wspomniałem tam również, że AngularJS 1.5 wprowadził nową pomocniczą funkcję angular.component(…) i na jej wykorzystaniu chciałbym się skupić w tym wpisie. W tym celu napiszę bardzo prosty komponent – listę kontaktów z avatarami.

Jeśli z jakiegoś powodu nie możesz zaktualizować AngularJS do wersji 1.5, Todd Motto napisał skrypt, który dodaje angular.component(…) do AngularJS od wersji 1.3. Dokładne informacje na ten temat można znaleźć na jego blogu.

AngularJS a komponenty

Zacznę od przypomnienia czym w ogóle jest komponent. W wielkim skrócie:

komponent to zenkapsulowany samowystarczalny element aplikacji, odpowiedzialny za dokładnie jedną funkcję. Zawiera widok i logikę biznesową.

AngularJS 1.5 wprowadza nową metodę angular.component(…), która przypomina angular.directive(…), ale jest od niej znacznie prostsza. Powstała głównie w celu ułatwienia tworzenia komponentów i promowania tego podejścia. Dodatkowo, metoda .component(…) pozwala na pisanie aplikacji w AngularJS 1 w stylu, który jest bliższy angular2 – dzięki czemu potencjalna przesiadka na ten drugi framework powinna być łatwiejsza, przynajmniej z koncepcyjnego punktu widzenia.

Składnia angular.component(…)

Komponent w AngularJS to tak naprawdę uproszczona dyrektywa. Jakie są różnice? Po pierwsze komponenty zawsze są elementami. Nie można stworzyć komponentu będącego atrybutem – do tego nadal trzeba skorzystać z dyrektywy. Ponadto składnia jest znacznie łatwiejsza. Stworzenie komponentu przy pomocy .directive(…).component(…) widoczne jest poniżej:

.directive(‘myList’, function () {
    return {
        restrict: ‘E’,
        scope: {},
        controllerAs: ‘$ctrl’,
        controller: ‘MyListCtrl’,
        bindToController: {
            contacts: ‘=‘
        },
        templateUrl: ‘myList.html’
    };
});

.component(‘myList’, {
    controller: ‘MyListCtrl’,
    bindings: {
        contacts: ‘=‘
    },
    templateUrl: ‘myList.html’
});

Pierwszą widoczną różnicą jest to, że metoda .component oczekuje tylko nazwy i obiektu z konfiguracją, natomiast metoda .directive oczekiwała nazwy oraz funkcji, która dopiero zwracała obiekt z konfiguracją. Ma to sens w dyrektywach, które nie mają kontrolerów (a mają tylko linking function), bo dzięki tej dodatkowej funkcji można do dyrektywy wstrzyknąć dowolne zależności. Jednak w przypadku komponentu mającego kontroler, zależności można wstrzyknąć bezpośrednio do niego i dzięki temu składnia została uproszczona.

Mała dygresja: Wszystkie nazwy dyrektyw i komponentów zawsze poprzedzam prefiksem, który jest unikatowy dla danej aplikacji. Pozwala to zapobiegać konfliktom nazw z komponentami innych autorów. W tym artykule takim prefiksem jest my.

bindings

Składnia dyrektyw pozwala na określenie, używając parametru scope, czy scope ma być dziedziczony po rodzicu czy izolowany. W przypadku komponentów scope zawsze jest izolowany i dlatego do metody .component(…) tego parametru nie trzeba już podawać. Ponadto, osoby korzystające z AngularJS trochę dłużej na pewno pamiętają małe zamieszanie dookoła parametrów scopebindToController w dyrektywach. Aby ten mętlik jakoś naprawić, składnia komponentów to upraszcza i nie zawiera obu tych atrybutów – zamiast nich ma tylko jeden o nazwie bindings. Mówiąc krótko poniższe fragmenty kodu są równoważne:

// directive
{
    scope: {},
    bindToController: {
        contacts: ‘=‘
    }
}

// component
{
    bindings: {
        contacts: ‘=‘
    }
}

$ctrl

Celowo pominąłem też parametr controllerAs przy tworzeniu komponentu. Nadal można zdefiniować własny alias dla kontrolera w widoku, jednak teraz możliwe jest także pominięcie tego atrybutu w ogóle i w takim przypadku przyjmie on domyślną wartość: $ctrl:

// directive
{
    controller: 'MyCtrl',
    controllerAs: '$ctrl'
}

// component
{
    controller: 'MyCtrl'
}

template

Nigdy jeszcze nie skorzystałem z tej możliwości, ale warto pamiętać, iż teraz parametr template może przyjąć nie tylko string zawierający html, ale również funkcję. Ta funkcja zostanie wywołana z dwoma argumentami – $element$attrs – i musi zwrócić ciąg znaków.

{
    template($element, $attrs) {
        if ($attrs.isNumeric) {
            return '<input type="number">';
        } else {
            return '<input type="text">';
        }
    }
}

Użyłem tutaj skróconej składni funkcji w obiekcie, dodanej w ES2015. Jest to równoważne zapisowi template: function($element, $attrs)…. W przykładach będę używał również innych elementów z ES2015 takich jak class lub arrow functions w celu pokazania w jaki sposób naprawdę teraz wyglądają tworzone przeze mnie aplikacje. Więcej na temat nowości w ECMAScript w innym wpisie.

Muszę też wspomnieć przy okazji o tym, że możliwe jest także konstruowanie tzw. komponentów bezstanowych. Jest to taki komponent, który nie zawiera kontrolera – tylko html i atrybuty. Mówiąc inaczej, jest to element wielokrotnego użytku, który nie ma modelu ani żadnej skomplikowanej logiki:

{
    bindings: {
        'item': '='
    },
    template: '<div> {{ $ctrl.item }} </div>'
}

Jednokierunkowy binding

W poprzednim artykule wspomniałem już o nowym rodzaju bindingu: jednokierunkowym. Do uzyskania takiego bindingu używa się symbolu <. Oznacza on, że zmiany w rodzicu będą widoczne w komponencie, natomiast zmiany w komponencie nie staną się widoczne dla rodzica. Nic nie oddaje tego lepiej niż interaktywny przykład. W trakcie edycji pierwszego pola wartość automatycznie kopiowana jest również do drugiego (wewnątrz komponentu). Natomiast zmiany dokonane w drugim polu nie są już widoczne w pierwszym:

Zobacz Pen Jednokierunkowy binding w AngularJS – Michał Miszczyszyn (@mmiszy) na CodePen.

Istotnym jest, aby pamiętać, że mowa tutaj o zmianach wartości prostej (jak powyżej) lub referencji. Jeśli do bindingu przekazany jest obiekt i w komponencie zmienione zostanie jakieś pole tego obiektu to taka zmiana będzie widoczna w rodzicu. Jeśli natomiast cały obiekt zostanie nadpisany to modyfikacja nie będzie propagowana. Jest to zachowanie znane każdemu programiście JavaScript:

// component
{
    bindings: {
        'item': '<' // referencja obiektu
    },
    controller() {
        this.item.name = 'test'; // jest widoczna w rodzicu
        this.item = {name: 'test'}; // nie jest widoczna w rodzicu
    }
}

Zdarzenia w komponencie

Dawniej, by być informowanym o zmianach atrybutów przekazanych do dyrektywy należało użyć funkcji $scope.$watch(…). Aby móc wykonać jakieś operacje gdy dyrektywa były usuwana ze strony, trzeba było nasłuchiwać na odpowiednie zdarzenie $destroy używając $scope.$on(…). Na szczęście nie jest to potrzebne w przypadku komponentów. W zasadzie wstrzykiwanie zmiennej $scope, poza rzadkimi przypadkami, w ogóle nie jest już przydatne w komponentach. Informowanie o wymienionych wyżej sytuacjadh zostało rozwiązane inaczej: Poprzez zdarzenia cyklu życia komponentu (ang. lifecycle hooks).

W wersji 1.5.0 AngularJS wprowadzono obsługę jednej metody kontrolera komponentu $onInit, a od wersji 1.5.3 dodano jeszcze trzy: $onChanges, $onDestroy$postLink. Więcej informacji można znaleźć na GitHubie, a tutaj krótkie podsumowanie:

  • $onInit – wywoływana gdy komponent zostanie skonstruowany a jego bindingi zainicjalizowane; jeśli jakaś logika znajduje się w kontruktorze to warto przenieść ją do tej metody,
  • $onChanges – wywoływana zawsze, gdy bindingi jednokierunkowe się zmienią; argumentem przekazywanym do tej metody jest obiekt, którego kluczami są nazwy bindingów, które się zmieniły, a wartościami obiekty w postaci { currentValue: …, previousValue: … },
  • $onDestroy – wywoływana gdy dany komponent jest niszczony (usuwany ze strony); wewnątrz tej metody należy np. zwolnić zasoby i usunąć nasłuchiwanie na zdarzenia,
  • $postLink – wywoływana gdy kontroler tego komponentu oraz jego dzieci zakończą fazę linkowania; wewnątrz tej funkcji powinno się dokonywać manipulacji na DOM.

W prostym przykładzie poniżej nie znalazłem niestety miejsca aby pokazać możliwości wszystkich tych metod, używam tylko $onInit oraz $onChanges. Warto jednak pamiętać o ich istnieniu.

Konkretny przykład

Stwórzmy aplikację w AngularJS, który zawiera w sobie listę kontaktów. Każdy kontakt będzie miał imię, wiek oraz email, dodatkowo wyświetlony będzie również jego Gravatar. Efekt końcowy wygląda w ten sposób:

AngularJS lista kontaktów

Tworzenie jakiejkolwiek aplikacji warto zacząć od koncepcyjnego podziału na komponenty. Daje to lepszy pogląd na całokształt tworzonego projektu oraz pomaga wstępnie zidentyfikować potrzeby. W tym celu biorę powyższy obrazek i oznaczam na nim granice poszczególnych komponentów. Dokładnie w ten sposób zostanie zaimplementowana ta aplikacja:

AngularJS projekt aplikacji

Pisanie kodu z użyciem Angulara zaczyna się zazwyczaj od zdefiniowania modułu aplikacji, w tym przypadku będzie to jedyny moduł: const app = angular.module('myApp', []);. Dla skrócenia i poprawy czytelności zakładam, że zmienna app istnieje wszędzie. Dodatkowo pomijam tutaj implementację serwisów, ale pełny kod zamieszczam na CodePen oraz na GitHubie na końcu wpisu. Cały HTML aplikacji wygląda w ten sposób:

<div ng-app="myApp">  
  <my-app></my-app>
</div>  

Widać tutaj jeden główny komponent. Pozostałe elementy będą zawarte w jego szablonie. W tym przypadku komponent aplikacji również nie jest zbyt rozbudowany, jedynym jego zadaniem jest pobranie kontaktów z serwisu ContactsService oraz przekazanie ich do do komponentu listy kontaktów. Dla ułatwienia kontakty są pobierane tutaj synchronicznie, choć oczywiście mogłyby one pochodzić np. z API.

app.component('myApp', {  
    template: `
    <my-contacts-list contacts="$ctrl.contacts"></my-contacts-list>
    `,
    controller: 'MyAppCtrl'
});

app.controller('MyAppCtrl', class MyAppCtrl {  
    constructor(ContactsService) {
        this.ContactsService = ContactsService;
    }

    $onInit() {
        this.contacts = this.ContactsService.contacts;
    }
});

<my-contacts-list> jest komponentem bezstanowym, który tylko przyjmuje tablicę kontaktów oraz dla każdego z nich tworzy element <my-contact-item> i przekazuje do niego obiekt z kontaktem:

app.component('myContactsList', {  
    bindings: {
        'contacts': '<'
    },
    template: `
    <ul>
        <li ng-repeat="contact in $ctrl.contacts track by contact.id">
            <my-contact-item contact="contact"></my-contact-item>
        </li> 
    </ul>
    `
});

<my-contact-item> również jest komponentem bezstanowym. Jego zadaniem jest wyświetlenie imienia i wieku danego kontaktu, oraz stworzenie elementu z avatarem:

app.component('myContactItem', {  
    bindings: {
        'contact': '<'
    },
    template: `
    <div>Name: {{$ctrl.contact.name}}</div>
    <div>Age: {{$ctrl.contact.age}}</div>
    <my-gravatar email="$ctrl.contact.email" size="64"></my-gravatar>
    `
});

<my-gravatar> to komponent, który łatwo wykorzystać ponownie w dowolnym miejscu aplikacji. Zależy tylko od przekazanego mu adresu email oraz opcjonalnego rozmiaru avatara. Wywołuje on metodę z serwisu GravatarService, która zwraca odpowiedni URL Gravatara, który z kolei jest używany do wyświetlenia obrazka. Warto zwrócić uwagę, że w przypadku zmiany przekazanego do tego komponentu adresu email, gravatarUrl również automatycznie się zmieni dzięki metodzie $onChanges:

app.component('myGravatar', {  
    bindings: {
        'email': '<',
        'size': '@'
    },
    template: `
        <img ng-src="{{ $ctrl.gravatarUrl }}">
    `,
    controller: 'MyGravatarCtrl'
});

app.controller('MyGravatarCtrl', class MyGravatarCtrl {  
    constructor(GravatarService) {
        this.GravatarService = GravatarService;
    }

    $onChanges() {
        this.updateGravatarUrl();
    }

    $onInit() {
        this.updateGravatarUrl();
    }

    updateGravatarUrl() {
        this.gravatarUrl = this.GravatarService
            .getGravatarUrl(this.email, this.size);
    }
});

W ten oto sposób zgodnie z najlepszymi praktykami AngularJS, powstała prosta aplikacja komponentowa. Opisałem większość istotnych elementów nowej funkcji angular.component(…) oraz zasady budowania aplikacji opartych o komponenty. Co prawda nie dotknąłem nawet takich tematów jak komunikacja pomiędzy komponentami czy reagowanie na zdarzenia, bo to tematy na tyle rozbudowane, że zasługują na osobny wpis. Co nieco na ten temat napisałem już w innym artykule: Komunikacja pomiędzy kontrolerami.

Na koniec jeszcze kilka uwag. Wszędzie użyłem template zamiast templateUrl. Dlaczego? Tylko ze względu na prostotę przykładu. W praktyce prawie zawsze używam templateUrl, a szablony zapisuję w osobnych plikach HTML. Oczywiście podobnie każdy kontroler i każdy komponent powinny znaleźć się w osobnych plikach, pogrupowane logicznie razem w foldery. Ma to znaczenie tylko z punktu widzenia poprawienia czytelności kodu i ułatwienia procesu tworzenia aplikacji gdyż na serwerze produkcyjnym i tak wszystkie angularowe szablony są osadzane z powrotem w środku plików JS, a z kolei te pliki są łączone razem w jedną dużą paczkę (choć to akurat może się zmienić gdy HTTP/2 stanie się bardziej popularne).

Cały kod, łącznie z pominiętymi tutaj fragmentami dostępny jest na gist.github.com/mmiszy/3736f131fe7cb7a1d37be892dcc00bab

Zobacz Pen zqXyOM – Michał Miszczyszyn (@mmiszy) na CodePen.