Kurs Angular · Moduł 4: Routing

Zaawansowany routing - meldunki ze szlaku

14 min czytania ·
W tej lekcji6

Szlak masz już przetarty. Trasy są rozpisane, parametry odczytane ze zwoju, przy bramach stoją strażnicy, a dojo ma swoje pokoje w pokojach. A mimo to podróż bywa niewygodna - i to na kilka sposobów naraz.

Klikasz w nawigacji "Zbrojownia" i przez pół sekundy nie dzieje się nic. Ekran stoi, bo przeglądarka dociąga paczkę z kodem zbrojowni, a resolver czeka na dane. Użytkownik nie wie, czy kliknięcie w ogóle doszło, więc klika drugi raz. Do tego masz otwartych dwanaście kart przeglądarki i każda nazywa się identycznie, bo tytuł strony ustawiłeś raz, w pliku index.html. A na koniec rada wojenna: panel z rozmową ma siedzieć z boku i nie gasnąć, kiedy główny widok wędruje z dojo do zbrojowni i z powrotem.

W Akademii żaden przemarsz nie odbywa się w ciszy. Kiedy kolumna rusza, posterunek przy wyjściu bije w bęben; kiedy dociera na miejsce, brama docelowa odpowiada uderzeniem. Zwiadowcy wyruszają wcześniej niż główny oddział, żeby zapasy czekały już na postoju. Nad każdą bramą wisi kartusz z nazwą miejsca, a obok bramy głównej bywa jeszcze furtka, którą wchodzi się na naradę, nie opuszczając dziedzińca. W tej lekcji dołożysz swojemu szlakowi dokładnie te cztery rzeczy.

Bębny na posterunkach - zdarzenia routera

Zacznijmy od ciszy podczas przejścia. Serwis Router, który w lekcji o parametrach ścieżki służył Ci do wydawania rozkazu wymarszu, ma też stronę bierną: pole events. To strumień - taki sam rodzaj strumienia, jaki widziałeś przy odczycie parametrów, więc zapisujesz się na niego metodą subscribe(), a Angular woła Twoją funkcję przy każdym nowym powiadomieniu. Różnica jest taka, że tym razem nie dostajesz mapy parametrów, tylko obiekt opisujący etap podróży, a każdy etap ma własną klasę.

Dwie klasy wystarczą, żeby zbudować pasek ładowania. NavigationStart Angular wysyła w chwili, gdy nawigacja się zaczyna - zaraz po kliknięciu linku albo po wywołaniu navigate(). NavigationEnd przychodzi na końcu, gdy trasa została dopasowana, strażnicy przepuścili, dane się pobrały, a komponent trafił do outletu. Zapamiętaj te nazwy co do litery: w pakiecie @angular/router nie ma niczego, co nazywałoby się RouteStart ani NavigationBegin. Sprawdziłem to kompilatorem - import takiej nazwy kończy się błędem o braku takiego eksportu i aplikacja nawet się nie zbuduje.

Żeby rozpoznać, który obiekt właśnie przyszedł, użyjemy operatora instanceof. To zwykły operator JavaScriptu: pyta, czy obiekt po lewej stronie jest egzemplarzem klasy po prawej, i zwraca prawdę albo fałsz. Subskrypcję zakładam w konstruktorze, a nie w ngOnInit, bo pierwsza nawigacja rusza natychmiast po starcie aplikacji i chcę usłyszeć także jej bęben.

import { Component, inject } from '@angular/core';
import { Router, NavigationStart, NavigationEnd } from '@angular/router';

@Component({
  selector: 'app-root',
  standalone: true,
  template: '<p>Akademia</p>'
})
export class AppComponent {
  private router = inject(Router);
  isLoading = false;

  constructor() {
    this.router.events.subscribe(event => {
      if (event instanceof NavigationStart) {
        this.isLoading = true;
      }
      if (event instanceof NavigationEnd) {
        this.isLoading = false;
      }
    });
  }
}

