Własne komponenty w Angular 2

W poprzedniej części kursu Angular 2 omówiłem założenia frameworka, tworzenie nowego projektu, podstawy bindingów oraz wreszcie kod pierwszego komponentu. Pominąłem jednak kilka kwestii takich jak bindingi na własnych komponentach, pełna składnia bindingu dwukierunkowego, czy możliwość odwoływania się do referencji do obiektów na poziomie szablonów (brzmi skomplikowanie, ale nie jest!). Chciałbym teraz do tych kwestii wrócić. W tym celu odtworzę projekt, który poprzednio zaimplementowałem w AngularJS 1.5 gdy opisywałem koncept komponentów. Będzie to prosta lista kontaktów z gravatarami.

Koncepcyjnie

Powróćmy do projektu aplikacji z tamtego wpisu. Jest to lista kontaktów podzielona na komponenty:

Koncept aplikacji

Zacznijmy budowanie aplikacji od stworzenia potrzebnych komponentów przy pomocy Angular CLI. Główny komponent aplikacji już istnieje (domyślnie stworzony przez ng new {NAZWA PROJEKTU}), wystarczy więc dodać contacts-list, contact-item i gravatar. Do tego również można wykorzystać Angular CLI:

ng generate component contacts-list  
ng g component contact-item  
ng g component gravatar  

Dodatkowo potrzebujemy serwis, w którym będą przechowywane kontakty:

ng g service contacts  

Dzięki pomocy Angular CLI, nie musimy już powtarzać wielu manualnych czynności co znacznie przyspiesza pracę z Angular 2.

Określenie „serwis” dotyczy w zasadzie dowolnej klasy w Angularze, która nie jest komponentem. Na przykład modele, wszelkie klasy pomocnicze i pośredniczące.

Serwisy w Angular 2

Kiedy otwieram wygenerowany właśnie przy pomocy Angular CLI serwis, moim oczom ukazuje się podstawowy kod:

import { Injectable } from [email protected]/core';

@Injectable()
export class ContactsService {  
}

Jest to zwykła klasa z dodanym dekoratorem @Injectable. Do czego jest potrzebny dekorator @Injectable? Jeśli klasa, którą tworzymy wykorzystuje Dependency Injection to musi mieć jakiś dekorator, np. @Injectable. Jest to wymaganie stawiane przez TypeScript – Angular 2 korzysta z metadanych parametrów przekazywanych do konstruktora, aby wstrzyknąć prawidłowe zależności. Te metadane są jednak nieobecne jeśli klasa nie ma żadnego dekoratora! W tym przypadku nie korzystamy (jeszcze) z Dependency Injection, jednak zalecam dodać dekorator ze względu na spójność z resztą aplikacji.

Dobrą praktyką jest dodawanie dekoratora @Injectable do każdego serwisu.

Stworzony serwis ma za zadanie przechowywać tablicę kontaktów. Zaczynamy więc od zadeklarowania interfejsu reprezentującego kontakt, a następnie dodajemy do klasy serwisu odpowiednie pole z jednym kontaktem (przykładowo). Cała klasa serwisu wygląda tak:

import {Injectable} from [email protected]/core';

export interface Contact {  
  id:number;
  name:string;
  age:number;
  email:string;
}

@Injectable()
export class ContactsService {  
  contacts:Array<Contact> = [{
    id: 1,
    name: 'Tester',
    age: 99,
    email: [email protected]'
  }];
}

Dependency Injection

Tak napisaną klasę ContactsService możemy wstrzyknąć w dowolne miejsce w aplikacji. Dependency Injection w Angular 2 jest podobne do tego z AngularJS, ale znacznie bardziej rozbudowane i dające więcej możliwości.

Dependency Injection jest wzorcem projektowym, który ma na celu usuwanie ścisłych powiązań pomiędzy komponentami aplikacji. Jest to realizowane poprzez odwrócenie kontroli (Inversion of Control) – komponenty nie tworzą ani nie ładują potrzebnych serwisów, a zamiast tego definiują listę zależności i oczekują, że te zależności zostaną im przekazane. Rozwiązanie to jest niezwykle elastyczne i ułatwia tworzenie aplikacji. Więcej na temat teorii można poczytać choćby na wikipedii.

