Kurs Angular · Moduł 2: Komponenty

Control Flow - nowa składnia w Angularze 17+

6 min czytania ·
W tej lekcji6

Do tej pory szablon pokazywał wszystko zawsze i w tej samej postaci. Prawdziwe dojo tak nie działa. Wojownik bez punktów życia powinien zobaczyć komunikat o porażce zamiast przycisku ataku. Lista uczniów bywa pusta i wtedy zamiast pustej ramki warto napisać, że w dojo nikogo nie ma. Odznaka przy imieniu zależy od stopnia: inna dla mistrza, inna dla początkującego.

Potrzebujesz więc warunków i pętli w samym szablonie. Angular 17 wprowadził do tego wbudowaną składnię control flow: bloki zaczynające się od znaku @, zapisywane podobnie jak instrukcje w TypeScripcie. Zastąpiły one starsze dyrektywy strukturalne *ngIf, *ngFor i *ngSwitch.

Dlaczego warto przejść na nową składnię? Bloki czyta się jak zwykły kod, nie wymagają żadnych importów i szybciej odświeżają listy. Stare dyrektywy nadal działają, ale w komponencie standalone trzeba je importować (NgIf, NgFor albo cały CommonModule), a od Angulara 20 są oznaczone jako przestarzałe. W nowym kodzie pisz więc bloki @.

@if - warunek przy bramie

Blok @if wyświetla fragment szablonu tylko wtedy, gdy warunek jest spełniony. Warunek zapisujesz w nawiasach okrągłych, a treść w klamrach. Po nim możesz dodać dowolnie wiele gałęzi @else if i jedną gałąź @else:

@if (health > 50) {
  <p>Wojownik w pełni sił</p>
} @else if (health > 0) {
  <p>Wojownik ranny</p>
} @else {
  <p>Wojownik pokonany</p>
}

Angular sprawdza warunki od góry do dołu i wyświetla pierwszą gałąź, której warunek jest prawdziwy. Przy health równym 30 pierwszy warunek jest fałszywy, a drugi prawdziwy, więc pojawi się "Wojownik ranny". Elementy pozostałych gałęzi w ogóle nie trafiają na stronę: nie są ukryte, po prostu nie istnieją. Gdy health się zmieni, Angular sam przełączy gałąź. Gałąź @else jest opcjonalna, więc samo @if (isLoggedIn) { ... } też jest poprawnym blokiem.

W starszych projektach ten sam rodzaj warunku zobaczysz zapisany dyrektywą:

<p *ngIf="isLoggedIn; else guestTemplate">Witaj!</p>
<ng-template #guestTemplate>
  <p>Zaloguj się</p>
</ng-template>

Efekt jest ten sam, ale zapis jest mniej czytelny: gałąź else musi siedzieć w osobnym elemencie <ng-template> z nazwą, a komponent musi importować NgIf. Warto rozpoznawać tę składnię w cudzym kodzie, ale we własnym używaj @if.

@for - przegląd oddziału

Blok @for powtarza fragment szablonu dla każdego elementu kolekcji. Zapis warrior of warriors znaczy: dla każdego elementu tablicy warriors wyświetl treść bloku, a bieżący element nazwij warrior:

<ul>
  @for (warrior of warriors; track warrior.id) {
    <li>{{ warrior.name }}</li>
  } @empty {
    <li>Brak wojowników w dojo</li>
  }
</ul>

Część track warrior.id jest obowiązkowa. Mówi Angularowi, po czym rozpoznać każdy element, gdy lista się zmienia. Jeśli do oddziału dołączy nowy wojownik albo ktoś odejdzie, Angular porówna identyfikatory i przebuduje tylko te elementy, które naprawdę się zmieniły, zamiast rysować całą listę od nowa. Bez track szablon się nie skompiluje, a kompilator odpowie komunikatem @for loop must have a "track" expression.

Najlepiej śledzić elementy po unikalnym identyfikatorze, takim jak id. Gdy elementy nie mają identyfikatora, a lista nie zmienia kolejności, możesz napisać track $index, czyli śledzić je po pozycji. Blok @empty jest opcjonalny: Angular wyświetli go wtedy, gdy kolekcja jest pusta, więc uczeń zobaczy komunikat zamiast pustej listy.

Zmienne kontekstowe pętli

Wewnątrz @for Angular udostępnia kilka gotowych zmiennych, które opisują bieżący obrót pętli. $index to numer elementu liczony od zera, $count to liczba wszystkich elementów, $first i $last mówią, czy element jest pierwszy lub ostatni, a $even i $odd rozróżniają pozycje parzyste i nieparzyste. Możesz nadać im własne nazwy słowem let:

@for (warrior of warriors; track warrior.id; let i = $index, last = $last) {
  <p [class.last]="last">{{ i + 1 }}. {{ warrior.name }}</p>
}

Tu i przechowuje numer z $index, więc lista zaczyna się od "1.", a nie od "0.". Zmienna last dodaje klasę CSS last tylko ostatniemu wojownikowi, na przykład po to, żeby nie rysować pod nim linii oddzielającej. Zmiennych kontekstowych możesz też używać bez aliasu, pisząc wprost {{ $index + 1 }}.

