Dataniedziela, 9 sierpnia 2026 Czas06:41:28
← Aplikacje webowe

Lekcja 10. Angular II — data binding, zdarzenia własne i serwisy

Trudny / egzaminacyjny

Po 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

  1. Dodaj pole tekstowe z [(ngModel)] pozwalające wpisać imię, i wyświetl w tym samym komponencie powitanie Witaj, {{ imie }}! aktualizujące się na bieżąco w miarę pisania.

  2. Zbuduj serwis LicznikOdwiedzinService z metodą zwiekszIWyswietl(): number zwię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).

  3. Zbuduj pełny mechanizm koszyka: serwis KoszykService przechowują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.