Jednym z nowych elementów DI w Angular 2 jest fakt, że równolegle do drzewa komponentów istnieje również drzewo hierarchicznych zależności, a konfiguracja Dependency Injection może nastąpić na dowolnym poziomie tego drzewa (w dowolnym komponencie). Więcej na ten temat można doczytać w dokumentacji. Na potrzeby tego wpisu wystarczy nam informacja, że aby zależność można było wstrzyknąć, musimy podać również który komponent ją udostępnia. Robimy to przy pomocy tablicy providers w dekoratorze @Component. Ponieważ chcemy, aby serwis był „globalny”, więc dodajemy tablicę providers w najwyższym komponencie aplikacji. W tym przykładzie akurat do tego samego komponentu wstrzykujemy również instancję serwisu, aby skorzystać z tablicy kontaktów:

@Component({
  …
  providers: [ContactsService]
})
export class AppComponent {  
  constructor(private contactsService: ContactsService) {
  }
}

Zdarzenia cyklu życia

Opisywałem już zdarzenia cyklu życia (lifecycle hooks) w AngularJS 1.5 i podobny koncept istnieje również w Angular 2. Krótko mówiąc, chodzi o takie metody w klasie komponentu, które są automatycznie wywoływane przez Angulara gdy komponent jest tworzony, zmieniany lub niszczony. Szczegóły można doczytać, w tym przypadku interesuje nas jedno konkretne zdarzenie: tworzenie komponentu.

Dobrą praktyką jest umieszczanie jak najmniej logiki w konstruktorze klasy. Dzięki temu instancjonowanie komponentu jest szybsze, łatwiej nim zarządzać i testować. Bardziej skomplikowane operacje zalecam przenieść do metody ngOnInit z interfejsu OnInit.

Aby wpiąć się w jedno ze zdarzeń cyklu życia, polecane jest zaimplementowanie w klasie komponentu odpowiedniego interfejsu udostępnianego przez Angulara. Chcemy wykonać pewne operacje od razu po stworzeniu komponentu, więc implementujemy interfejs OnInit, który zawiera metodę ngOnInit. Zostanie ona automatycznie wywołana przez Angulara po stworzeniu komponentu. Cały kod klasy ostatecznie wygląda w ten sposób:

export class AppComponent implements OnInit {  
  public contacts:Array<Contact>;

  ngOnInit() {
    this.contacts = this.contactsService.contacts;
  }

  constructor(private contactsService:ContactsService) {
  }
}

Wejście i wyjście komponentu

Widok stworzonego komponentu zawiera w sobie tylko jeden komponent <typeofweb-contacts-list>. Chcielibyśmy do tego komponentu przekazać jako argument tablicę z kontaktami. Jak to zrobić? Opisywałem wcześniej różne rodzaje bindingów w Angular 2. Można przekazać coś do komponentu, odebrać od niego, lub użyć bindingu dwukierunkowego. Służą do tego specjalne dekoratory @Input i @Output, które umieszcza się przed nazwą pola w klasie:

export class TypeofwebComponent {  
  @Input() name:string;
  @Output() event = new EventEmitter();
}

Aby teraz przekazać do komponentu atrybut name wystarczy po prostu napisać:

<typeofweb-component name="Michał"></typeofweb-component>  
<typeofweb-component [name]="variable"></typeofweb-component>  

W pierwszej wersji przekazywany jest ciąg znaków „Michał”, w drugiej przekazana zostanie zawartość zmiennej variable. Skorzystanie z bindingu @Output jest nieco bardziej skomplikowane. Przede wszystkim w kodzie HTML przekazujemy funkcję, którą definiuje się w klasie wyżej:

<typeofweb-component (event)=“handler($event)”></typeofweb-component>  

Teraz, aby handler został wywołany, konieczne jest wyemitowanie zdarzenia wewnątrz klasy TypeofwebComponent:

