Kurs Angular · Moduł 2: Komponenty

Input i Output - komunikacja między komponentami

6 min czytania ·
W tej lekcji6

W dojo pojawił się nowy komponent: karta wojownika app-samurai-card. Ma pokazywać imię i poziom samuraja, tylko skąd ma wiedzieć, którego? Sama tego nie zgadnie, bo listę uczniów zna komponent nadrzędny, czyli rodzic. W drugą stronę jest ten sam kłopot: gdy uczeń kliknie na karcie przycisk ataku, lista musi się dowiedzieć, kto zaatakował, żeby dopisać to do dziennika walki.

Angular rozwiązuje to jedną zasadą: dane płyną w dół, zdarzenia w górę. Rodzic wydaje dziecku rozkazy przez @Input, a dziecko składa raporty rodzicowi przez @Output. Tak jak w armii shoguna: generał nie zagląda żołnierzowi do głowy, a żołnierz nie wydaje rozkazów generałowi. Obie strony porozumiewają się ustalonymi kanałami.

@Input - rozkazy od shoguna

Dekorator @Input() oznacza pole, którego wartość może podać rodzic. Pole dostaje wartość domyślną, której komponent użyje, gdy rodzic nic nie przekaże:

import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-samurai-card',
  standalone: true,
  template: `
    <div class="card">
      <h3>{{ name }}</h3>
      <p>Poziom: {{ level }}</p>
    </div>
  `
})
export class SamuraiCardComponent {
  @Input() name = '';
  @Input() level = 1;
}

Dekorator Input importujesz z @angular/core, tak jak Component. Karta nie wie, skąd przyjdą dane, i nie musi wiedzieć: po prostu wyświetla to, co dostanie. Rodzic przekazuje wartości atrybutami w tagu komponentu:

<app-samurai-card name="Musashi" [level]="10"></app-samurai-card>
<app-samurai-card [name]="bestStudent" [level]="bestLevel"></app-samurai-card>

Zapis name="Musashi" przekazuje stały tekst. Zapis [level]="10" to property binding z drugiej lekcji: Angular oblicza wyrażenie 10 i przekazuje liczbę. Bez nawiasów, jako level="10", dziecko dostałoby napis "10". W drugiej linii [name]="bestStudent" odczytuje pole bestStudent z klasy rodzica, a gdy rodzic je zmieni, karta sama pokaże nowe imię. Przy tekście oba zapisy są równoważne: [name]="'Musashi'" daje to samo co name="Musashi". Pamiętaj też, że rodzic musi mieć SamuraiCardComponent na swojej liście imports.

Wymagany input

Czasem wartość domyślna nie ma sensu. Karta bez klanu jest bezużyteczna, więc chcemy, żeby rodzic nie mógł o nim zapomnieć. Służy do tego opcja required w dekoratorze:

export class SamuraiCardComponent {
  @Input() name = '';
  @Input() level = 1;
  @Input({ required: true }) clan!: string;
}

Jeśli rodzic wstawi kartę bez atrybutu clan, kompilator Angulara zgłosi błąd, zanim aplikacja w ogóle się uruchomi. Wykrzyknik w clan!: string jest dla TypeScriptu: to obietnica, że pole dostanie wartość, choć nie ma jej w deklaracji. Bez niego tryb strict, który Angular włącza domyślnie, zgłosiłby, że pole nie ma wartości początkowej. Tu obietnica jest bezpieczna, bo required gwarantuje, że rodzic poda wartość.

@Output - raport dla shoguna

Teraz kierunek odwrotny. Dziecko ogłasza zdarzenie przez pole oznaczone dekoratorem @Output(). Wartością pola jest EventEmitter, czyli nadajnik zdarzeń, a w nawiasach ostrych podajesz typ wartości, którą zdarzenie niesie:

import { Component, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'app-attack-button',
  standalone: true,
  template: `<button (click)="onAttack()">Atakuj!</button>`
})
export class AttackButtonComponent {
  @Output() attacked = new EventEmitter<number>();

  onAttack(): void {
    const damage = Math.floor(Math.random() * 100);
    this.attacked.emit(damage);
  }
}

Zapis new EventEmitter<number>() tworzy nadajnik zdarzeń niosących liczbę. <number> to parametr typu: TypeScript przypilnuje, żeby przez attacked nie wysłać napisu ani obiektu. Po kliknięciu przycisku metoda onAttack() losuje obrażenia od 0 do 99 i wysyła je rodzicowi wywołaniem this.attacked.emit(damage). Samo emit niczego nie wyświetla. To tylko meldunek, a o tym, co z nim zrobić, decyduje rodzic.

Rodzic nasłuchuje zdarzenia dziecka tak samo jak kliknięcia, przez event binding z nazwą pola oznaczonego @Output:

import { Component } from '@angular/core';
import { AttackButtonComponent } from './attack-button.component';

@Component({
  selector: 'app-battle',
  standalone: true,
  imports: [AttackButtonComponent],
  template: `
    <app-attack-button (attacked)="handleAttack($event)"></app-attack-button>
    <p>Ostatnie obrażenia: {{ lastDamage }}</p>
  `
})
export class BattleComponent {
  lastDamage = 0;

  handleAttack(damage: number): void {
    this.lastDamage = damage;
    console.log(`Zadane obrażenia: ${damage}`);
  }
}

Przy zdarzeniu z @Output zmienna $event nie jest zdarzeniem przeglądarki, tylko dokładnie tą wartością, którą dziecko przekazało do emit(). Tutaj jest to liczba obrażeń, więc handleAttack od razu zapisuje ją w lastDamage, a interpolacja pokazuje ją na ekranie. Dziecko nie wie nic o lastDamage, a rodzic nie wie, jak dziecko liczy obrażenia. Każdy pilnuje swojej części.