@switch - wybór odznaki

Gdy jedno wyrażenie może przyjąć kilka znanych wartości, zamiast łańcucha @else if użyj bloku @switch. Każdy @case porównuje wynik wyrażenia z podaną wartością:

@switch (rank) {
  @case ('master') {
    <span class="badge">Mistrz</span>
  }
  @case ('advanced') {
    <span class="badge">Zaawansowany</span>
  }
  @default {
    <span class="badge">Początkujący</span>
  }
}

Angular porównuje wartości operatorem ===, więc napis '10' nie pasuje do liczby 10. Wyświetla się tylko pasujący @case. Inaczej niż w instrukcji switch z JavaScriptu nie ma tu przechodzenia do kolejnych przypadków, więc nie piszesz żadnego break. Blok @default jest opcjonalny i działa tylko wtedy, gdy żaden @case nie pasuje. Może stać w dowolnym miejscu bloku, ale zwyczajowo piszemy go na końcu, bo tak czyta się najłatwiej.

@let - zmienna w szablonie

Od Angulara 18.1 możesz w szablonie zadeklarować zmienną blokiem @let. Przydaje się, gdy to samo wyrażenie powtarza się kilka razy albo jest na tyle długie, że warto nadać mu nazwę:

@let fullName = firstName + ' ' + lastName;
@let discountedPrice = price * 0.9;

<h2>{{ fullName }}</h2>
<p>Cena po rabacie: {{ discountedPrice | currency }}</p>

Zapis zawsze kończy się średnikiem: @let, nazwa, znak równości, wyrażenie i ;. Zmienna jest tylko do odczytu, więc nie przypiszesz jej nowej wartości, ale Angular sam przeliczy ją, gdy zmieni się firstName, lastName albo price. Znak | przepuszcza wartość przez pipe, czyli funkcję formatującą: currency zamienia liczbę na zapis kwoty. Taki pipe trzeba dodać do imports komponentu jako CurrencyPipe z pakietu @angular/common.

Małpa i klamry jako zwykły tekst

Nowa składnia ma jeden skutek uboczny. Skoro @ otwiera blok, a } go zamyka, nie możesz już wpisać tych znaków w tekst szablonu wprost. Adres ango@dojo.jp w akapicie zakończy się błędem kompilacji, bo Angular uzna @dojo za początek bloku. Takie znaki zapisuje się encjami HTML:

<p>Napisz do mistrza: ango&#64;dojo.jp</p>
<h3>Blok &#64;if</h3>
<p>Klamra zamykająca: &#125;</p>

Encja &#64; wyświetla się jako @, &#123; jako {, a &#125; jako }. Podwójne klamry to szczególny przypadek, bo Angular rozkodowuje encje jeszcze przed interpolacją. Jeśli chcesz pokazać napis {{ }} dosłownie, dodaj do elementu atrybut ngNonBindable, który wyłącza wiązania w jego treści.

Sala treningowa poniżej to ten sam komponent co w pierwszej lekcji. Znajdź w nim sekcję Control Flow Demo: zobaczysz w niej @if z gałęziami @else if i @else, @for z track i @empty oraz @switch z @default. Nagłówki tych sekcji używają encji &#64;, a nagłówek interpolacji atrybutu ngNonBindable.

Kod do tej lekcji: src/app/app.component.ts
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