Pole isLoading jest teraz zawsze zgodne ze stanem podróży: prawda w trakcie, fałsz po wszystkim. Czytaj to wnętrze subskrypcji linia po linii, bo taka właśnie kolejność stoi w pliku: najpierw linia otwierająca subskrypcję z funkcją przyjmującą zdarzenie, potem warunek zapalający pasek, potem warunek gaszący, a na końcu klamra zamykająca całe wywołanie. Zwróć uwagę, czego ta zmiana nie dotknęła - konfiguracja tras wygląda tak samo, komponenty docelowe nic o pasku nie wiedzą, strażnicy działają bez zmian. Subskrypcja tylko przysłuchuje się meldunkom i niczego nie blokuje: gdybyś ją usunął, nawigacja przebiegłaby identycznie, po prostu bez informacji dla użytkownika.

Między bębnem a odpowiedzią bramy Angular wysyła jeszcze kilka meldunków i warto wiedzieć, że tam są. Uruchomiłem router i spisałem strumień: po NavigationStart idzie RoutesRecognized (router wie już, która trasa pasuje), potem GuardsCheckStart i GuardsCheckEnd - to Twoi strażnicy z lekcji o guardach, a pomiędzy nimi mieszczą się jeszcze meldunki o aktywacji komponentów - dalej ResolveStart i ResolveEnd, czyli praca resolvera z poprzedniej lekcji, a dopiero na samym końcu NavigationEnd. Cały cykl trasy, który poznawałeś kawałkami przez ten moduł, przepływa przez to jedno pole.

Sam znacznik logiczny nie wystarczy - użytkownik ma coś zobaczyć. W szablonie komponentu głównego pokazuję pasek warunkowo, składnią @if z lekcji o komponentach, a pod nim zostawiam główny outlet dokładnie tam, gdzie stał.

@if (isLoading) {
  <div class="loader">Goniec w drodze...</div>
}

<router-outlet></router-outlet>

Od tej chwili każde przejście między trasami zapala napis i gasi go, gdy komponent docelowy jest już na ekranie. Nie zmieniło się przy tym miejsce renderowania: <router-outlet> to wciąż ta sama brama, w której pojawia się komponent aktywnej trasy, a pasek jest tylko rusztowaniem obok niej. Pole isLoading jest zwykłym polem klasy i Angular sam odświeży widok po jego zmianie - w module o sygnałach dymnych zamienisz je na sygnał i zobaczysz, dlaczego to lepszy pomysł.

Jest w tym kodzie pułapka i lepiej wpaść w nią teraz niż na produkcji. Sprawdziłem to na działającym routerze: kiedy strażnik zawróci użytkownika spod bramy, Angular nie wysyła NavigationEnd. Zamiast niego przychodzi NavigationCancel, a adres w pasku zostaje ten sam. Twój warunek nigdy się nie wykona, isLoading zostaje na wartości prawda i pasek ładowania wisi na ekranie do końca sesji. Tak samo kończy się NavigationError, czyli meldunek o błędzie w trakcie podróży. Dlatego pasek gaś na wszystkich trzech meldunkach naraz.

// ten sam konstruktor, dwie nazwy dolozone do importu z @angular/router
this.router.events.subscribe(event => {
  if (event instanceof NavigationStart) {
    this.isLoading = true;
  }
  if (event instanceof NavigationEnd
    || event instanceof NavigationCancel
    || event instanceof NavigationError) {
    this.isLoading = false;
  }
});

Teraz każda podróż kończy się zgaszeniem paska niezależnie od tego, czy dotarła do celu, została zawrócona przez strażnika, czy przewróciła się na błędzie. Warunek zapalający pasek nie zmienił się ani o znak - zdarzenie rozpoczęcia jest nadal jedno i nadal nazywa się NavigationStart. Polecam Ci tę wersję jako domyślną: wariant z samym NavigationEnd wygląda czyściej w lekcji, ale w prawdziwej aplikacji ze strażnikami przy bramach zawiesza pasek przy pierwszym odmówionym wejściu.

