Kurs Angular · Moduł 4: Routing
Parametry ścieżki - odczytać rozkaz z adresu
W tej lekcji7
Na poprzednim postoju rozrysowałeś szlaki Tōkaidō, a wśród nich taki wpis: { path: 'samurai/:id', component: SamuraiDetailComponent }. Dwukropek przed id powiedział routerowi, że ten odcinek adresu jest zmienny. Wejście na /samurai/7 otwiera ekran profilu, wejście na /samurai/128 otwiera dokładnie ten sam ekran - i tu zaczyna się kłopot. Komponent, który się pojawia, nie ma bladego pojęcia, o którego wojownika chodzi. Szlak doprowadził go pod właściwe drzwi i zamilkł.
W Akademii wygląda to tak: goniec przybiega z posterunku i wręcza Ci zwój. Na zwoju widnieje pieczęć z numerem wojownika, a na marginesie dopisano drobiazgi - jak posortować listę, od której strony zacząć. Szlak dostarczył zwój pod bramę, ale nikt go za Ciebie nie przeczyta. Twoim zadaniem jest złamać pieczęć. Nauczysz się tu dwóch rzeczy: odczytywać adres, na którym stoisz, oraz wydawać rozkaz wymarszu na inny adres wprost z kodu klasy.
Zwój aktywnej trasy - ActivatedRoute
Opis trasy, która akurat jest otwarta, Angular trzyma w serwisie o nazwie ActivatedRoute. Serwis to gotowy obiekt, który framework tworzy sam i podaje Ci na żądanie - nie tworzysz go operatorem new, tylko o niego prosisz. Prośbę składa się funkcją inject() z pakietu @angular/core: podajesz nazwę serwisu, dostajesz gotowy egzemplarz. Ten gest nazywa się wstrzyknięciem zależności i w module o gildiach rzemieślniczych rozłożysz go na czynniki pierwsze. Tutaj wystarczy Ci jedno zdanie: inject(ActivatedRoute) zwraca zwój opisujący trasę, na której właśnie stoisz.
Zapamiętaj od razu, czego szukać nie warto. Nazwy RouteParams i NavigationService brzmią sensownie, ale w Angularze po prostu nie istnieją - pakiet @angular/router niczego takiego nie eksportuje i kompilator odrzuci taki import. Istnieje za to serwis Router, który poznasz w drugiej połowie lekcji, tylko że on służy do przenoszenia się między trasami, a nie do czytania z bieżącej. Do odczytu parametrów jest dokładnie jeden adres: ActivatedRoute.
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-samurai-detail',
standalone: true,
template: '<h1>Profil samuraja</h1>'
})
export class SamuraiDetailComponent {
private route = inject(ActivatedRoute);
}Na ekranie nie zmieniło się nic - szablon wciąż wyświetla ten sam nagłówek bez numeru. Zmieniło się tylko to, że pole route trzyma teraz zwój i możesz do niego zajrzeć. Zwróć uwagę na dwa szczegóły. Po pierwsze, nie ma tu żadnego konstruktora: inject() wywołujesz wprost przy deklaracji pola, a Angular zdąży już wtedy przygotować serwis. Po drugie, słowo private mówi, że zwój jest sprawą wewnętrzną klasy - szablon do niego nie zagląda i nie powinien.
Migawka trasy - snapshot
Najprostszy odczyt to jedno spojrzenie na zwój przy bramie. ActivatedRoute ma pole snapshot, czyli migawkę: zamrożony stan trasy w chwili, gdy o niego pytasz. W migawce siedzi paramMap - mapa parametrów ścieżki, obiekt typu ParamMap, który udostępnia metodę get(nazwa). Podajesz jej nazwę parametru bez dwukropka, a więc 'id', i dostajesz wartość albo null, gdy takiego parametru w adresie nie ma.
Wartość zawsze wraca jako tekst, nawet dla /samurai/7 - dostaniesz napis '7', nie liczbę. Skoro get() może zwrócić null, trzeba przewidzieć ten przypadek. Robi to operator ??, po polsku zwany operatorem łączenia z wartością pustą: zwraca prawą stronę tylko wtedy, gdy lewa jest null albo undefined. Cały odczyt to łańcuch czterech ogniw w jednej linii: this.route, potem .snapshot, potem .paramMap, a na końcu .get('id'). Umieścimy go w ngOnInit, czyli w tym samym haku cyklu życia, który poznałeś przy rytuałach wojownika.
import { Component, inject, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-samurai-detail',
standalone: true,
template: '<h1>Samuraj #{{ samuraiId }}</h1>'
})
export class SamuraiDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
samuraiId = '';
ngOnInit(): void {
this.samuraiId = this.route.snapshot.paramMap.get('id') ?? '';
}
}Wejście na /samurai/7 pokaże teraz napis "Samuraj #7". Co się przy tym nie zmieniło: plik z trasami. Konfiguracja samurai/:id wygląda tak samo jak wczoraj, a router nadal nic sam do komponentu nie wkłada - to komponent sięga po dane. Uważaj na jedną pułapkę: nazwa :id w konfiguracji trasy i napis 'id' w get('id') muszą się zgadzać co do litery. Literówka nie wywoła żadnego błędu, tylko cicho zwróci null, ?? podstawi pusty tekst, a Ty będziesz patrzył na pusty nagłówek i szukał winy w routerze.
Dopiski na marginesie - parametry zapytania
Parametr ścieżki jest obowiązkowy: adres /samurai bez numeru w ogóle nie trafi w trasę samurai/:id, bo brakuje mu całego odcinka, więc router poszuka innej trasy albo pokaże ekran błędu. Ale zwoje mają też margines, na którym goniec dopisuje rzeczy opcjonalne - sposób sortowania, numer strony, wybrany filtr. W adresie te dopiski żyją po znaku zapytania, jak w /samurai?sort=name, i nazywają się parametrami zapytania. Angular trzyma je w osobnej mapie o nazwie queryParamMap, obsługiwanej tą samą metodą get() i z tą samą zasadą, że brak parametru daje null.
Różnica jest jedna, za to istotna: parametry zapytania nie biorą udziału w dopasowaniu trasy. Adresy /samurai/7 oraz /samurai/7?sort=level trafiają w tę samą trasę i ten sam komponent. Dlatego to dobre miejsce na wszystko, bez czego ekran ma się otworzyć poprawnie. Poniżej dokładam do znanej Ci już klasy jedno pole i jedną linię odczytu.
// ta sama klasa, importy bez zmian
sortOrder = 'default';
ngOnInit(): void {
this.samuraiId = this.route.snapshot.paramMap.get('id') ?? '';
this.sortOrder = this.route.snapshot.queryParamMap.get('sort') ?? 'default';
}Wejście na /samurai/7?sort=level da teraz samuraiId równy '7' i sortOrder równy 'level', a wejście na samo /samurai/7 zostawi sortOrder z wartością zapasową 'default'. Zwróć uwagę, że nie zmienił się ani sposób odczytu, ani obsługa braku wartości - zmieniła się wyłącznie mapa, do której sięgasz: paramMap dla odcinków ścieżki, queryParamMap dla dopisków po znaku zapytania. To jedyna rzecz, którą musisz tu rozróżniać.
Kiedy migawka przestaje wystarczać
Teraz najważniejszy moment lekcji. Stoisz na /samurai/7 i klikasz link "następny wojownik", prowadzący do /samurai/8. Adres w pasku się zmienia, router dopasowuje tę samą trasę i ten sam komponent, więc nie tworzy go od nowa - używa istniejącego egzemplarza. A skoro komponent nie powstaje na nowo, to ngOnInit nie zostanie wywołany po raz drugi. Twoje pole samuraiId zostaje z siódemką, choć w pasku adresu widnieje ósemka.
Warto zrozumieć, gdzie dokładnie leży wina, bo to nie jest to, co podpowiada intuicja. Sama migawka jest przy każdej nawigacji aktualizowana - gdybyś odczytał this.route.snapshot.paramMap.get('id') po przejściu, dostałbyś ósemkę. Kłopot polega na tym, że nikt jej już nie odczyta, bo nie ma po temu okazji. Potrzebujesz czegoś, co samo Cię zawiadomi o zmianie.
Do tego służy paramMap odczytywana bez snapshot, wprost z ActivatedRoute. To nie jest pojedyncza wartość, tylko strumień wartości pojawiających się w czasie, czyli Observable. Żeby cokolwiek z niego dostać, zapisujesz się na powiadomienia metodą subscribe(), przekazując funkcję, którą Angular wywoła przy każdej nowej wartości. W terminach Akademii: snapshot to zwój odebrany raz przy bramie, a subscribe() to stały goniec, który przybiega z nowym zwojem, ilekroć rozkaz się zmieni.
ngOnInit(): void {
this.route.paramMap.subscribe(params => {
this.samuraiId = params.get('id') ?? '';
});
}Pierwsza linia otwiera subskrypcję, druga wykonuje odczyt, trzecia zamyka funkcję i wywołanie - i w tej kolejności ten kod istnieje w pliku. Obiekt params jest dokładnie tego samego typu co snapshot.paramMap, więc metoda get() i obsługa null przez ?? wyglądają identycznie. Angular wywoła tę funkcję od razu przy wejściu na trasę, a potem jeszcze raz przy każdym przejściu na inny numer. Nie zmieniło się nic poza momentem odczytu: z jednorazowego stał się powtarzalny.
Kiedy sięgać po co? Polecam Ci prostą regułę: jeśli w widoku istnieje jakikolwiek link prowadzący do tej samej trasy z innym parametrem, bierz strumień. W pozostałych przypadkach migawka jest krótsza, czytelniejsza i nie zostawia po sobie subskrypcji. Subskrypcję zaś, którą otwierasz, wypada posprzątać przy niszczeniu komponentu, czyli w ngOnDestroy - narzędzia, które robią to elegancko, czekają na Ciebie u zwiadowców ninja, w module o RxJS. Jest jeszcze trzecia droga: opcja withComponentInputBinding() podawana przy konfiguracji routera sprawia, że parametry trasy wpadają wprost do wejść komponentu. Wymaga jednak wiedzy o sygnałach, której nabierzesz później, więc dziś zostajemy przy ActivatedRoute.
Rozkaz wymarszu - serwis Router
Do tej pory przenosił Cię routerLink w szablonie. Bardzo często jednak decyzja o przejściu zapada w kodzie: po zapisaniu formularza, po udanym logowaniu, po wybraniu wojownika z listy. Wtedy potrzebujesz drugiego serwisu z @angular/router, o nazwie Router. Podział ról jest ostry i warto go zapamiętać raz na zawsze: ActivatedRoute opisuje, gdzie stoisz, a Router przenosi Cię gdzie indziej.
Dwie inne nazwy bywają tu mylone. RouterOutlet to nie serwis, tylko dyrektywa - znacznik <router-outlet>, który w szablonie wyznacza miejsce na renderowanie komponentu trasy; nie da się go wstrzyknąć po to, by gdziekolwiek przejść. Z kolei Location z pakietu @angular/common to cienka nakładka na historię przeglądarki, z metodami w rodzaju back() czy go(). Potrafi cofnąć użytkownika albo podmienić adres, ale nie zna Twojej konfiguracji tras i nie uruchamia ich dopasowania. Do przejścia na trasę zdefiniowaną w pliku z trasami służy Router i tylko on.
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: 'app-samurai-list',
standalone: true,
template: '<button (click)="goToSamurai(7)">Pokaz wojownika</button>'
})
export class SamuraiListComponent {
private router = inject(Router);
goToSamurai(id: number): void {
this.router.navigate(['/samurai', id]);
}
}Kliknięcie przycisku przeniesie użytkownika na /samurai/7, a tam odezwie się komponent z pierwszej połowy lekcji i odczyta siódemkę. Najważniejszy szczegół siedzi w nawiasach: metoda navigate() przyjmuje tablicę segmentów, a nie gotowy tekst. Zapis ['/samurai', id] znaczy "trasa samurai, a w niej ten numer", i to Angular skleja te kawałki ukośnikiem oraz koduje w nich znaki specjalne. Liczba w tablicy jest jak najbardziej dozwolona - router zamieni ją na tekst, choć przy odczycie po drugiej stronie i tak wróci jako napis. Cała linia czyta się od lewej: this.router, potem .navigate(, potem ['/samurai', id], na końcu );.
Dopiski przy wymarszu - queryParams
Skoro adres może nieść parametry zapytania, musi istnieć sposób, by je dopisać przy nawigacji. Metoda navigate() przyjmuje drugi, opcjonalny argument: obiekt z dodatkowymi ustawieniami podróży. Pole queryParams w tym obiekcie to zwykły zestaw kluczy i wartości, które Angular doklei do adresu po znaku zapytania.
sortByName(): void {
this.router.navigate(['/samurai'], {
queryParams: { sort: 'name', order: 'asc' }
});
}Po tym wywołaniu w pasku adresu pojawi się /samurai?sort=name&order=asc. Nie zmieniło się przy tym dopasowanie trasy - router nadal wybiera trasę samurai, dokładnie tę samą co bez dopisków, bo parametry zapytania w dopasowaniu nie uczestniczą. Zmienił się wyłącznie adres, który zobaczy komponent czytający queryParamMap.
Domyślnie każda nowa nawigacja porzuca dotychczasowe parametry zapytania. Jeśli użytkownik ustawił sortowanie i filtry, a Ty przenosisz go na kolejny ekran, dopiski przepadną. Sterujesz tym polem queryParamsHandling, które przyjmuje trzy sensowne wartości: 'preserve' zachowuje dotychczasowe dopiski i pomija te, które właśnie podałeś, 'merge' łączy stare z nowymi, a 'replace' odtwarza domyślne zachowanie. Polecam 'merge' jako pierwszy wybór bo 'preserve' po cichu wyrzuca do kosza parametry przekazane w tym samym wywołaniu, co potrafi kosztować godzinę szukania błędu.
goToSamuraiKeepingFilters(id: number): void {
this.router.navigate(['/samurai', id], {
queryParamsHandling: 'preserve'
});
}Jeśli użytkownik był na /samurai?sort=level, to po tym wywołaniu trafi na /samurai/7?sort=level - numer wojownika się zmienił, dopisek o sortowaniu przetrwał. Nie zmieniła się natomiast sama metoda: wciąż jest to navigate() z tablicą segmentów, doszła tylko jedna opcja w drugim argumencie.
navigateByUrl - cały adres jednym tekstem
Bywa, że adres przychodzi do Ciebie w jednym kawałku i nie masz z czego budować tablicy. Klasyczny przypadek to adres zapamiętany przed logowaniem, żeby po zalogowaniu wrócić użytkownika tam, gdzie zaczął. Rozkrawanie takiego napisu na segmenty byłoby stratą czasu, więc Router daje drugą metodę: navigateByUrl(). Bierze cały adres jako jeden tekst, razem z parametrami zapytania i fragmentem po krzyżyku.
goToSavedUrl(): void {
this.router.navigateByUrl('/samurai/123?sort=level');
}Nie próbuj tego samego z navigate() - wywołanie navigate('/samurai/123') nie skompiluje się, bo pierwszy argument tej metody musi być tablicą i TypeScript odrzuci pojedynczy napis. Nie szukaj też metod goTo() ani redirect(): serwis Router ich nie ma i kompilator powie wprost, że taka właściwość nie istnieje. Mylące bywa słowo redirectTo, które faktycznie znasz z konfiguracji tras, ale to pole wpisu w tablicy tras, a nie metoda serwisu.
Na co dzień polecam navigate() jako domyślny wybór: bezpiecznie skleja segmenty, koduje znaki specjalne i przyjmuje opcje takie jak queryParams. Po navigateByUrl() sięgaj wtedy, gdy adres dostajesz z zewnątrz w postaci gotowego tekstu i rozbieranie go byłoby sztuką dla sztuki.
Pamiętaj: ActivatedRoute to zwój, który czytasz, a Router to rozkaz wymarszu, który wydajesz - migawka wystarczy, gdy stoisz w miejscu, ale gdy szlak zmienia się pod nogami, potrzebujesz gońca.
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 serwis używamy do odczytania parametrów z aktualnej ścieżki?
2. Który serwis (poza ActivatedRoute) wstrzykujemy, aby programistycznie zmienić trasę z poziomu klasy komponentu?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Układanie w poziomie
Ułóż w jednej linii odczyt parametru 'id' z migawki (snapshot) trasy:
- Edytor kodu
Uzupełnij komponent by odczytać parametr 'id' z URL używając inject() i ActivatedRoute.
- Układanie w poziomie
Ułóż w jednej linii wywołanie nawigacji do /samurai z parametrem id:
- Układanie w pionie
Uporządkuj kolejne linie subskrypcji parametrów trasy (od góry do dołu, jak w komponencie):