Cały przepływ krok po kroku

Prześledźmy jeszcze raz drogę meldunku od dziecka do rodzica. Każdy krok wynika z poprzedniego:

  1. Dziecko deklaruje pole @Output() attacked = new EventEmitter<number>().
  2. W dziecku następuje zdarzenie, na przykład uczeń klika przycisk ataku.
  3. Dziecko wywołuje this.attacked.emit(damage) i wysyła wartość.
  4. Rodzic odbiera ją przez (attacked)="handleAttack($event)", gdzie $event to wysłana wartość.

Rozkaz w dół przez @Input działa prościej, bo nie potrzebuje żadnego zdarzenia: rodzic zmienia wartość w swoim polu, a Angular przekazuje ją dziecku przy najbliższym odświeżeniu widoku.

Nowsze API: funkcje input() i output()

Od Angulara 19 stabilne są też funkcje input() i output(), które robią to samo bez dekoratorów. Tak wygląda karta wojownika zapisana w tym stylu:

import { Component, input, output } from '@angular/core';

@Component({
  selector: 'app-warrior-card',
  standalone: true,
  template: `
    <h3>{{ name() }}</h3>
    <button (click)="attacked.emit(name())">Atakuj!</button>
  `
})
export class WarriorCardComponent {
  name = input('Nieznany');
  attacked = output<string>();
}

Wywołanie input('Nieznany') tworzy input z wartością domyślną. Jego wartość odczytujesz z nawiasami, name(), bo to sygnał, czyli wartość, która sama powiadamia Angulara o zmianach. Sygnały poznasz dokładnie w szóstym module. Wymagany input zapiszesz jako input.required<string>(). Z kolei output<string>() zastępuje @Output() z EventEmitter i ma tę samą metodę emit(). Rodzic nie widzi żadnej różnicy: nadal pisze [name]="..." i (attacked)="...". W zadaniach tego modułu możesz używać obu zapisów, ale warto znać dekoratory, bo spotkasz je w większości istniejących projektów.

Pełny przykład - lista z wyborem

Połączmy oba kierunki. Karta samuraja z pierwszego przykładu dostaje przycisk i zdarzenie selected, a lista wyświetla karty w pętli @for i zapamiętuje wybranego wojownika:

// Karta samuraja z pierwszego przykładu, uzupełniona o @Output
import { Component, Input, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'app-samurai-card',
  standalone: true,
  template: `
    <div class="card">
      <h3>{{ name }}</h3>
      <p>Poziom: {{ level }}</p>
      <button (click)="selected.emit(name)">Wybierz</button>
    </div>
  `
})
export class SamuraiCardComponent {
  @Input() name = '';
  @Input() level = 1;
  @Input({ required: true }) clan!: string;
  @Output() selected = new EventEmitter<string>();
}

// Lista samurajów z możliwością wyboru
@Component({
  selector: 'app-samurai-list',
  standalone: true,
  imports: [SamuraiCardComponent],
  template: `
    @for (samurai of samuraiList; track samurai.id) {
      <app-samurai-card
        [name]="samurai.name"
        [level]="samurai.level"
        [clan]="samurai.clan"
        (selected)="onSelect($event)">
      </app-samurai-card>
    }

    @if (selectedName) {
      <p>Wybrany: {{ selectedName }}</p>
    }
  `
})
export class SamuraiListComponent {
  samuraiList = [
    { id: 1, name: 'Musashi', level: 10, clan: 'Niten' },
    { id: 2, name: 'Kojiro', level: 9, clan: 'Ganryū' }
  ];

  selectedName = '';

  onSelect(name: string): void {
    this.selectedName = name;
  }
}

Lista przekazuje każdej karcie trzy inputy: name, level i wymagany clan. Karta wyświetla imię i poziom, a po kliknięciu przycisku wysyła swoje imię wywołaniem selected.emit(name) wprost z szablonu. Lista odbiera je przez (selected)="onSelect($event)", zapisuje w selectedName, a blok @if pokazuje akapit "Wybrany: Musashi". Każdy element tego przykładu poznałeś w tym module: dekorator, wiązania, control flow i komunikację między komponentami.

Pamiętaj: rozkazy płyną w dół przez @Input, raporty wracają w górę przez @Output i emit(), a żaden wojownik nie sięga do cudzej klasy bez ustalonego kanału.

Hasła z dokumentacji w tej lekcji

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Który dekorator służy do wysyłania zdarzeń z komponentu dziecka do rodzica?

  2. 2. Który dekorator pozwala komponentowi dziecku przyjmować dane od komponentu rodzica?

  3. 3. Jak oznaczyć @Input jako wymagany (required) zgodnie z lekcją?

  4. 4. Która składnia poprawnie reprezentuje two-way binding w Angularze?

Zadania praktyczne w grze

  • Klikanie w kolejności

    Ułóż tokeny, aby utworzyć obiekt zdarzenia: new EventEmitter<number>()

  • Edytor kodu

    Uzupełnij dekoratory @Input dla imienia wojownika i @Output dla zdarzenia ataku.

  • Układanie w poziomie

    Ułóż tokeny jednej linii, aby wyemitować zdarzenie z obrażeniami: this.attacked.emit(damage);

  • Klikanie w kolejności

    Ułóż tokeny, aby przekazać dziecku poziom wojownika przez property binding: [level]="10"

  • Układanie w pionie

    Uporządkuj kroki przepływu danych przy komunikacji rodzic-dziecko przez @Output (od góry do dołu):

  • Układanie w pionie

    Uporządkuj główne części pliku komponentu w typowej kolejności (od góry do dołu):

Przydatne artykuły