Zwiadowcy przed kolumną - wstępne ładowanie tras

Druga niewygoda to czekanie na paczkę z kodem. W module o klanach ustawiłeś leniwe ładowanie: trasa z loadComponent albo loadChildren nie trafia do pierwszej paczki aplikacji, tylko dojeżdża osobno, gdy użytkownik naprawdę tam wejdzie. Start jest dzięki temu błyskawiczny, ale koszt nie znika - przenosi się na pierwsze kliknięcie, a przy słabym łączu to kilka sekund wpatrywania się w pasek, który przed chwilą zbudowałeś.

Da się zjeść ciastko i mieć ciastko: niech paczki dojadą w tle, kiedy aplikacja już wstała i nic się nie dzieje. Tym zajmuje się strategia wstępnego ładowania. Angular ma dwie gotowe: NoPreloading, czyli nie pobieraj niczego z góry - i to jest zachowanie domyślne - oraz PreloadAllModules, czyli pobierz w tle wszystkie leniwe trasy. Strategię podaje się routerowi przy rejestracji, funkcją withPreloading(). To jedna z tak zwanych cech routera, czyli dodatkowych ustawień, które provideRouter() przyjmuje jako kolejne argumenty po tablicy tras. Czytaj to wywołanie jak jedno zdanie: nazwa withPreloading, nawias otwierający, nazwa strategii w środku, nawias zamykający.

// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withPreloading(PreloadAllModules))
  ]
};

Zwróć uwagę, że do withPreloading() wchodzi sama nazwa klasy, bez new i bez nawiasów - egzemplarz strategii Angular utworzy sobie sam. I teraz najważniejsze, czyli co się tutaj nie zmieniło: tablica tras jest nietknięta, wpisy z loadComponent dalej są leniwe, a pierwsza paczka aplikacji nie urosła ani o bajt. Zmienił się wyłącznie moment pobrania.

Sprawdziłem to na uruchomionym routerze z jedną leniwą trasą. Przy domyślnej strategii kod tej trasy nie został pobrany ani razu, dopóki na nią nie wszedłem. Po włączeniu PreloadAllModules ten sam kod pobrał się sam, tuż po pierwszym NavigationEnd, mimo że nikt na tę trasę nie wchodził - a późniejsze wejście nie pobrało go po raz drugi, bo czekał już w pamięci. Stąd bierze się cała korzyść: przejście, które wcześniej trwało tyle, ile pobranie paczki, staje się natychmiastowe.

Kiedy tego nie robić? Gdy aplikacja ma ciężki panel administracyjny, którego zwykły użytkownik nigdy nie otworzy, PreloadAllModules ściągnie go każdemu i zmarnuje transfer. Dla aplikacji wielkości naszej Akademii polecam ją bez wahania, bo kosztuje jedno wywołanie; przy większych projektach pisze się własną strategię, opartą na interfejsie PreloadingStrategy, i pobiera z góry tylko wybrane trasy.

Furtka obok bramy - nazwane outlety

Trzecia niewygoda to rada wojenna. Chcesz, żeby obok głównego widoku siedział boczny panel z własną trasą, żeby zmiana głównego widoku go nie gasiła, a odświeżenie strony nie zamykało. To znaczy, że stan panelu musi być zapisany w adresie, tak samo jak stan widoku głównego.

Do tej pory każdy Twój szablon miał jeden <router-outlet>, choć nikt nie powiedział, że musi być jeden. Ten domyślny outlet ma nazwę - primary - tylko nigdy jej nie wpisujesz. Obok niego wolno postawić drugi, z atrybutem name, a we wpisie trasy dopisać pole outlet z tą samą nazwą. Trasa mówi wtedy: renderuj mnie w tej furtce, nie w bramie głównej. Zacznijmy od tras, bo to tam zapada decyzja.

