Jeśli dopiero zaczynasz swoją przygodę z Angularem, prędzej czy później spotkasz się z pojęciem routingu. Na początku konfiguracja Routera może wyglądać jak kolejna tablica pełna niezbyt oczywistych właściwości, takich jak path, children, loadChildren, canActivate czy resolve.
W tym artykule przyjrzymy się podstawom routingu w Angularze. Zobaczymy, czym jest Router, w jaki sposób definiujemy ścieżki aplikacji, jak budujemy z nich bardziej rozbudowane struktury oraz czym różni się zwykłe ładowanie tras od lazy loadingu. Krótko omówimy również guardy i resolvery, żebyś wiedział, czym są, kiedy pojawiają się w konfiguracji i za co odpowiadają.
Nie będziemy zagłębiać się w każdy szczegół Angular Routera. Celem jest przede wszystkim zbudowanie podstawowego obrazu tego, jak routing działa i jak czytać jego konfigurację w istniejącej aplikacji.
Jeśli jednak chcesz dowiedzieć się więcej, to polecam artykuł Miłosza.
Routing
Zdecydowana większość aplikacji webowych składa się z wielu ekranów. Możemy mieć stronę główną, listę produktów, szczegóły konkretnego produktu, profil użytkownika czy panel administracyjny. Każdy z tych widoków chcemy zazwyczaj powiązać z konkretnym adresem URL:
/
/products
/products/42
/profile
/admin
W tradycyjnej aplikacji przejście pod kolejny adres często oznacza pobranie z serwera całej nowej strony HTML. Angular jest jednak frameworkiem służącym między innymi do budowania aplikacji typu Single Page Application, czyli SPA. Po pierwszym załadowaniu aplikacji kolejne przejścia pomiędzy jej widokami mogą odbywać się bez przeładowywania całej strony.
I właśnie tutaj pojawia się routing.
Routing pozwala określić, jaki fragment aplikacji powinien zostać wyświetlony dla konkretnego adresu URL. Angular udostępnia do tego Angular Router – oficjalny mechanizm frameworka odpowiedzialny za nawigację.
Router
Najprościej mówiąc, Router zarządza nawigacją wewnątrz aplikacji.
Kiedy użytkownik chce przejść pod dany adres, router sprawdza konfigurację routingu i szuka ścieżki odpowiadającej temu adresowi. Jeśli ją znajdzie, to może wyświetlić przypisany do niej komponent.
Obok pojęcia Router możesz też spotkać się z definicją Route. Łatwo pominąć, a są to dwie osobne definicje:
Router – mechanizm obsługi nawigacji
Route – trasa, ścieżka, pojedyncza reguła opisująca, co powinno się wydarzyć po wejściu na określony adres
Konfiguracja routingu
Przed chwilą powiedzieliśmy, że router sprawdza konfigurację routingu. Czym ona jest?
Przy obecnie powrzechnym wykorzystaniu podejścia standalone components, taką konfigurację możemy znaleźć w pliku app.routes.ts.
Zaś w aplikacjach opartych na NgModule routing często jest definiowany w osobnym module, na przykład app-routing.module.ts.
Niemniej niezależnie od podejścia, sposób określania ścieżek jest taki sam.
import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { ProductsComponent } from './products/products.component';
import { ProductDetailsComponent } from './product-details/product-details.component';
export const routes: Routes = [
{
path: '',
component: HomeComponent,
},
{
path: 'products',
component: ProductsComponent,
},
{
path: 'products/:id',
component: ProductDetailsComponent,
},
];
Routes jest tablicą zawierającą definicje kolejnych tras. Każdy znajdujący się w niej obiekt opisuje jedną trasę lub fragment drzewa routingu.
path – określa fragment adresu, który powinien zostać dopasowany przez Router
component – wskazuje komponent, który Angular powinien aktywować po dopasowaniu tej trasy
Tak więc gdy wejdziemy np. na ścieżkę myApp.com/products – aktywujemy ProductsComponent.
Jednak pojawia się ważne pytanie: gdzie konkretnie na stronie zobaczymy ten komponent?
RouterOutlet
W głównym template aplikacji możemy znaleźć na przykład taki fragment kodu:
<header>...</header>
<router-outlet />
<footer>...</footer>
RouterOutlet możemy potraktować jak miejsce przygotowane dla Routera. To właśnie w nim Angular może wyrenderować komponent odpowiadający aktualnie aktywnej trasie.
Dzięki temu nagłówek czy stopka mogą pozostać na ekranie, a Router zmienia jedynie zawartość znajdującą się pomiędzy nimi.
Zaznaczmy, że RouterOutlet nie musi znajdować się tylko w jednym miejscu aplikacji. Są sytuacje, gdzie ścieżki są zagnieżdżone lub występują tzw. named outlets, czyli kilka outletów na tym samym poziomie. Ale to temat na osobny artykuł. Po prostu nie zdziw się, Drogi Czytelniku, jeśli gdzieś zobaczysz wiele router outletów. To normalne 🙂
Przechodzenie między trasami
Aby móc nawigować się pomiędzy ścieżkami, np z /products do /home możemy użyć angularowego narzędzia routerlink, który nakładamy na element html.
<a routerLink="/">Home Page</a>
<a routerLink="/products">Products</a>
<a routerLink="/profile">Profile</a>
Możemy również wykonać nawigację z poziomu pliku .ts wykorzystując wspomniany wcześniej Router:
private readonly router = inject(Router);
openProducts(): void {
this.router.navigate(['/products']);
}
Pierwsze rozwiązanie dobrze sprawdza się w template, natomiast ta bardziej programistyczna nawigacja jest przydatna wtedy, kiedy przejście na inny ekran jest wynikiem jakiejś logiki aplikacji.
ActivatedRoute
ActivatedRoute reprezentuje aktualnie aktywną trasę i pozwala komponentowi odczytać informacje związane z bieżącym adresem URL.
Możemy wykorzystać go między innymi do pobrania parametrów ścieżki, parametrów query czy danych przypisanych do trasy.
Mając adres /products/42 możemy odczytać parametr id w komponencie:
private readonly activatedRoute = inject(ActivatedRoute);
ngOnInit(): void {
const id = this.activatedRoute.snapshot.paramMap.get('id');
}
ActivatedRoute jest więc obiektem, dzięki któremu komponent może dowiedzieć się, w jakim kontekście routingu został uruchomiony i jakie dane zostały przekazane przez aktualną trasę.
Od Angulara 16 parametr z adresu URL można też odebrać bezpośrednio jako input komponentu. Nie trzeba wtedy wstrzykiwać ActivatedRoute. Najpierw włącz tę funkcję w konfiguracji routera, w app.config.ts:
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withComponentInputBinding()),
],
};
Potem w komponencie wystarczy zadeklarować input o tej samej nazwie co parametr w ścieżce (products/:id):
import { Component, input } from '@angular/core';
@Component({
selector: 'app-product-details',
template: `<p>Produkt o ID: {{ id() }}</p>`,
})
export class ProductDetails {
readonly id = input.required<string>();
}
Routing jako drzewo
Na początku łatwo patrzeć na routing jak na zwykłą listę adresów:
/home
/products
/profile
/settings
W praktyce routing znacznie lepiej wyobrażać sobie jako drzewo.
Załóżmy, że nasza aplikacja posiada dodatkowy panel administracyjny:
/admin
/admin/users
/admin/settings
W tej sytuacji możemy przedstawić tę strukturę w taki sposób:
/
├── products
│ └── :id
├── profile
└── admin
├── users
└── settings
users i settings są tutaj trasami należącymi do części admin.
Angular pozwala opisać taką zależność przy pomocy children, dzięki której możemy tworzyć kolejne tablice tras w formie zagnieżdżonej struktury.
export const routes: Routes = [
{
path: 'products',
component: ProductsComponent,
},
{
path: 'admin',
component: AdminComponent,
children: [
{
path: 'users',
component: UsersComponent,
},
{
path: 'settings',
component: SettingsComponent,
},
],
},
];
Właśnie w takim przypadku możemy zastosować drugi router outlet – na ścieżce admina.
Naturalnie nie musimy trzymać całej konfiguracji routingu w jednym pliku. Możemy je podzielić na wiele innych i umieścić w różnych miejscach projektu.
app.routes.ts
products/
products.routes.ts
admin/
admin.routes.ts
profile/
profile.routes.ts
Te tematy, tj. drzewa routingu i podziału na mniejsze segmenty, przenoszą nas płynnym ruchem do definicji lazy loadingu, która często pojawia się w środowisku Angulara.
Lazy Loading – nie wszystko musimy ładować na starcie aplikacji
W dotychczasowych przykładach komponenty używane przez routing były bezpośrednio importowane i wskazywane za pomocą właściwości component. Oznacza to, że ich kod jest dołączany do kodu potrzebnego podczas początkowego ładowania aplikacji.
W niewielkiej aplikacji zazwyczaj nie stanowi to większego problemu. Wraz ze wzrostem projektu możemy jednak mieć coraz więcej niezależnych części, takich jak:
- strona główna,
- katalog produktów,
- koszyk,
- konto użytkownika,
- panel administracyjny,
- system raportów.
Użytkownik może wejść do aplikacji tylko po to, żeby przejrzeć kilka produktów. Czy w takim przypadku podczas pierwszego otwarcia aplikacji potrzebuje od razu kodu odpowiedzialnego za panel administracyjny i raporty?
Niekoniecznie.
W takiej sytuacji możemy wykorzystać lazy loading, czyli ładowanie określonych części aplikacji dopiero wtedy, gdy staną się potrzebne.
W prostym routingu mogliśmy napisać:
import { AboutComponent } from './about/about.component';
export const routes: Routes = [
{
path: 'about',
component: AboutComponent,
},
];
AboutComponent jest tutaj bezpośrednio importowany i powiązany z trasą.
W przypadku lazy loadingu używamy loadComponent:
export const routes: Routes = [
{
path: 'about',
loadComponent: () =>
import('./about/about.component')
.then(m => m.AboutComponent),
},
];
W takiej konfiguracji kod komponentu zostanie pobrany dopiero wtedy, gdy użytkownik będzie chciał aktywować tę trasę. Angular może dzięki temu podzielić aplikację na osobne fragmenty kodu ładowane na żądanie.
To samo dotyczy podejścia children:
- prosty routing
export const routes: Routes = [
{
path: '',
component: HomeComponent, },
{
path: 'admin',
component: AdminComponent,
children: [
{
path: 'users',
component: UsersComponent,
},
{
path: 'settings',
component: SettingsComponent,
}, ],
}, ];
- lazy loading
export const routes: Routes = [
{
path: '',
component: HomeComponent, },
{
path: 'admin',
loadChildren: () => import('./admin/admin.routes') .then(m => m.ADMIN_ROUTES),
}, ];
W tym przypadku główna konfiguracja wie, że istnieje gałąź /admin, ale szczegóły jej routingu mogą zostać załadowane dopiero wtedy, gdy będą potrzebne.
Nie oznacza to jednak, że każda trasa musi być lazy loadowana. Lazy loading zmniejsza ilość JavaScriptu potrzebnego przy pierwszym załadowaniu, ale jednocześnie oznacza konieczność pobrania kolejnego fragmentu aplikacji w momencie późniejszej nawigacji. Dokumentacja Angulara wskazuje więc lazy loading jako narzędzie, którego użycie warto dopasować do struktury aplikacji.
Guardy i resolvery
Konfiguracja routingu może zawierać nie tylko informacje o ścieżce i komponencie. Możemy również określić dodatkowe mechanizmy, które mają zostać uruchomione podczas nawigacji.
Na tym etapie najważniejsze jest rozpoznanie ich w konfiguracji routingu. Szczegóły implementacji guardów i resolverów można potraktować jako osobny, bardziej zaawansowany temat.
Guard
Guard pozwala zdecydować, czy użytkownik może przejść do danej trasy. Może być wykorzystywany na przykład do sprawdzenia, czy użytkownik jest zalogowany albo czy posiada odpowiednie uprawnienia.
{
path: 'admin',
component: AdminComponent,
canActivate: [authGuard],
}
canActivate oznacza tutaj, że zanim Router aktywuje AdminComponent, powinien uruchomić wskazany guard.
Jeżeli guard pozwoli na nawigację, Router może kontynuować aktywowanie trasy. Jeżeli ją zablokuje, użytkownik nie przejdzie do danego widoku. Guard może również zwrócić informację powodującą przekierowanie użytkownika na inną trasę.
Możemy więc myśleć o nim jak o punkcie kontrolnym:
użytkownik chce wejść na /admin
↓
guard
↙ ↘
można? nie można?
↓
/admin
Angular posiada kilka rodzajów guardów przeznaczonych do różnych sytuacji. Możemy między innymi kontrolować możliwość wejścia na trasę, jej dopasowania czy opuszczenia aktualnego ekranu.
Resolver
Resolver pozwala przygotować dane potrzebne przez daną trasę zanim zostanie ona aktywowana.
Załóżmy, że użytkownik przechodzi pod adres /products/42
W konfiguracji routingu mamy:
{
path: 'products/:id',
component: ProductDetailsComponent,
resolve: {
product: productResolver,
},
}
Fragment :id jest parametrem trasy. Dla adresu /products/42 jego wartością będzie 42.
Zanim Router aktywuje ProductDetailsComponent, uruchomi funkcję productResolver. Resolver może odczytać wartość parametru id, użyć jej do pobrania danych produktu o identyfikatorze 42, a następnie przekazać te dane dalej do trasy.
Dzięki temu komponent może zostać uruchomiony już z przygotowanymi danymi, zamiast dopiero po wejściu na ekran rozpoczynać ich pobieranie.
Podsumowanie
Na początku konfiguracja routingu może wyglądać bardzo niepozornie:
{
path: 'products',
component: ProductsComponent,
}
Po poznaniu kilku dodatkowych możliwości możemy jednak spotkać trasę przypominającą:
{
path: 'products/:id',
loadComponent: () =>
import('./product-details/product-details.component')
.then(m => m.ProductDetailsComponent),
canActivate: [authGuard],
resolve: {
product: productResolver,
},
}
I mimo że na pierwszy rzut oka znajduje się tutaj znacznie więcej elementów, wszystkie opisują jedną rzecz – co powinno wydarzyć się podczas przejścia użytkownika do konkretnego fragmentu aplikacji.
- Router musi znaleźć pasującą trasę.
- Może załadować potrzebny do niej kod.
- Może sprawdzić guardy.
- Może uruchomić resolvery.
Na końcu aktywuje odpowiednią część drzewa routingu i wyświetla ją w RouterOutlet.
Rzeczywisty proces nawigacji Angular Routera jest bardziej rozbudowany i posiada własne etapy oraz zdarzenia, ale na początku nie musimy znać wszystkich jego szczegółów.
Podsumujmy krótko najważniejsze pojęcia:
Router zarządza nawigacją.
Routes opisuje konfigurację dostępnych tras.
Route opisuje pojedynczą trasę lub fragment drzewa.
RouterOutlet wskazuje miejsce, w którym może pojawić się aktywowany komponent.
children pozwala budować zagnieżdżone drzewo routingu.
loadComponent i loadChildren pozwalają ładować części aplikacji dopiero wtedy, kiedy są potrzebne.
Guardy pozwalają wpływać na możliwość nawigacji.
Resolvery pozwalają przygotować dane wymagane przez trasę przed jej aktywowaniem.
To dopiero podstawy Angular Routera, ale już ich znajomość wystarcza, żeby znacznie łatwiej odnaleźć się w konfiguracji routingu istniejącej aplikacji. Kiedy następnym razem zobaczymy app.routes.ts, zamiast traktować go jako listę tajemniczych obiektów, możemy spojrzeć na niego jak na mapę pokazującą strukturę i sposób nawigacji po naszej aplikacji.