Javascript

Kurs Angular 2 – Komunikacja, Input i Output

Wiemy już w jaki sposób tworzyć podstawowe komponenty i wykorzystywać je do budowania naszych aplikacji w Angular 2. Dzisiaj omówimy pierwszy ze sposobów umożliwiający prostą komunikację pomiędzy nimi – właściwości oznaczone dekoratorem @Input() oraz @Output().

Dane wejściowe @Input()

W Angular 2 komponent rodzica ma możliwość przekazania do dziecka danych, które mogą determinować zachowanie komponentu lub w ogóle – pozwolić na jego odpowiednie wyrenderowanie. Jest to możliwe dzięki adnotacji @Input(), a jej wykorzystanie jest banalnie proste:


@Component({ selector: 'simple-component' })
export class SimpleComponent {
  @Input() title : string;
}

Teraz przekazanie instancji komponentu SimpleComponent parametru title ogranicza się do poniższego zapisu:


<simple-component title="Example"></simple-component>

Oczywiście, nic nie stoi na przeszkodzie, aby nasze parametry miały określoną wartość domyślną:


@Input() title = "Default title";

Zdarzenia @Output()

Wiemy już w jaki sposób przekazać dane z rodzica do dziecka. Adnotacja @Output() pozwala nam osiągnąć odwrotny rezultat – przekazać wartości od dziecka do rodzica – przy pomocy zdarzenia. Przykład:


@Output() titleChanged = new EventEmitter<string>();

Warto podkreślić, że EventEmitter jest klasą generyczną – szablonem. Powyżej określiliśmy, że parametr jaki będzie przekazywał w postaci argumentu, będzie typu string. Teraz, aby przekazać metodę, która wywoła się przy emisji zdarzenia, wystarczy poniższy zapis:


<simple-component (titleChanged)="titleChanged($event)"></simple-component>

Aby wyemitować zdarzenie, musimy wywołać metodę emit() obiektu titleChanged:


this.titleChanged.emit(newTitle);

Poskutkuje to wywołaniem funkcji titleChanged rodzica i przekazaniem mu w postaci argumentu – nowego tytułu.

Praktyka, nie teoria

Myślę, że to wystarczający wstęp teoretyczny – czas zająć się praktyką. W ramach ćwiczeń stworzymy prostą aplikację w stylu Todo, która będzie pozwalała na tworzenie nowych zadań oraz usuwanie już istniejących. Po raz kolejny – aby maksymalnie ograniczyć czas poświęcony na style CSS – wykorzystamy bibliotekę Materialize. Końcowy rezultat powinien być taki, jak na poniższym obrazku:

Todoapp example

Rozpoczniemy od komponentu, który będzie reprezentował pojedyncze zadanie – TodoItemComponent. Niech na pojedyncze zadanie składa się nazwa oraz status. Dla wygody wygenerujemy sobie enumerator TodoStatus:


ng g enum todo-status

export enum TodoStatus {
  TODO,
  IN_PROGRESS,
  IN_REVIEW,
  BUG,
  DONE
}

Oraz interfejs – Todo, wymuszający na obiektach posiadanie odpowiednich składowych:


ng g interface todo

export interface Todo {
  name: string,
  status: TodoStatus
}

Nasz komponent powinien otrzymać od rodzica obiekt spełniający interfejs Todo oraz metodę, która będzie wywoływana podczas usuwania zadania. Dodatkowo w zależności od statusu, powinien dobierać odpowiednią klasę CSS oraz wyświetlać prawidłowy tekst. Otwieramy plik todo-item.component.ts i realizujemy postawione założenia:


@Component({
  selector: 'todo-item',
  templateUrl: './todo-item.component.html',
  styleUrls: ['./todo-item.component.css']
})
export class TodoItemComponent {
  @Output() removeTodo = new EventEmitter<Todo>();
  @Input() item: Todo;

  get statusName(): string {
    const { status } = this.item;

    switch(status) {
      case TodoStatus.DONE: return "DONE";
      case TodoStatus.BUG: return "BUG";
      case TodoStatus.IN_PROGRESS: return "IN PROGRESS";
      case TodoStatus.IN_REVIEW: return "IN REVIEW";
      default: return "TODO";
    }
  }