// app.routes.ts
import { Routes } from '@angular/router';
import { DojoComponent } from './dojo/dojo.component';
import { ChatComponent } from './chat/chat.component';

export const routes: Routes = [
  { path: 'dojo', component: DojoComponent },
  { path: 'chat', component: ChatComponent, outlet: 'sidebar' }
];

Wpis z outlet: 'sidebar' nie jest dzieckiem żadnej trasy - to osobny szlak, biegnący równolegle do głównego. Nie zmienia się przez niego nic w dopasowaniu adresu /dojo: gdy tam wejdziesz, router nadal wybierze DojoComponent i wstawi go do bramy głównej, a furtka zostanie pusta, dopóki ktoś jej nie otworzy. Teraz szablon. Główny outlet zostaje bez zmian, obok dokładam drugi i nadaję mu dokładnie tę nazwę, którą wpisałem w trasie.

<main>
  <router-outlet></router-outlet>
</main>

<aside>
  <router-outlet name="sidebar"></router-outlet>
</aside>

Dwa znaczniki, jedna różnica: atrybut name z wartością w cudzysłowie. Ten bez atrybutu to primary, ten z atrybutem obsługuje trasy oznaczone jako sidebar. Kolejność zapisu jest zawsze taka sama i warto ją sobie utrwalić: otwarcie znacznika, nazwa atrybutu ze znakiem równości, wartość w cudzysłowie, a na końcu część zamykająca. Sprawdziłem, co się dzieje, gdy tej ostatniej zabraknie, bo to nie jest oczywiste: Angular nie zgłasza żadnego błędu, tylko uznaje całą dalszą treść szablonu za zawartość outletu i wstawia komponent trasy dopiero za nią, przez co elementy na ekranie ustawiają się w odwrotnej kolejności. Skrócony zapis z ukośnikiem też zadziała, ale pełna para znaczników jest w Angularze zapisem podstawowym i przy niej zostanę.

Zostaje link, który tę furtkę otwiera. Zamiast zwykłej ścieżki podajesz do routerLink obiekt z polem outlets, a w nim mapę: nazwa furtki oraz tablica segmentów, które mają się w niej pojawić. Pamiętaj, żeby dyrektywa RouterLink znalazła się w tablicy imports komponentu, tak samo jak przy zwykłych linkach.

<a [routerLink]="[{ outlets: { sidebar: ['chat'] } }]">Zwolaj narade</a>

Sprawdziłem, jak po tym kliknięciu wygląda adres, bo składnia jest nieoczywista: stojąc na /dojo dostajesz /dojo(sidebar:chat). Nazwana furtka trafia do nawiasu za główną ścieżką i dzięki temu przeżywa odświeżenie strony oraz daje się wysłać komuś linkiem. Najważniejsze jest to, czego nie widać: brama główna nawet nie drgnęła - DojoComponent jak wisiał na ekranie, tak wisi, doszła wyłącznie zawartość panelu. Żeby furtkę zamknąć, nawigujesz do niej z wartością null zamiast tablicy, czyli { outlets: { sidebar: null } }, a adres wraca do czystego /dojo.

Kartusz nad bramą - tytuł strony

Czwarta niewygoda jest najprostsza do naprawienia. Tytuł strony - ten widoczny na karcie przeglądarki, w historii i w czytniku ekranu - w zwykłym projekcie jest ustawiony raz w pliku index.html i nigdy się nie zmienia. Router może go pilnować za Ciebie: wpis trasy przyjmuje pole title, a Angular po zakończonej nawigacji wpisuje tę wartość do tytułu dokumentu.