interface Samurai {
  id: number;
  name: string;
  clan: string;
  level: number;
  health: number;
}

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [FormsModule],
  template: `
    <div class="dojo-training">
      <header>
        <h1>Sala treningowa Dojo</h1>
        <p>Moduł 2: Komponenty Angular</p>
      </header>

      <section class="training-section">
        <h2>Data Binding Demo</h2>

        <!-- Interpolacja -->
        <div class="demo-box">
          <h3 ngNonBindable>Interpolacja {{ }}</h3>
          <p>Imię: {{ samurai.name }}</p>
          <p>Klan: {{ samurai.clan }}</p>
        </div>

        <!-- Property Binding -->
        <div class="demo-box">
          <h3>Property Binding [ ]</h3>
          <progress [value]="samurai.health" max="100"></progress>
          <p>Zdrowie: {{ samurai.health }}%</p>
        </div>

        <!-- Event Binding -->
        <div class="demo-box">
          <h3>Event Binding ( )</h3>
          <button (click)="attack()">Atakuj!</button>
          <button (click)="heal()">Lecz</button>
        </div>

        <!-- Two-way Binding -->
        <div class="demo-box">
          <h3>Two-way Binding [( )]</h3>
          <input [(ngModel)]="samurai.name" placeholder="Imię samuraja">
          <p>Aktualne imię: {{ samurai.name }}</p>
        </div>
      </section>

      <section class="control-flow-section">
        <h2>Control Flow Demo</h2>

        <!-- @if -->
        <div class="demo-box">
          <h3>&#64;if / &#64;else</h3>
          @if (samurai.health > 50) {
            <p class="status good">Samuraj w dobrej formie!</p>
          } @else if (samurai.health > 20) {
            <p class="status warning">Samuraj osłabiony</p>
          } @else {
            <p class="status danger">Samuraj w krytycznym stanie!</p>
          }
        </div>

        <!-- @for -->
        <div class="demo-box">
          <h3>&#64;for</h3>
          <ul>
            @for (s of samuraiList; track s.id) {
              <li [class.selected]="s.id === selectedId" (click)="selectSamurai(s.id)">
                {{ s.name }} (Lvl {{ s.level }})
              </li>
            } @empty {
              <li>Brak samurajów</li>
            }
          </ul>
        </div>

        <!-- @switch -->
        <div class="demo-box">
          <h3>&#64;switch</h3>
          @switch (getRank(samurai.level)) {
            @case ('master') {
              <span class="badge master">Mistrz</span>
            }
            @case ('advanced') {
              <span class="badge advanced">Zaawansowany</span>
            }
            @default {
              <span class="badge beginner">Początkujący</span>
            }
          }
        </div>
      </section>
    </div>
  `,
  styles: [`
    .dojo-training {
      font-family: 'Segoe UI', sans-serif;
      max-width: 900px;
      margin: 0 auto;
      padding: 20px;
      background: #1a1a2e;
      color: #eee;
    }

    header {
      text-align: center;
      border-bottom: 2px solid #c9a227;
      padding-bottom: 20px;
      margin-bottom: 20px;
    }

    h1 { color: #c9a227; }
    h2 { color: #c9a227; margin-top: 30px; }
    h3 { color: #888; font-size: 0.9rem; margin-bottom: 10px; }

    .demo-box {
      background: rgba(255,255,255,0.05);
      border: 1px solid #333;
      border-radius: 8px;
      padding: 15px;
      margin-bottom: 15px;
    }

    button {
      background: #c9a227;
      color: #1a1a2e;
      border: none;
      padding: 10px 20px;
      margin: 5px;
      border-radius: 4px;
      cursor: pointer;
    }

    button:hover { opacity: 0.9; }

    input {
      padding: 10px;
      border: 1px solid #c9a227;
      border-radius: 4px;
      background: #16213e;
      color: #eee;
      width: 200px;
    }

    progress {
      width: 100%;
      height: 20px;
    }

    .status { padding: 10px; border-radius: 4px; }
    .status.good { background: #2d6a4f; }
    .status.warning { background: #b45309; }
    .status.danger { background: #991b1b; }

    ul { list-style: none; padding: 0; }
    li {
      padding: 10px;
      margin: 5px 0;
      background: #16213e;
      border-radius: 4px;
      cursor: pointer;
    }
    li:hover { background: #1e3a5f; }
    li.selected { border: 2px solid #c9a227; }

    .badge {
      padding: 5px 15px;
      border-radius: 20px;
      font-weight: bold;
    }
    .badge.master { background: #c9a227; color: #1a1a2e; }
    .badge.advanced { background: #7c3aed; }
    .badge.beginner { background: #4a5568; }
  `]
})
export class AppComponent {
  samurai: Samurai = {
    id: 1,
    name: 'Musashi',
    clan: 'Niten Ichi-ryū',
    level: 8,
    health: 100
  };

  samuraiList: Samurai[] = [
    { id: 1, name: 'Musashi', clan: 'Niten', level: 10, health: 100 },
    { id: 2, name: 'Kojiro', clan: 'Ganryū', level: 9, health: 85 },
    { id: 3, name: 'Jubei', clan: 'Yagyu', level: 7, health: 90 }
  ];

  selectedId = 1;

  attack(): void {
    this.samurai.health = Math.max(0, this.samurai.health - 15);
  }

  heal(): void {
    this.samurai.health = Math.min(100, this.samurai.health + 20);
  }

  selectSamurai(id: number): void {
    this.selectedId = id;
  }

  getRank(level: number): string {
    if (level >= 9) return 'master';
    if (level >= 5) return 'advanced';
    return 'beginner';
  }
}

Pamiętaj: @if pilnuje bramy, @for prowadzi przegląd oddziału i zawsze potrzebuje track, @switch rozdaje odznaki, a @let nadaje nazwę temu, co powtarzasz.

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. Jaka jest nowa składnia warunkowego renderowania w Angularze 17+?

  2. 2. Co jest wymagane w nowym bloku @for, aby Angular mógł optymalnie śledzić elementy listy?

  3. 3. Który blok dodajemy do @for, aby wyświetlić treść gdy kolekcja jest pusta?

Zadania praktyczne w grze

  • Klikanie w kolejności

    Ułóż tokeny, aby otworzyć blok warunkowy: @if (isLoggedIn) {

  • Edytor kodu

    Uzupełnij szablon używając @if do warunkowego wyświetlania i @for do iteracji po wojownikach.

  • Układanie w pionie

    Uporządkuj linie bloku @switch w poprawnej kolejności (od góry do dołu):

  • Układanie w poziomie

    Ułóż tokeny jednej linii, aby zadeklarować zmienną w szablonie: @let fullName = firstName;

Przydatne artykuły