Lekcja 10. Angular II — data binding, zdarzenia własne i serwisy
Trudny / egzaminacyjnyPo co się tego uczymy?
W poprzedniej lekcji dane w komponencie były "na sztywno" wpisane w kodzie. Prawdziwe aplikacje muszą reagować na działania użytkownika (kliknięcia, wpisywanie tekstu) i pobierać dane z zewnątrz (z backendu). Ta lekcja pokazuje mechanizm wiązania danych w obie strony, komunikację między komponentem rodzica i potomnym, oraz serwisy — miejsce, gdzie żyje logika i dane współdzielone przez wiele komponentów.
Teoria
Interpolacja jednokierunkowa a [property binding]. {{ wartosc }} (poznane w lekcji 9) wypisuje wartość jako TEKST. Żeby powiązać wartość TypeScript z WŁAŚCIWOŚCIĄ znacznika HTML (np. czy przycisk jest wyłączony), używa się nawiasów kwadratowych: [disabled]="czyZapisywanie" — wiązanie TYLKO w jedną stronę (z TypeScript do HTML).
Wiązanie zdarzeń — (event). Nawiasy okrągłe podpinają zdarzenie HTML do metody w klasie: (click)="zapisz()" wywołuje metodę zapisz() po kliknięciu — bezpośredni odpowiednik @onclick z Blazor albo Clicked="..." z XAML w MAUI. Inne częste zdarzenia: (input) (każde naciśnięcie klawisza), (submit) (wysłanie formularza), (change) (zmiana wartości i utrata fokusu).
Dwukierunkowe wiązanie — [(ngModel)]. Łączy nawiasy kwadratowe I okrągłe naraz (stąd nazwa "banana in a box" — kwadratowe na zewnątrz, okrągłe w środku): <input [(ngModel)]="imie" /> — wpisany tekst automatycznie aktualizuje pole imie w klasie, a programowa zmiana imie automatycznie aktualizuje pole na ekranie. Wymaga zaimportowania FormsModule (widziane już w lekcji 4).
@Input() i @Output() — komunikacja komponent rodzic ↔ potomny. Dokładnie ten sam problem, który w Blazor rozwiązywał [Parameter] i EventCallback:
| Dekorator | Kierunek | Zastosowanie |
|---|---|---|
@Input() nazwa!: string; |
rodzic → potomny (dane W DÓŁ) | komponent potomny odbiera dane przekazane z zewnątrz: <app-karta [nazwa]="produkt.nazwa"></app-karta> |
@Output() kliknieto = new EventEmitter<string>(); |
potomny → rodzic (zdarzenie W GÓRĘ) | komponent potomny informuje rodzica: this.kliknieto.emit(wartosc);, a rodzic nasłuchuje: <app-karta (kliknieto)="obsluz($event)"></app-karta> |
Serwisy (services) — logika i dane współdzielone. Gdy KILKA komponentów potrzebuje tych samych danych albo tej samej logiki (np. listy produktów, komunikacji z backendem), umieszcza się je w OSOBNEJ klasie — serwisie, oznaczonym dekoratorem @Injectable({ providedIn: 'root' }). Komponent "prosi" o dostęp do serwisu przez KONSTRUKTOR (mechanizm zwany Dependency Injection — ta sama koncepcja, którą poznałeś przy SignInManager/DbContext w lekcjach o ASP.NET Core). Serwis to bezpośredni odpowiednik oddzielenia logiki od UI z lekcji o MVVM w dziale MAUI — komponent zajmuje się TYLKO wyświetlaniem, serwis TYLKO danymi/logiką.
Schemat
Komponent RODZICA (lista-produktow.component.ts)
│
│ <app-karta-produktu [produkt]="p" (dodano)="naDodanie($event)">
▼
Komponent POTOMNY (karta-produktu.component.ts)
@Input() produkt!: Produkt; ← DANE W DÓŁ
@Output() dodano = new EventEmitter<Produkt>();
│
│ użytkownik klika "Dodaj do koszyka"
▼
this.dodano.emit(this.produkt); ← ZDARZENIE W GÓRĘ
│
▼
Komponent RODZICA: naDodanie(produkt: Produkt) { ... }
SERWIS (produkt.service.ts) Komponent A Komponent B
@Injectable({providedIn: "root"}) │ │
export class ProduktService { │ constructor( │ constructor(
pobierzWszystkie() { ... } ◄──────────┘ private ps: │ private ps:
} ProduktService) │ ProduktService)
(JEDNA instancja współdzielona przez WSZYSTKIE komponenty, które o nią poproszą)
Przykład z życia
Lista produktów w sklepie internetowym renderuje wiele komponentów "Karta produktu" (potomne), a kliknięcie przycisku "Dodaj do koszyka" na KONKRETNEJ karcie musi zaktualizować licznik koszyka widoczny w NAGŁÓWKU strony (zupełnie inny komponent) — to właśnie zadanie dla @Output() (karta informuje rodzica) połączonego z serwisem koszyka (współdzielonym między listą a nagłówkiem).
karta-produktu.component.ts
// karta-produktu.component.ts (komponent potomny)
import { Component, Input, Output, EventEmitter } from '@angular/core';
interface Produkt {
id: number;
nazwa: string;
cena: number;
}
@Component({
selector: 'app-karta-produktu',
standalone: true,
templateUrl: './karta-produktu.component.html'
})
export class KartaProduktuComponent {
@Input() produkt!: Produkt;
@Output() dodano = new EventEmitter<Produkt>();
dodajDoKoszyka() {
this.dodano.emit(this.produkt);
}
}
koszyk.service.ts + lista-produktow.component.ts
// koszyk.service.ts - serwis współdzielony
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class KoszykService {
private liczbaProduktow = 0;
dodaj(): void {
this.liczbaProduktow++;
}
pobierzLiczbe(): number {
return this.liczbaProduktow;
}
}
// ---------------------------------------------------------------------
// lista-produktow.component.ts (komponent rodzica, korzysta z serwisu i komponentu potomnego)
import { Component } from '@angular/core';
import { KartaProduktuComponent } from '../karta-produktu/karta-produktu.component';
import { KoszykService } from '../koszyk.service';
@Component({
selector: 'app-lista-produktow',
standalone: true,
imports: [KartaProduktuComponent],
templateUrl: './lista-produktow.component.html'
})
export class ListaProduktowComponent {
produkty = [
{ id: 1, nazwa: 'Klawiatura', cena: 89.99 },
{ id: 2, nazwa: 'Monitor', cena: 799.00 }
];
constructor(private koszykService: KoszykService) {}
naDodanie(produkt: any) {
this.koszykService.dodaj();
console.log(`Dodano do koszyka: ${produkt.nazwa}`);
}
}
Komentarz i wyjaśnienie kodu
@Input() produkt!: Produkt; — wykrzyknik po nazwie właściwości (definite assignment assertion) mówi kompilatorowi TypeScript: "wiem, że ta właściwość na pewno zostanie przypisana z zewnątrz (przez rodzica), zanim będzie użyta, mimo że nie ma tu wartości domyślnej" — bez tego kompilator zgłosiłby ostrzeżenie o potencjalnie niezainicjalizowanym polu.
constructor(private koszykService: KoszykService) {} to Dependency Injection w praktyce — Angular SAM tworzy (albo używa już istniejącej) instancji KoszykService i przekazuje ją do konstruktora, dokładnie tak samo jak ASP.NET Core sam dostarczał DbContext czy SignInManager do konstruktorów stron w poprzednich lekcjach o backendzie. Słowo private przed parametrem konstruktora to skrót TypeScript automatycznie tworzący pole klasy o tej samej nazwie — nie trzeba osobno deklarować private koszykService: KoszykService; nad konstruktorem.
providedIn: 'root' oznacza, że Angular tworzy TYLKO JEDNĄ instancję KoszykService dla całej aplikacji (singleton) — dzięki temu i ListaProduktowComponent, i (np.) komponent nagłówka odwołują się do TEGO SAMEGO obiektu, więc zmiana licznika w jednym miejscu jest widoczna wszędzie indziej, gdzie serwis jest wstrzyknięty.
Ćwiczenie samodzielne
Rozbuduj komponenty z lekcji 8 o komunikację rodzic-potomny: dodaj przycisk w komponencie potomnym (karta produktu) z @Output(), i obsłuż zdarzenie w rodzicu, wypisując w konsoli przeglądarki (console.log), który produkt został kliknięty.
Zadania do pracy własnej
Dodaj pole tekstowe z
[(ngModel)]pozwalające wpisać imię, i wyświetl w tym samym komponencie powitanieWitaj, {{ imie }}!aktualizujące się na bieżąco w miarę pisania.Zbuduj serwis
LicznikOdwiedzinServicez metodązwiekszIWyswietl(): numberzwiększającą i zwracającą licznik, i wykorzystaj go w DWÓCH różnych, niepowiązanych komponentach, sprawdzając, że oba "widzą" TĘ SAMĄ, rosnącą wartość (dowód, że serwis jest współdzielonym singletonem).Zbuduj pełny mechanizm koszyka: serwis
KoszykServiceprzechowujący TABLICĘ dodanych produktów (nie tylko licznik), komponent listy produktów z możliwością dodawania przez@Output()z kart potomnych, oraz OSOBNY komponent "Podsumowanie koszyka" (niepowiązany bezpośrednio z listą) wyświetlający wszystkie dodane produkty i sumę cen, pobierane z tego samego serwisu.
Typowe błędy
Próba bezpośredniego zmodyfikowania @Input() wewnątrz komponentu potomnego — dane przekazane przez @Input() powinny być traktowane jako "tylko do odczytu" z perspektywy komponentu potomnego; jeśli trzeba je zmienić, właściwą drogą jest zgłoszenie zdarzenia @Output() i pozostawienie decyzji rodzicowi.
Tworzenie NOWEJ instancji serwisu ręcznie (new KoszykService()) zamiast wstrzyknięcia przez konstruktor — to niweczy sens providedIn: 'root', bo każdy komponent dostałby WŁASNĄ, niezależną kopię danych zamiast współdzielonej.
Zapominanie o zaimportowaniu komponentu potomnego w tablicy imports komponentu rodzica (standalone components) — bez tego Angular nie rozpozna znacznika <app-karta-produktu> w szablonie rodzica.
Nawiązanie do egzaminu zawodowego
To realizacja INF.04.7.3 (obsługa zdarzeń, kontrolek i przepływu danych w aplikacji webowej) — mechanizm @Input()/@Output()/serwisy jest fundamentem architektury każdej większej aplikacji Angular, w tym wszystkich czterech projektów końcowych tego działu. Kolejna, ostatnia lekcja o Angularze dodaje formularze reaktywne i routing między "stronami" aplikacji SPA.