Trzy inne nazwy przychodzą w tym miejscu do głowy i wszystkie trzy są błędne. pageTitle to zwyczaj z innych bibliotek, w Angularze nieznany. head kojarzy się z sekcją dokumentu HTML, ale wpis trasy o czymś takim nie słyszał. name brzmi najnaturalniej ze wszystkich i właśnie dlatego jest groźne, bo trasa pola o tej nazwie nie ma. Skompilowałem każdą z tych trzech prób i każda kończy się tym samym komunikatem TypeScriptu: taka właściwość nie istnieje w typie Route. Właściwe pole jest jedno i nazywa się title.

// app.routes.ts, importy jak wyzej
export const routes: Routes = [
  { path: 'dojo', component: DojoComponent, title: 'Dojo - Akademia Samurajow' },
  { path: 'armory', component: ArmoryComponent, title: 'Zbrojownia - Akademia Samurajow' }
];

Po wejściu na /dojo karta przeglądarki nazywa się teraz "Dojo - Akademia Samurajow" - uruchomiłem to i tytuł dokumentu faktycznie się podmienia przy każdym przejściu. Co się przy tym nie zmienia: sam widok. Pole title niczego nie renderuje, nie dokłada nagłówka do szablonu, dotyka wyłącznie paska karty. Nagłówek na stronie nadal musisz napisać sam.

Statyczny napis wystarczy dla ekranów, których jest po jednym. Gorzej z profilem wojownika: pod trasą samurai/:id kryje się tyle stron, ilu masz samurajów, a tytuł "Profil samuraja" na każdej z nich niczego nie rozróżnia. Dlatego title przyjmuje nie tylko tekst, ale i funkcję. Angular wywoła ją przy każdej nawigacji i przekaże migawkę trasy - ten sam obiekt, z którego w lekcji o parametrach czytałeś paramMap. Napis, który funkcja zwróci, stanie się tytułem.

{
  path: 'samurai/:id',
  component: SamuraiDetailComponent,
  title: (route) => 'Samuraj #' + (route.paramMap.get('id') ?? '')
}

Wejście na /samurai/7 daje tytuł "Samuraj #7" - to też uruchomiłem i tak właśnie jest. Operator ?? znasz z odczytu parametrów: chroni przed sytuacją, w której parametru zabrakło i get() zwróciło pustą wartość. Nie zmienił się natomiast sposób odczytu parametru - ta sama paramMap, ta sama metoda get(), tylko wywołana w konfiguracji trasy zamiast w komponencie. Sklejanie plusem możesz zastąpić szablonem tekstowym z odwróconymi apostrofami, wynik będzie identyczny; przy tak krótkim tytule polecam plus, bo nie każe czytelnikowi wypatrywać klamerek.

Rozkaz z dopiskiem

Zostało domknąć jedną rzecz, którą właśnie zobaczyłeś od strony adresu. Skoro adres potrafi nieść stan bocznej furtki, to tym bardziej poniesie drobiazg w rodzaju "otwórz profil od razu na zakładce z umiejętnościami". Takie dopiski to parametry zapytania, a przy nawigacji z kodu wskazujesz je polem queryParams w drugim argumencie metody navigate(). Pierwszy argument zostaje bez zmian: tablica segmentów, w której po nazwie trasy podajesz numer wojownika.

import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';

@Component({
  selector: 'app-samurai-list',
  standalone: true,
  template: '<button (click)="goToSamurai(5)">Zobacz wojownika</button>'
})
export class SamuraiListComponent {
  private router = inject(Router);

  goToSamurai(id: number): void {
    this.router.navigate(['/samurai', id], {
      queryParams: { tab: 'skills' }
    });
  }
}

