Kurs Angular · Moduł 2: Komponenty
Input i Output - komunikacja między komponentami
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:
- Dziecko deklaruje pole
@Output() attacked = new EventEmitter<number>(). - W dziecku następuje zdarzenie, na przykład uczeń klika przycisk ataku.
- Dziecko wywołuje
this.attacked.emit(damage)i wysyła wartość. - Rodzic odbiera ją przez
(attacked)="handleAttack($event)", gdzie$eventto 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. Który dekorator służy do wysyłania zdarzeń z komponentu dziecka do rodzica?
2. Który dekorator pozwala komponentowi dziecku przyjmować dane od komponentu rodzica?
3. Jak oznaczyć @Input jako wymagany (required) zgodnie z lekcją?
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):