this.event.emit('Dziala');  

Nie wspomniałem tutaj o bindingu dwukierunkowym, gdyż nie ma do niego specjalnej składni. Taki binding definiuje się poprzez stworzenie pola z dekoratorem @Input i pola o tej samej nazwie z suffiksem Change z dekoratorem @Output:

<typeofweb-component [(name)]=“variable”></typeofweb-component>  
@Input() name:string;
@Output() nameChange = new EventEmitter();

Referencja na element

Przy okazji warto wspomnieć o jeszcze jednym specjalnym symbolu używanym w szablonach: # czyli referencji na element. Spójrzmy od razu na przykład. Zakładam, że w klasie komponentu istnieje metoda log, która wypisuje przekazaną wartość do konsoli:

<input #mojInput>  
<button (click)="log(mojInput.value)">Log</button>  

Teraz po wpisaniu czegoś w input i kliknięciu w guzik, w konsoli wyświetli się wpisana wartość. Jak to działa? #mojInput oznacza stworzenie lokalnej (w szablonie) zmiennej, która wskazuje na dany element. Pod zmienną mojInput znajduje się w tym przypadku referencja na input, a więc mojInput.value zawiera w sobie wartość wpisaną w pole. Jeśli # zostanie użyty na elemencie, który jest komponentem, to referencja będzie wskazywała na kontroler tego komponentu – więcej w dokumentacji.

Lista kontaktów

Wróćmy do tworzonej aplikacji. W komponencie ContactsListComponent definiujemy pole przyjmujące tablicę kontaktów:

@Input() contacts:Array<Contact>;

a w widoku AppComponent przekazujemy ją jako atrybut:

<typeofweb-contacts-list [contacts]="contacts"></typeofweb-contacts-list>  

To jednak… nie działa. Jeszcze. Dodatkowo każdy komponent (lub jego rodzic, patrz punkt o hierarchicznym Dependency Injection) powinien definować komponenty i dyrektywy, z których będzie korzystał (analogicznie do tablicy providers). Do dekoratora @Component komponentu AppComponent dodajemy więc:

directives: [ContactsListComponent]  

Prawie gotowa aplikacja

Korzystając z wiedzy, którą już posiadamy, możemy teraz napisać resztę aplikacji. Po pierwsze definiujemy, że ContactsListComponent będzie korzystał z ContactItemComponent:

directives: [ContactItemComponent]  

W widoku listy kontaktów iteruję po wszystkich kontaktach i kolejno je wyświetlam:

<ul>  
  <li *ngFor="let contact of contacts">
    <typeofweb-contact-item [contact]="contact"></typeofweb-contact-item>
  </li>
</ul>  

Komponent przyjmuje jako atrybut pojedynczy obiekt z kontaktem:

@Input() contact:Contact;

I wyświetla go:

<div>Name: {{contact.name}}</div>  
<div>Age: {{contact.age}}</div>  
<typeofweb-gravatar [email]="contact.email" size="64"></typeofweb-gravatar>  

Voilà! Gotowe. Źródła komponentu typeofweb-gravatar tutaj pominę, ale całość dostępna jest na moim GitHubie: github.com/mmiszy/angular2-contacts-list. Efekt prezentuje się poniżej:

Podsumowanie

W tym artykule opisałem kilka istotnych elementów tworzenia aplikacji w Angular 2. Po pierwsze nowe komendy Angular CLI: ng generate …. Ponadto omówiłem sposoby implementacji wejścia i wyjścia do komponentów, wstrzykiwanie zależności oraz tworzenie pośredniczących serwisów. W kolejnej części dokończę projekt listy zadań w Angular 2 i omówię komunikację pomiędzy komponentami z wykorzystaniem Reduksa. Aby oswoić się z samymi konceptami Reduksa, polecam mój wpis Flux i Redux. Zachęcam do komentowania!

Michał Miszczyszyn

Programista z doświadczeniem w JavaScripcie po stronie klienta i serwera. Wielki fan TypeScripta.

Subscribe to Type of Web

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!