Kliknięcie przycisku przenosi na adres /samurai/5?tab=skills - segment z numerem po ukośniku, dopisek po znaku zapytania. Trasa dopasowuje się dokładnie tak samo jak bez dopisku, bo parametry zapytania w dopasowaniu nie uczestniczą, a komponent profilu odczyta zakładkę wtedy, gdy będzie mu potrzebna. Jest tu jeszcze jeden szczegół, który dobrze zrozumieć po lekcji o strażnikach: navigate() zwraca obietnicę, a w niej wartość logiczną. Prawda oznacza, że nawigacja doszła do skutku, fałsz - że została zawrócona. Sprawdziłem oba warianty i różnią się bardziej, niż mogłoby się wydawać. Strażnik zwracający false daje fałsz, adres w pasku zostaje stary, a w strumieniu ląduje samo NavigationCancel. Strażnik zwracający UrlTree, czyli wariant polecany w lekcji o strażnikach, zachowuje się inaczej: obietnica daje prawdę, bo przekierowanie się powiodło, adres zmienia się na docelowy, a w strumieniu widać najpierw NavigationCancel, a zaraz po nim NavigationEnd dla nowej trasy. Jeśli po nawigacji robisz cokolwiek jeszcze, na przykład czyścisz formularz albo pokazujesz komunikat, warto na tę wartość zerknąć przez await lub then().

Cały szlak w cztery kroki

Na koniec modułu ułóżmy drogę w jedną sekwencję, bo składałeś ją przez pięć lekcji po kawałku. Wszystko zaczyna się od tablicy Routes: to tam wpisujesz pary ścieżki i komponentu, a przy okazji parametry, dzieci, strażników, resolvery i tytuły. Sama tablica niczego nie robi - jest tylko mapą leżącą na stole. Krok drugi to zarejestrowanie tej mapy w aplikacji przez provideRouter(routes) w pliku konfiguracyjnym app.config.ts - dopiero teraz router w ogóle wie, że jakieś szlaki istnieją, i to tu dokładasz cechy w rodzaju withPreloading(). Krok trzeci należy do użytkownika: klika link z routerLink albo wpisuje adres w pasku przeglądarki. Ten sam krok możesz wykonać za niego z kodu, wołając na wstrzykniętym serwisie jedną z dwóch metod: navigate(), która skleja trasę z tablicy segmentów, albo navigateByUrl(), jeśli masz gotowy adres w jednym kawałku tekstu - wtedy cała linia to this, kropka i nazwa serwisu, kropka i nazwa metody, a w nawiasie adres w apostrofach. Krok czwarty jest już po stronie routera: dopasowuje adres do wpisu z tablicy, przepuszcza go przez strażników i resolvery, po czym renderuje komponent w <router-outlet>. Cokolwiek dołożysz później do routingu, dokładasz do jednego z tych czterech kroków.

Pamiętaj: szlak nie musi milczeć - bije w bębny na każdym postoju, wysyła zwiadowców po zapasy, zanim ruszy kolumna, i wypisuje nazwę miejsca na kartuszu nad bramą, a Twoim zadaniem jest tych meldunków słuchać.

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óre zdarzenie routera sygnalizuje rozpoczęcie nawigacji?

  2. 2. Której właściwości trasy używamy, aby ustawić tytuł strony (Title Strategy) w Angularze?

Zadania praktyczne w grze

  • Klikanie w kolejności

    Zbuduj wywołanie włączające preloading wszystkich lazy modułów (przekazywane do provideRouter):

  • Układanie w poziomie

    Ułóż w jednej linii drugi (nazwany) router-outlet o nazwie 'sidebar':

  • Edytor kodu

    Uzupełnij metodę nawigacji programatycznej z parametrem ID i query params.

  • Układanie w pionie

    Uporządkuj linie subskrypcji zdarzeń routera (od góry do dołu, jak w komponencie):

  • Układanie w pionie

    Uporządkuj etapy działania routingu - od konfiguracji do wyświetlenia komponentu:

  • Klikanie w kolejności

    Zbuduj wywołanie nawigacji po pełnym adresie URL:

  • Klikanie w kolejności

    Zbuduj podstawowy znacznik router-outlet (główny punkt renderowania tras):

Przydatne artykuły