  get classNames() {
    const statusName = this.statusName.split(" ").pop().toLowerCase();

    return {
      'status': true,
      [`status-${statusName}`]: true
    }
  }

  public remove(): void {
    this.removeTodo.emit(this.item);
  }
}

Widzimy, że ze składowych @Input() i @Output() korzystamy tak samo, jak gdybyśmy posługiwali się zwykłymi polami klasy. Pozostaje nam szablon komponentu:


<h5>{{ item.name }}</h5>
<span [ngClass]="classNames">{{ statusName }}</span>
<i class="material-icons" (click)="remove()">close</i>

To wszystko – nasz pierwszy komponent jest gotowy. Przechodzimy do formularza, dzięki któremu będziemy w stanie dodawać nowe zadania do kolekcji. Komponent powinien emitować zdarzenia addTodo() i przekazywać w postaci argumentu – nowy obiekt spełniający interfejs Todo – jeżeli pole Name nie pozostało puste.


@Component({
  selector: 'add-todo',
  templateUrl: './add-todo.component.html',
  styleUrls: ['./add-todo.component.css']
})
export class AddTodoComponent {
  @Output() addTodo = new EventEmitter<Todo>();
  todo: Todo;

  constructor() {
    this.setInitial();
  }

  public add(): void {
    const { name, status } = this.todo;

    if(name.length > 0) {
      this.todo.status = +status;
      this.addTodo.emit(this.todo);
      this.setInitial();
    }
  }

  private setInitial(): void {
    this.todo = {
      name: "",
      status: TodoStatus.TODO
    }
  }
}

Pola input oraz select powiążemy z obiektem todo przy pomocy dwustronnego wiązania, które osiągniemy wykorzystując [(ngModel)] (jeżeli zagadnienie bindingu jest Ci obce, zerknij tutaj). Kod szablonu komponentu:


<div class="card">
  <div class="row">
    <div class="form-control col s8">
      <input placeholder="Name" [(ngModel)]="todo.name" type="text" class="validate">
    </div>
    <div class="form-control col s8">
      <select [(ngModel)]="todo.status">
        <option value="0">TODO</option>
        <option value="1">IN PROGRESS</option>
        <option value="2">IN REVIEW</option>
        <option value="3">BUG</option>
        <option value="4">DONE</option>
      </select>
    </div>
    <div class="form-control col s8">
      <button type="button" class="btn red darken-1" (click)="add()">Add item</button>
    </div>
  </div>
</div>

Ostatnia prosta

Nasze komponenty są już gotowe. Pozostało nam złożyć to wszystko w całość i odpowiednio reagować na emitowane zdarzenia. Nasz root component powinien posiadać listę zadań Todo oraz implementację dwóch, głównych metod, których zadaniem jest dodawanie lub usuwanie obiektów z kolekcji.


Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  public todos: Todo[];

  constructor() {
    this.todos = [];
  }

  public addTodo(todo: Todo) {
    this.todos.unshift(todo);
  }

  public removeTodo(todo: Todo) {
    this.todos = this.todos.filter(item => item !== todo);
  }
}

To wszystko. Pozostaje szablon, w którym jako rodzic przekażemy dzieciom odpowiednie dane wejściowe i podepniemy pod emitowane zdarzenia nasze implementacje metod addTodo() i removeTodo().


<div class="container">
  <h2>Todo App</h2>
  <add-todo (addTodo)="addTodo($event)"></add-todo>
  <div class="todo-list">
    <ul class="collection">
      <li *ngFor="let todo of todos" class="collection-item">
        <todo-item [item]="todo" (removeTodo)="removeTodo($event)"></todo-item>
      </li>
    </ul>
  </div>
</div>

Nasza prosta aplikacja powinna już funkcjonować zgodnie z założeniami.

Podsumowanie

Celem artykułu było zaprezentowanie podstawowej możliwości komunikacji pomiędzy komponentami w konfiguracji rodzic – dziecko. Jest to jeden z kilku sposobów i sprawdza się w najprostszych przypadkach. W kolejnych artykułach zajmiemy się pozostałymi rozwiązaniami – referencją lokalną, shared service i koncepcją znaną przede wszystkim programistom Reacta – Redux’em.

Pełny kod źródłowy dostępny na GitHub.