21 cze 2024
28 min

Wszystko co musisz wiedzieć o Angular Router

Routing to kluczowa funkcjonalność, która umożliwia tworzenie dynamicznych aplikacji SPA (Single Page Application). Mechanizm ten umożliwia nawigację między różnymi widokami w aplikacji bez konieczności ponownego ładowania strony z serwera. Jako nowoczesny framework Angular zapewnia potężne i elastyczne narzędzia do zarządzania ścieżkami adresów URL, umożliwiając programistom tworzenie intuicyjnych i responsywnych interfejsów.

Aplikacja SPA działa poprzez ładowanie pojedynczej strony HTML i dynamiczną aktualizację zawartości podczas interakcji użytkownika z aplikacją. Zamiast wysyłać nowe żądanie do serwera dla każdej nowej strony, SPA pobiera dane asynchronicznie i aktualizuje bieżącą stronę, co powoduje szybsze i bardziej płynne działanie. Osiąga to poprzez połączenie JavaScript, HTML i CSS, przy czym Angular skutecznie obsługuje dynamiczne zmiany stanu.

W tym artykule wyjaśnimy szczegółowo definiowanie ścieżek, ich protekcję i konfigurację, rozszerzenia i opcje konfiguracji dla samego Routera i korzystanie z mechanizmu nawigacji.

Konfiguracja routingu

Aby zacząć korzystać z routingu w aplikacji, pierwszą rzeczą, którą należy zrobić jest użycie funkcji provideRouter, aby skonfigurować niezbędne providery:

provideRouter(routes: ROUTES, ...features: RouterFeatures[]): EnvironmentProviders

w konfiguracji aplikacji:

bootstrapApplication(AppComponent, { providers: [provideRouter(ROUTES)] });

Ten sposób jest wciąż stosunkowo nowy, ponieważ został wprowadzony wraz z standalone components. W aplikacjach opartych na modułach trzeba wykorzystać statyczną metodę forRoot klasy RouterModule:

static forRoot(routes: Routes, config?: ExtraOptions):
	ModuleWithProviders<RouterModule>

oraz zaimportować RouterModule w głównym module aplikacji (AppModule):

@NgModule({
   imports: [RouterModule.forRoot(ROUTES)]
})
export class AppModule { }

Definicja ścieżek

Router jest gotowy do użycia, jednak musimy jeszcze zdefiniować ścieżki (Routes), czyli tablicę obiektów typu Route. Jest to przepis dla Angulara, jak poruszać się po aplikacji. Dwa najważniejsze składniki to: ścieżka i związany z nią komponent.

Ścieżka to fragment tekstu, który buduje adres URL wyświetlany na pasku adresu przeglądarki. Może być statyczny i dynamiczny. Ścieżka dynamiczna (param) zaczyna się od dwukropka, który wskazuje, że ten kawałek adresu URL można zastąpić pewną wartością. Tekst po dwukropku to nazwa parametru, której można użyć do odczytania tej wartości.

Ścieżki w adresie URL są oddzielone ukośnikami, które tworzą hierarchiczną strukturę. Ta cecha znajduje odzwierciedlenie w konfiguracji przez parametr children. Przyjmuje on kolejną tablicę Routes, tworząc drzewo routingu.

const ROUTES: Routes = [
   { path: ‘dashboard’, component: DashboardComponent },
   { path: ‘products’, component: ProductsComponent, children: [
      { path: ‘top’, component: TopProductsComponent },
	{ path: ‘:id’, component: ProductDetailsComponent }
   ]},
   { path: ‘’, redirectTo: ‘/dashboard’, pathMatch: ‘full’ },
   { path: ‘**’, component: PageNotFoundComponent }
]

Kolejność definicji routów ma znaczenie. Angular stosuje strategię first-match wins i wybiera pierwszy, która spełnia warunki. Z tego powodu należy zadeklarować te bardziej szczegółowe przed mniej szczegółowymi. Z tego powodu ścieżka “top” jest zdefiniowana przed “:id”. W przeciwnym razie “top” zostanie rozpoznane jako wartość parametru id, a Angular otworzy stronę z komponentem ProductDetailsComponent.

Jak widać pusta ścieżka definiuje parametr pathMatch. Ten wpływa na proces dopasowywania i akceptuje dwie wartości:

  • “prefix” (domyślnie) – ta opcja dopasowuje route, gdy podana ścieżka jest przedrostkiem całego adresu URL. Route “admin” pasowałaby do wszystkich adresów URL, takich jak “/admin”, “admin/settings”, “admin/users”: 
{ path: ‘admin’, component: AdminComponent, pathMatch: ‘prefix’ }
  • “full” – ta opcja dopasowuje route tylko wtedy, gdy cały adres URL pasuje do konfiguracji. Jeśli trasa z pustą ścieżką w przykładzie powyżej nie miałaby opcji pathMatch ustawionej na “full”, użytkownik nigdy nie zobaczy strony z komponentem PageNotFoundComponent po podaniu nieprawidłowego adresu URL. Zamiast tego zawsze będzie przekierowywany na dashbord, ponieważ pusty znak można dopasować jako początek każdego adresu URL

Dwie gwiazdki (**) to wildcard, który pasuje do dowolnego adresu URL, więc należy go zawsze zdefiniować na końcu. Router wybiera tę trasę, jeśli nie jest w stanie dopasować żadnej innej, co oznacza, że podany adres wskazuje na stronę, która nie istnieje, i właśnie dlatego wyświetlamy komponent PageNotFoundComponent.

Czasami może być konieczne przekierowanie użytkownika na inną stronę. Jak w tym przykładzie, jeśli adres URL jest pusty, użytkownik jest przekierowywany na stronę dashboardu. Aby to zrobić, zdefiniuj parametr redirectTo. Akceptuje on statyczną ścieżkę, do której użytkownik powinien zostać przekierowany lub funkcję, aby obsłużyć bardziej skomplikowane przypadki:

type RedirectFunction = (
  redirectData: Pick<
    ActivatedRouteSnapshot,
    'routeConfig' | 'url' | 'params' | 'queryParams' | 'fragment' | 'data' | 'outlet' | 'title'
  >,
) => string | UrlTree

{
   path:old-dashboard-page’,
   redirectTo: ({ url }) => {
      if (url.contains(‘v2’)) return ‘/dashboard’
      else {
         inject(NotifictionService).open(‘Page no longer available’, Theme.ERROR);
         return ‘/not-found
      }
   }
}

Lazy loading

Lazy loading to podstawowa, ale niezwykle skuteczna technika optymalizacji. Możesz jej użyć w konfiguracji routingu, aby odroczyć pobieranie komponentów (lub modułów), dopóki użytkownik nie przejdzie do określonej strony zamiast ładować cały kod podczas uruchamiania aplikacji. Rozbija ona kod aplikacji na mniejsze części (chunks) i ładuje je asynchronicznie, co zmniejsza początkowy rozmiar ładowanego kodu (initial bundle size), poprawiając wydajność. Jest to niezbędna technika w budowaniu skalowalnych i wydajnych aplikacji, ponieważ im większa aplikacja, tym większa poprawa wydajności.

Możemy to zaobserwować w zakładce Network, więc stwórzmy mały eksperyment. Jako obiekt testowy posłuży prosta aplikacja. Ma dwie strony, na których wyświetlana jest tabela z Angular Material. W pierwszym scenariuszu nie używamy lazy-loadingu:

Wszystkie pliki zostały załadowane natychmiastowo, a po zmianie strony nic się nie dzieję. Zwróć uwagę na rozmiar pliku main.js. Teraz skorzystajmy z lazy-loadingu i sprawdźmy czy coś się różni:

Jak widać, rozmiar pliku main.js zmniejszył się, a fragmenty ze stronami wyświetlającymi tabele zostały załadowane asynchronicznie po ich otwarciu.

Wiemy już, że ta technika działa, więc jak z niej skorzystać? Wystarczy przekazać funkcję do parametru loadComponent, która asynchronicznie importuje komponent:

const ROUTES: Routes = [
  {
    path: 'user',
    loadComponent: () =>
      import('./user.component').then((c) => c.UserComponent),
  },
];

Kluczowym jest użycie funkcji import wewnątrz loadComponent. Dzięki temu nie musimy wprost definiować importu (import {UserComponent} from ‘./user.component’), który tak czy inaczej spowodowałby natychmiastowe załadowanie komponentu.

W aplikacjach opartych na modułach taki rezultat można osiągnąć poprzez wykorzystanie funkcji loadChildren, aby załadować moduł importujący RouterModule z definicjący ścieżki poprzez statyczną metodę forChild:

const ROUTES: Routes = [
  {
    path: 'user',
    loadChildren: () => import('./user.module).then(m => m.UserModule),
  },
];

@NgModule({ imports: [RouterModule.forChild(USER_FEATURE_ROUTES)] })
export class UserModule {}

W tym przykładzie zmienna USER_FEATURE_ROUTES zawiera konfigurację stron związanych z zarządzaniem użytkownikami. W ten sposób moduł UserModule stanowi punkt wejściowy do tego obszaru aplikacji. Takie strukturyzowanie aplikacji to bardzo przydatny wzorzec, jednak dzisiaj nie korzystamy już z modułów…

Definiowanie jednej wielkiej zmiennej definiującej wszystkie ścieżki w aplikacji zdecydowanie brzmi jak zły pomysł. Chcemy trzymać je osobno w plikach obok komponentów których dotyczą. Jednak nie możemy po prostu zaimportować takiego pliku – to pozbawiłoby nas dobrodziejstw lazy-loadingu. 

Na szczęście istnieje proste i efektywne rozwiązanie. Funkcja przypisana do loadChildren może ładować nie tylko moduły, ale również pliki z konfiguracją routingu.

const ROUTES: Routes = [
  {
    path: 'user',
    loadChildren: () =>
      import('./user.routes).then((m) => m.USER_FEATURE_ROUTES),
  },
];

Ze względu na prostotę w przykładach w tym artykule nie konfiguruję lazy-loadingu – taka definicja jest zdecydowanie dłuższa. Jednak w Twojej aplikacji powinieneś zawsze korzystać z tego wzorca.

Dopasowywanie ścieżek (Route Matcher)

Route matcher to funkcja pozwalająca na stworzenie własnej logiki dopasowywania ścieżek, jeżeli domyślny sposób nie jest wystarczający. Zwraca ona obiekt UrlMatchResult zawierający skonsumowane segmenty oraz wartości dla parametrów ścieżki – lub null jeżeli URL nie spełnia Twoich wymagań.

type UrlMatcher = (
  segments: UrlSegment[],
  group: UrlSegmentGroup,
  route: Route,
) => UrlMatchResult | null

type UrlMatchResult = {
  consumed: UrlSegment[];
  posParams?: {[name: string]: UrlSegment};
}

Stwórzmy taką funkcję, której zadaniem jest dopasowanie ścieżki jeżeli parametrem “username” jest nazwa profilu na X (Twitterze), a więc rozpoczynająca się od “@”. Dodatkowo znak “@” powinien zostać usunięty z ciągu znaków przekazanego jako wartość tego parametru:

export const ROUTES: Routes = [
  {
    path: 'users',
    component: UsersListComponent,
    children: [
      {
        matcher: nameMatcher,
        component: UserComponent,
        children: [{ path: 'details', component: UserDetailsComponent }],
      },
    ],
  },
];

const nameMatcher: UrlMatcher = (
  url: UrlSegment[],
): UrlMatchResult | null => {
  const usernameSegment = url[0];

  if (usernameSegment.path.match(/^@[\w]+$/gm))
    return {
      consumed: [usernameSegment],
      posParams: {
        username: new UrlSegment(usernameSegment.path.slice(1), {}),
      },
    };
  else return null;
};

Jeżeli użytkownik poda adres “users/@angularlove/details” parametr url będzie miał wartość: [“@angularlove”,”details”] ponieważ segment “users” został już skonsumowany przez route UserListComponent. Pierwszy segment to właśnie nazwa, którą jesteśmy zainteresowani. Sprawdzamy ją za pomocą wyrażenia regularnego, aby sprawdzić czy rozpoczyna się od “@” – jeżeli tak zwracamy obiekt UrlMatchResult.

Pole consumed zawiera segmenty adresu, które sprawdziliśmy/wykorzystaliśmy (a więc skonsumowaliśmy) aby dopasować route, ponieważ każdy segment może być wykorzystany tylko raz – w tym przypadku to “@angularlove”. Gdybyśmy skonsumowali również segment “details” Router dopasowałby komponent UserComponent, ale nie byłby w stanie dopasować komponentu UserDetailsComponent, ponieważ nie byłoby więcej segmentów do skonsumowania.

Pole posParams to obiekt zawierający parametry ścieżki. W powyższym przykładzie definiujemy parametr “username” i przypisujemy do niego nazwę użytkownika. Aby spełnić wymagania dotyczące typu musimy utworzyć obiekt UrlSegment i przekazać do niego naszą nazwę. Drugi parametr konstruktora klasy UrlSegment to matrix param, który również jest częścią segmentu, jednak nie korzystamy z niego w tym przypadku, dlatego przekazujemy pusty obiekt. Teraz możemy odczytać wartość parametru “username” w komponencie:

@Component({ ... })
export class UserComponent implements OnInit {
  readonly usernameParam$ = inject(ActivatedRoute).paramMap.pipe(
    map((paramMap) => paramMap.get('username')),
  );

  ngOnInit() {
    this.usernameParam$.subscribe(console.log); // ‘angularlove’
  }
}

Ochrona routów (Route Guards)

Guard to punkt kontrolny w procesie nawigacji, pozwalający kontrolować czy użytkownik może otworzyć lub opuścić daną stronę. Są one niezbędne do wdrożenia środków bezpieczeństwa, zarządzania uwierzytelnianiem i egzekwowania kontroli dostępu w aplikacji.

CanActivate

To podstawowy guard określający, czy strona może być aktywowana przez użytkownika. Jest powszechnie używany do wdrażania kontroli uwierzytelnienia lub autoryzacji przed zezwoleniem na dostęp do chronionych stron.

type CanActivateFn = (
 route: ActivatedRouteSnapshot,
 state: RouterStateSnapshot
) => MaybeAsync<GuardResult>

Na początku wyjaśnijmy co oznaczają te typy:

  • route to snapshot zawierający parametry routa 
  • state zawiera adres oraz parametry root’a
  • typ MaybeAsync to następująca unia typów: MaybeAsync<T> = T | Observable<T> | Promise<T>
  • typ GuardResult opisuje typ wartości zwracanej przez guard: GuardResult = boolean | UrlTree | RedirectCommand

Klasa RedirectCommand została wprowadzona w wersji 18 Angulara i pozwala na zdefiniowanie przekierowania, podobnie jak UrlTree, dodatkowo określając jego charakter przez NavigationBehaviorOptions

RedirectCommand.constructor( 
   redirectTo: UrlTree, 
   navigationBehaviorOptions?: NavigationBehaviorOptions | undefined
)

Jeden route może być chroniony przez wiele guardów, więc:

  • jeżeli wszystkie zwrócą wartość true nawigacja jest kontynuowana
  • jeżeli którykolwiek zwróci wartość false nawigacja jest przerywana
  • jeżeli którykolwiek zwróci obiekt UrlTree lub RedirectCommand nawigacja jest przerywana a użytkownik zostaje przekierowany

Teraz zobaczmy jak działają one w praktyce i stwórzmy guard weryfikujący uwierzytelnienie użytkownika:

const authGuard: CanActivateFn = (): boolean | UrlTree => {
  const authService = inject(AuthService);
  const router = inject(Router);
  return authService.isAuthenticated() || router.createUrlTree([‘/login’])
}

Nasza logika wymaga jedynie dostępu do serwisu AuthService, możemy więc pominąć argumenty tej funkcji. Jeżeli użytkownik jest uwierzytelniony nawigacja jest kontynuowana. W przeciwnym razie użytkownik zostaje przekierowany do strony logowania. Zauważ, że guard jest wywoływany wewnątrz injection context co pozwala na bezpieczne użycie funkcji inject.

Aby ochronić stronę przed niepowołanym dostępem musimy przekazać utworzony guard to tablicy canActivate w obiekcie konfiguracji:

const routes: Routes = [
  { 
    path: ‘some-path’,
    component: MyComponent, 
    canActivate: [authGuard] 
  }
];

Guardy w postaci funkcji zostały wprowadzone w Angularze 14. Do tego czasu były one definiowane jako serwisy implementujące odpowiedni interfejs. Takie rozwiązanie zostało już oznaczone jako przestarzałe (deprecated), ale nadal istnieje szansa, że możesz na nie natrafić w projekcie. Nasz guard w postaci serwisu wyglądałby następująco:

@Injectable({ providedIn: 'root' })
class AuthGuard implements CanActivate {
  constructor(
    private readonly authService: AuthService,
    private readonly router: Router
  ) {}

  canActivate(): boolean | UrlTree {
    return (
      this.authService.isAuthenticated() ||
      this.router.createUrlTree(['/login'])
    );
  }
}

CanActivateChild

Załóżmy, że w naszej aplikacji mamy stronę powitalną z linkami do wielu podstron, które wymagają uwierzytelnienia, aby je otworzyć. W tym przypadku możemy skorzystać z utworzonego już guardu authGuard. Definicja routing mogłaby wyglądać następująco:

const routes: Routes = [
  {
    path: '',
    component: WelcomeComponent,
    children: [
      {
        path: 'feature-1',
        component: Feature1Component,
        canActivate: [authGuard],
      },
      {
        path: 'feature-2',
        component: Feature2Component,
        canActivate: [authGuard],
      },
	...
      {
        path: 'feature-10',
        component: Feature10Component,
        canActivate: [authGuard],
      }
    ],
  },
];

Nie wygląda to zbyt dobrze – zdecydowanie za dużo powtórzeń, których powinniśmy unikać. Na ratunek przychodzi nam jednak guard CanActivateChild, który pozwala kontrolować dostęp do podstron z poziomu wyżej.

type CanActivateChildFn = (
 childRoute: ActivatedRouteSnapshot,
 state: RouterStateSnapshot,
) => MaybeAsync<GuardResult>

Jak możesz zauważyć jest niemal identyczny do CanActivate. W tym przypadku możemy wykorzystać authGuard, który satysfakcjonuje oba typy:

const routes: Routes = [
  {
    path: '',
    component: WelcomeComponent,
    canActivateChild: [authGuard],
    children: [
      {
        path: 'feature-1',
        component: Feature1Component,
      },
      {
        path: 'feature-2',
        component: Feature2Component,
      },
	...
      {
        path: 'feature-10',
        component: Feature10Component,
      }
    ],
  },
];

Zdecydowanie lepiej! Teraz zmodyfikujemy ten scenariusz. Musimy dodać kolejną podstronę – jednak ta nie wymaga już autoryzacji. Czy to oznacza, że musimy powrócić do poprzedniego rozwiązania i zdefiniować guardy dla każdej z podstron osobno? Niekoniecznie.

Możemy skorzystać z wzorca Component-Less Route i stworzyć warstwę pośrednią, która nie definiuje ścieżki i nie jest przypisany do niej żaden komponent. Nie zmienia to sposobu w jaki działa routing i pozwala na “grupowanie” routów:

const routes: Routes = [
  {
    path: '',
    component: WelcomeComponent,
    children: [
      {
        path: 'contact',
        component: ContactComponent
      },
      {
        path: '',
        canActivateChild: [authGuard],
        children: [
          {
            path: 'feature-1',
            component: Feature1Component,
          },
          {
            path: 'feature-2',
            component: Feature2Component,
          },
	    ...
          {
            path: 'feature-10',
            component: Feature10Component,
          }
        ]
      }
    ],
  },
];

Przypisanie guarda CanActivateChild to takiego routa powoduje, że jest on wywoływany przy każdym otwarciu podstrony. Możesz również użyć guarda typu CanActivate – w ten sposób zostanie on wywołany tylko podczas nawigacji do danej grupy, ale już nie podczas nawigowania wewnątrz niej. Ta techinka może być przydatna szczególnie z resolverami do zredukowania liczby zbędnych zapytań do serwera.

Warto również wspomnieć, że z racji tego, że Component-Less Route nie ma przypisanego żadnego komponentu nie wpływa to proces odczytywania wartości parametrów np. używając serwisu ActivatedRoute.

CanDeactivate

Ten guard sprawdza czy użytkownik może bezpiecznie opuścić stronę. Zazwyczaj służy on do wyświetlenia dialogu z informacją o niezapisanych zmianach.

type CanDeactivateFn<T> = (
 component: T,
 currentRoute: ActivatedRouteSnapshot,
 currentState: RouterStateSnapshot,
 nextState: RouterStateSnapshot,
) => MaybeAsync<GuardResult>

Podobnie jak poprzednie guardy, ten przyjmuje argumenty związane z parametrami stron – a więc aktualnej (currentRoute oraz currentState) oraz stanu routingu, który powstałby po opuszczeniu strony. Ponadto przyjmuje również argument component, ponieważ zazwyczaj jego logika opiera się na wewnętrznym stanie opuszczanego komponentu.

Czas na stworzenie takiego guardu. Chcemy, aby był on reużywalny, więc dobrą praktyką jest zdefiniowanie interfejsu, który powinien być implementowany przez każdy komponent przypisany do route’u, który korzysta z tego typu guardu:

interface SafeDeactivate {
  get canBeDeactivated(): boolean
}

Interfejs ten zawiera getter, z którego zwrócona wartość decyduje czy użytkownik może bezpiecznie opuścić stronę. Dla przykładu komponent zawierający formularz może zaimplementować ten getter tak, aby zwracał wartość mówiącą, czy formularz jest w stanie “pristine”.

const canDeactivate: CanDeactivateFn<SafeDeactivate> = (
  component: SafeDeactivate
): boolean | Observable<boolean> =>
  component.canBeDeactivated ||
  inject(MatDialog)
    .open(ConfirmationDialog)
    .afterClosed()
    .pipe(map(response => !!response));

Nasz guard sprawdza, czy użytkownik może bezpiecznie opuścić stronę – jeżeli nie wyświetla on dialog z informacją, że strona zawiera niezapisane zmiany i upewnić się, że nadal chce on ją opuścić.

const routes: Routes = [
  { 
    path: ‘form’,
    component: MassiveFormComponent, 
    canActivate: [canDeactivate] 
  }
];

CanMatch

Aby lepiej zrozumieć jak działa ten guard, najpierw krótko opiszę proces nawigacji:

  1. Wszystko zaczyna się od jakiejś interakcji użytkownika – naciśnięcia przycisku, linku lub ręczenej zmiany adresu w przeglądarce
  2. Angular stara się dopasować route dla nowego adresu i wybiera pierwszy, który spełnia wymagania
  3. Teraz jest czas na lazy-loading i załadowanie odpowiedniego kodu
  4. Route został rozpoznany, jednak Angular musi upewnić się, że użytkownik może wykonać taką nawigację, Jak już wiemy jest to zadanie, którym zajmuje się guard. Załóżmy więc że użytkownik chce przejść ze strony A do strony B:
    • Angular sprawdza czy można bezpiecznie opuścić stronę A (CanDeactivate)
    • jeżeli B jest podstroną Angular wywołuje CanActivate na jej rodzicu oraz CanActivateChid (w takim przypadku child to właśnie strona B)
    • sprawdza czy użytkownik może otworzyć stronę B (CanActivate)
  5. W tym momencie route jest aktywowany i Angular tworzy instancję przypisanego komponentu

Jak możesz zauważyć opisane do tej pory guardy wchodzą na scenę w punkcie 4, gdzie route jest już dopasowany. Jak zatem działa CanMatch? Jest on wywoływany w punkcie 2, kiedy Angular stara się dopasować adres do konfiguracji routingu, co daje możliwość pominięcia niektórych stron.

type CanMatchFn = 
(route: Route, segments: UrlSegment[]) => MaybeAsync<GuardResult>

Jeżeli którykolwiek z guardów CanMatch zwróci wartość false route jest pomijany i sprawdzane są kolejne z nich. Jest to szczególnie przydatne, jeżeli zamierzasz skojarzyć dwa różne komponenty z jedną ścieżką. Przypuśćmy, że chcesz utworzyć dashboard dla administratora i użytkownika – oczywiście powinny one mieć różne funkcjonalności. Zamiast tworzyć kolejną wartę dla komponentu, którego jedynym zadaniem byłoby warunkowe wyświetlanie odpowiedniego dashboardu (co tworzy niepotrzebną warstwę logiki), możesz skorzystać z CanMatch:

const canMatchAdmin: CanMatchFn = (): boolean =>
	inject(AuthService).isAdmin();

const canMatchUser: CanMatchFn = (): boolean =>
	inject(AuthService).isUser();

const routes: Routes = [
  { 
    path: ‘dashboard’,
    component: AdminDashboardComponent, 
    canMatch: [canMatchAdmin] 
  },
  { 
    path: ‘dashboard’,
    component: UserDashboardComponent, 
    canMatch: [canMatchUser] 
  }
];

CanLoad

Guard CanLoad jest przestarzały i został zastąpiony przez CanMatch, ponieważ jego działanie jest bardzo podobne. Decyduje on czy lazy-loaded moduł może zostać załadowany. Jego celem jest zapobiegnięcie niepotrzebnemu ładowaniu modułu, jeżeli użytkownik i tak nie ma do niego dostępu.

type CanLoadFn = (route: Route, segments: UrlSegment[]) => 
	MaybeAsync<GuardResult>

Powodem, dla którego nie powinien być on używany jest to, że lazy-loading powinien służyć jedynie jako metoda optymalizacji, a nie jako element architektury decydujący, czy moduł może zostać załadowany na podstawie wewnętrznego stanu aplikacji. Ponadto nie jest on tak uniwersalny jak CanMatch, ponieważ działa wyłącznie w parze z lazy-loadingiem i tylko dla modułów.

Resolver

Ściśle rzecz biorąc resolver nie jest guardem często opisywany jest wraz z nimi, ponieważ pozwala na wstępne pobieranie danych podczas nawigacji pomiędzy stronami. Router czeka na ich pobranie zanim otworzy stronę, a jeżeli wystąpi jakiś błąd umożliwia przekierowanie za pomocą RedirectCommand.

type ResolveFn<T> = (
  route: ActivatedRouteSnapshot,
  state: RouterStateSnapshot,
) => MaybeAsync<T | RedirectCommand>

Stwórzmy teraz resolver pobierający dane użytkownika na podstawie jego id uzyskanego z adresu:

const userResolver: ResolveFn<User> = (route: ActivatedRouteSnapshot) => {
  const router = inject(Router);
  return inject(UserService)
    .getUser(route.paramMap.get('id') ?? '')
    .pipe(
      catchError(() =>
        of(
          new RedirectCommand(router.createUrlTree(['/not-found']), {
            skipLocationChange: true,
          }),
        ),
      ),
    );
};

Resolvery nie są definiowane w tablice a w obiekcie przypisanym do pola resolve:

{ 
   path: ‘user/:id’,
   component: UserDetailsComponent, 
   resolve: { user: userResolver } 
}

Klucz skojarzony z danym resolverem służy do odczytania pobranych przez niego danych:

@Component({ ... })
export class UserDetailsComponent {
   readonly user$: = inject(ActivatedRoute).data.pipe(
      map(data => data?.user)
   );
}

Czasami musimy pobrać dane z wielu źródeł, zwykle dla kilku komponentów na stronie. Wyświetlanie animacji ładowania dla każdego z osobna może być rozpraszające zwłaszcza, że każdy z tych procesów może zakończyć się w innym momencie. Zamiast tego wolelibyśmy wstępnie pobrać niezbędne dane i płynnie wyświetlić stronę. Brzmi to jak idealne zadanie dla resolverów:

{
   path: dashboard,
   component: DashboardComponent,
   resolve: {
      user: userResolver,
      orders: ordersResolver,
      payments: paymantsResolver
   }
}

Tym sposobem strona zostanie otwarta w momencie, w którym wszystkie dane zostaną załadowane. Jednak do tego czasu użytkownik czeka na poprzedniej stronie. Dobrym pomysłem byłoby poinformowanie go trwających procesach. Tutaj użyteczne są eventy Routera, pozwalając na stworzenie funkcji wskazującej, czy resolvery właśnie pobierają dane:

// Use only inside the injection context
export function isResolveInProgress(): Observable<boolean> => 
   inject(Router).events.pipe(
      filter(e => e instanceof ResolveStart || e instanceof ResolveEnd),
      map(e => e instanceof ResolveStart)
   )

Nie przejmuj się tym, że używamy wielu resolverów. Te eventy wywoływane są tylko raz podczas jednej nawigacji – ResolveStart podczas próby otwarcia nowej strony oraz ResolveEnd kiedy wszystkie resolvery zakończyły działanie.

Ponowne wywoływanie guardów i resolverów

Guardy i resolvery są wywoływane zawsze kiedy route jest aktywowany lub opuszczany. Jednak kiedy się nie zmienia możesz zdecydować czy chcesz je ponownie wywołać poprzez pole runGuardsAndResolvers

Jeżeli potrzebujesz jeszcze więcej elastyczności możesz również przekazać funkcję:

type RunGuardsAndResolversFn = (
  from: ActivatedRouteSnapshot,
  to: ActivatedRouteSnapshot
) => boolean

Ta funkcjonalność jest wykorzystywana raczej rzadko, ale może być przydatna do synchronizacji zmian w UI z adresem URL (jak zmiana sortowania w tabeli) bez niepotrzebnego wywoływania gardów i resolverów.

Ustawienie tytułu strony

Tytuł strony to tekst widoczny w zakładce karty w przeglądarki zaraz obok ikonki. Ustawienie sensownego tytułu pozytywnie wpływa na SEO.

Podstawowym sposobem jest podanie wartości do pola title w definicji routa, jednak użycie statycznej wartości nie jest najwygodniejszym sposobem i często musimy stworzyć tytuł na podstawie jakiejś zmiennej. W takim przypadku Angular pozwala użyć resolvera, aby dynamicznie utworzyć tytuł:

const productNameTitleResolver: ResolveFn<string> = (
  route: ActivatedRouteSnapshot,
): string => {
  const productId = route.paramMap.get('id');
  return productId ? inject(ProductsService).getById(productId).name : '';
};

export const ROUTES: Routes = [
  {
    path: 'products',
    component: ProductsComponent,
    title: 'Products',
    children: [
      {
        path: ':id',
        component: ProductDetailsComponent,
        title: productNameTitleResolver
      },
    ],
  },
  {
    path: 'cart',
    component: CartSummaryComponent,
    title: 'Cart Summary'
  },
];

Jeżeli masz generyczny fragment tytułu, taki jak nazwa aplikacji, który powinien być zawarty na wielu (lub wszystkich) stronach, możesz zdefiniować własną strategię za pomocą serwisu rozszerzającego klasę TitleStrategy:

abstract class TitleStrategy {
  abstract updateTitle(snapshot: RouterStateSnapshot): void;
  buildTitle(snapshot: RouterStateSnapshot): string;
  getResolvedTitleForRoute(snapshot: ActivatedRouteSnapshot): any;
}

Nasza strategia polega na dodaniu nazwy aplikacji na początku istniejącego już tytułu. Aby odczytać ten tytuł należy użyć metody buildTitle. Ustawienie nowego tytułu jest możliwe za pomocą serwisu Title.

@Injectable()
export class AppNameTitleStrategy extends TitleStrategy {
  private readonly titleService = inject(Title);

  override updateTitle(snapshot: RouterStateSnapshot) {
    const routeTitle = this.buildTitle(snapshot);
    this.titleService.setTitle(routeTitle ? `MyApp | ${routeTitle}` : 'MyApp');
  }
}

Teraz wystarczy tylko zarejestrować nową strategię jako provider. W tym przypadku chcemy, aby działała ona globalnie:

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(ROUTES),
    { provide: TitleStrategy, useClass: AppNameTitleStrategy },
  ],
});

Route provider

Od wersji 14 Angulara możliwe jest definiowanie providera na poziomie routu. W ten sposób zależność przechowywana przez Environment Injector. Ten typ Injectora jest tworzony przy okazji tworzenia dynamicznie ładowanych komponentów. W skrócie providowane tam zależności są dostępne dla komponentu przypisanego do routa oraz jego potomków. Jeżeli chcesz dowiedzieć się więcej na temat Dependency Injection sprawdź nasz inny artykuł

Rozszerzenia (Router Features)

Funkcja provideRouter oprócz samej konfiguracji routingu przyjmuje również funkcje rozszerzeń umożliwiające korzystanie z różnych funkcjonalności. W aplikacjach opartych na modułach są one aktywowane poprzez obiekt opcji przekazywany jako drugi argument statycznej metody forRoot klasy RouterModule. Przyjrzyjmy się możliwościom, które oferują.

Component Input Binding

Jest to niezwykle użyteczna funkcjonalność wprowadzona w wersji 16, która pozwala na powiązanie parametrów związanych z routingiem, takich jak parametry, query params czy dane pobrane przez resolvery, bezpośrednio z inputami komponentu.

Aby z niej skorzystać wywołaj funkcję withComponentInputBinding w provideRouter:

provideRouter(ROUTES, withComponentInputBinding())

Skorzystajmy z przykładowej definicji:

{
  path: ':id',
  data: { description: 'Customer profile page' },
  resolve: { customer: customerResolver },
  component: CustomerProfileComponent,
}

i załóżmy, że komponent CustomerProfileComponent wyświetla tabelę z zamówieniami klienta – możemy więc spodziewać się query paramsów “page” i “size” do obsługi jej paginacji.

Bez ten funkcjonalności musielibyśmy stworzyć wszystkie te pola ręcznie:

@Component { … }
export class CustomerIdComponent {
  private readonly route = inject(ActivatedRoute);

  readonly customerId$ = this.route.paramMap.pipe(
    map((paramMap) => paramMap.get('id')),
  );

  readonly customer$ = this.route.data.pipe(
    map((data) => data['customer']),
    map((customer) => (isCustomerType(customer) ? customer : null)),
  );

  readonly page$ = this.route.queryParamMap.pipe(
    map((queryParamMap) => queryParamMap.get('page')),
    map((page) => (page ? parseInt(page) : null)),
  );

  readonly size$ = this.route.queryParamMap.pipe(
    map((queryParamMap) => queryParamMap.get('size')),
    map((size) => (size ? parseInt(size) : null)),
  );

  readonly description$ = this.route.data.pipe(
    map(data => data['description']),
  );
}

Jednak korzystając z takiego powiązania możemy znacznie zredukować ilość kodu:

@Component { … }
export class CustomerIdComponent {
  customerId = input<string | undefined>(undefined, { alias: 'id' });
  customer = input<Customer | undefined, unknown>(undefined, {
    transform: (customer: unknown) =>
      isCustomerType(customer) ? customer : undefined,
  });
  page = input(undefined, { transform: numberAttribute });
  size = input(undefined, { transform: numberAttribute });
  description = input<string>();
}

Warto jednak pamiętać, że Angular robi to automatycznie nie biorąc pod uwagę typu zmiennych, dlatego dobrym pomysłem jest skorzystanie z funkcji transformujących wartość inputa, aby uniknąć błędów podczas działania aplikacji.

Opcje konfiguracji

Funkcja withRouterConfig służy do opakowania obiektu konfiguracji, dla których nie istnieją osobne rozszerzenia.

canceledNavigationResolution

Definiuje sposób w jaki router powinien przywrócić stan kiedy nawigacja jest anulowana:

  • “replace” (domyślnie) – ustawia stan przeglądarki na stan router przed rozpoczęciem nawigacji – router zamienia element w historii przeglądarki zamiast próbować wrócić do poprzedniego
  • “computed” – router stara się przywrócić stan historii przeglądarki do momentu zanim nawigacja została anulowana

onSameUrlNavigation

Definiuje kiedy router powinien zmodyfikować adres w przeglądarce:

  • “deffered” (domyślnie) – po udanej nawigacji
  • “eager” – na początku nawigacji. Pozwala to na obsługę błędu z użyciem adresu nawigacji, przy której nastąpił

onSameUrlNavigation

Określa jak obsłużyć nawigację pod ten sam adres:

  • “ignore” (domyślny) – router ignoruje nawigację
  • “reload” – router normalnie przeprowadza proces nawigacji, co może być przydatne do wywołania przekierowania, guardu lub resolvera na podstawie wewnętrznego stanu, który mógł ulec zmianie. Pamiętaj jednak, że nawet używając opcji “reload” Angular domyślnie skorzysta z utworzonej już instancji komponentu

paramsInheritanceStrategy

Definiuje w jaki sposób router łączy parametry:

  • “emptyOnly” (domyślny) – potomny route dziedziczy parametry z rodzica tylko w przypadku, gdy jego ścieżka jest pusta lub nie ma przypisanego komponentu (component-less route)
  • “always” – potomny route dziedziczy wszystkie parametry swoich przodków

Wstępne ładowanie

Omówiliśmy już jak działa lazy-loading i dlaczego jest to tak ważny wzorzec. Jednak czasami wiemy z góry, że użytkownik będzie musiał załadować komponent czy moduł np. jeżeli zawiera kluczową funkcjonalność. W takim przypadku przydatne może być jego wstępne załadowanie, aby uniknąć wystąpienia opóźnień.

Aby dostosować strategię wstępnego załadowania należy użyć funkcji withPreloading i przekazać jako parametr obiekt klasy rozszerzającej klasę PreloadingStrategy:

abstract class PreloadingStrategy {
  abstract preload(route: Route, fn: () => Observable<any>): Observable<any>
} 

Angular oferuje dwie predefiniowane strategie:

  • NoPreloading (domyślna) – nie wykonuje wstępnego ładowania
  • PreloadAllModules – wstępnie ładuje wszystkie chunki

Oczywiście możemy stworzyć własną strategię np. opartą na fladze przekazywanej do obiektu data dla kluczowych funkcjonalności:

@Injectable({ providedIn: 'root' })
export class FlagBasedPreloadingStrategy extends PreloadingStrategy {
  override preload(
    route: Route,
    preload: () => Observable<any>,
  ): Observable<any> {
    return route.data?.['preload'] === true ? preload() : of(null);
  }
}

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(
      [
	  ...,
        {
          path: 'dashboard',
          loadComponent: () =>
            import('./dashboard.component').then((m) => m.DashboardComponent),
          data: { preload: true },
        },
      ],
      withPreloading(FlagBasedPreloadingStrategy),
    ),
  ],
});

Scrolling Memory

Wyobraźmy sobie użytkownika, który scrolluje listę i klika w jeden z elementów, aby otworzyć stronę ze szczegółami. Miłym doświadczeniem byłby powrót do poprzedniej pozycji strony kiedy wróci do listy. Na takie zachowanie pozwala użycie funkcji withInMemoryScrolling. Jako argumenty przyjmuje ona obiekt z dwoma właściwościami:

  • anchorScrolling – jeżeli jest ustawione na “enabled” i adres zawiera fragment (#) strona jest scrollowana do jego pozycji
  • scrollPositionRestoration – konfiguruje pozycję strony podczas nawigacji wstecz. Dostępne są trzy opcje:
    • “disabled” – bez zmian
    • “top” – początek strony
    • “enabled” – przywraca poprzednią pozycję strony

Takie działanie jest możliwe dzięki eventowi Scroll, który przechowuje pozycję strony. Zazwyczaj jednak dane do wspomnianej listy są pobierane z serwera, a ponieważ są one dostarczone z pewnym opóźnieniem, efekt ten nie działa. Dzieję się tak ponieważ event Scroll jest wywoływany bezpośrednio po NavigationEnd. Aby to naprawić możemy stworzyć serwis, który przechowuje pozycję strony i przywraca ją w odpowiedniej chwili. 

type ScrollPosition = [number, number];

@Injectable({ providedIn: 'root' })
export class ScrollRestorationService {
  private readonly viewportScroller = inject(ViewportScroller);

  private readonly position = toSignal(
    inject(Router).events.pipe(
      filter((event): event is Scroll => event instanceof Scroll),
      map((scrollEvent): Scroll => scrollEvent.position ?? [0, 0]),
    ),
    { initialValue: [0, 0] },
  );

  restoreScrollingPosition() {
    this.viewportScroller.scrollToPosition(this.position());
  }
}

Teraz jedyne co pozostało zrobić to wywołanie metody restoreScrollingPosition w momencie kiedy lista zostanie wyrenderowana.

View Transition

View Transition API to świetne narzędzie, które pozwala na tworzenie płynnych przejść pomiędzy stronami podczas nawigacji. Dzięki funkcji withViewTransition nie musisz przeprowadzać ręcznej konfiguracji i skupić się na tworzeniu animacji.

Jako argument przyjmuje ona obiekt konfiguracji z dwiema właściwościami:

  • skipInitialTransition – wyłącza animację podczas ładowania strony
  • onViewTransitionCreated – callback z obiektem ViewTransitionInfo jako paramterem, który pozwala na większą swobodę konfiguracji jak pominięcie animacji jeżeli tylko query param uległ zmianie

Obsługa błędu nawigacji

Użycie withNavigationErrorHandler pozwala na zdefiniowanie funkcji obsługi błędu nawigacji. Funkcja ta jest wywoływana wewnątrz injection context, co pozwala na użycie serwisów w jej wnętrzu. Dodatkowo możesz przekonwertować błąd nawigacji na przekierowanie zwracając RedirectCommand. Zwrócenie innej wartości jest ignorowane, a router obsługuje ten błąd w domyślny sposób. 

provideRouter(
  ROUTES,
  withNavigationErrorHandler((error: NavigationError) => {
    inject(LoggerService).log(error.toString());
    return new RedirectCommand(inject(Router).parseUrl('/error'), {
      skipLocationChange: true,
    });
  }),
)

Początkowa nawigacja 

Czasami konieczne jest zdefiniowanie kiedy router wykona początkową nawigację. Domyślne zachowanie (“enabledNonBlocking”) to wykonanie jej zaraz po utworzenie root komponentu. Bootstrap nie jest zablokowany

Możesz również ją zablokować (“enabledBlocking”), używając funkcji withEnabledBlockingInitialNavigation do czasu kiedy root komponent jest utworzony a bootstrap zablokowany do czasu kiedy początkowa nawigacja zostanie zakończona. Użycie tej funkcji jest wymagane przy SSR.

Kolejną opcją jest zablokowanie początkowej nawigacji (“disbaled”) używając funkcji withDisabledInitialNavigation, dzięki czemu możesz zyskać więcej kontroli nad tym procesem w przypadku skomplikowanej logiki inicjalizacji.

Location strategy

W aplikacjach SPA pozwalamy przeglądarce na zarządzanie routingiem zamiast wysyłać żądania do serwera, aby pobrać stronę, kiedy adres ulegnie zmianie. Angular może zarządzać adresem na dwa sposoby.

Domyślnym jest PathLocationStrategy, który korzysta z History API (wymagane jest wsparcie HTML5 przez przeglądarkę). Korzystając z funkcji pushState zmienia adres bez interakcji z serwerem. Tym sposobem dostajemy adres URL, który może być udostępniany lub zapisywany.

Niestety to rozwiązanie ma pewną wadę: jeśli załadujemy stronę z adresem np. “my-app/users/123/orders” przeglądarka wyśle żądanie do serwera właśnie z tym adresem. Aby korzystać z PathLocationStrategy serwer musi zwracać plik index.html dla każdego adresu.

Ponadto musimy powiedzieć przeglądarce, co powinno być prefiksowane do żądanej ścieżki, aby wygenerować URL. Można to zrobić, określając “base href” w sekcji head pliku index.html:

<base href=’your/prefix’ />

lub podając wartość dla tokenu APP_BASE_HREF.

Inną opcją jest HashLocationStrategy. Aby ją włączyć, użyj funkcji withHashLocation. Ta strategia używa fragmentów hash, a więc części URL poprzedzonej znakiem hash (#), a więc URL wyglądałaby następująco: „my-app/#/users/123/orders”.

Przez wiele lat była to główna metoda obsługi routingu po stronie klienta, ponieważ fragment hash nigdy nie jest wysyłany do serwera i przechowuje stan aplikacji po stronie klienta. W tym przykładzie serwer widzi tylko jeden URL („my-app”), a fragment hash („users/123/orders”) jest używany tylko przez Router.

Obecnie powinieneś używać HashLocationStrategy tylko wtedy, gdy musisz wspierać starsze przeglądarki.

Debugowanie

Wywołanie funkcji withDebugTracing powoduje logowanie wszystkich eventów routera do konsoli, co może być pomocne podczas debugowania.

Używanie routingu w komponentach

Po omówienie konfiguracji czas najwyższy czas wykorzystać routing w komponentach

Po pierwsze, użytkownik powinien móc wygodnie poruszać się po aplikacji. Dodajmy więc dyrektywę routerLink do elementu w pliku HTML, aby umożliwić nawigowania po aplikacji poprzez interakcję z tym elementem (np. klikając na niego). Najczęściej używanym parametrem jest path, który możemy zdefiniować w następujący sposób:

  • tworząc statyczną ścieżkę
<a routerLink=’/users/123’> Link to user page </a>
  • używając zmiennej jako wartości parametru
<a [routerLink]=”[‘/users’, userId]”> Link to user page </a>
    • ../ – router poszukuje o jeden poziom wyżej.łącząc statyczne fragmenty w jedno wyrażenie

      <a [routerLink]=”[‘/settings/users’, userId]”> Link to user page </a>
      

      Ponieważ matrix param są częścią segmentu, te również mogą być zdefiniowane w tablicy ścieżki:

      <a [routerLink]=”[‘/users’, userId, {details: true}]”> Link to user page </a>
      

      Tak zdefiniowany router link (zakładając, że “userId” jest równe “123”) przekieruje tutaj na ścieżkę: “/users/123;details=true”

      Warto zwrócić uwagę na definiowanie ścieżek relatywnych. Możemy to osiągnąć poprzez odpowiednie użycie prefixu. Jeśli pierwszy segment zaczyna się od:

      • / – router wyszukuje ścieżki z poziomu ‘root
      • ./ lub bez prefixu  – router poszukuje na poziomie dziecka obecnego poziomu ścieżki

    Możemy również definiować queryParamsy poprzez przekazanie obiektu dla inputu queryParams:

    <a routerLink=”/users” [queryParams]=”{showInactive: showInactiveUsers, sortBy: ‘name’}”> Users </a>
    

    Ten link prowadzi do adresu: "/users?showInactive=true&sortBy=name". Jeżeli potrzebujesz nawigować między dwoma adresami URL zawierającymi query params, możesz określić sposób ich obsługi, używając inputu queryParamsHandling:

    • pusty ciąg znaków lub nieokreślony (domyślny) – zastępuje bieżące parametry nowymi
    • "merge" – łączy nowe parametry z bieżącymi
    • "preserve" – zachowuje bieżące parametry

    Inną częścią URL jest fragment, który można zdefiniować, używając inputu fragment:

    <a [routerLink]="['/products', productId]" [queryParams]="{currency: 'EUR'}" fragment="pricing">Product</a>
    

    Ten link prowadzi do adresu: "/products/123?currency=EUR#pricing". Domyślnie fragmenty są zastępowane podczas nawigacji. Jeśli chcesz zachować bieżący fragment, użyj inputu preserveFragment.

    Oprócz opcji tworzenia URL, możesz także użyć innych inputów związanych z zachowaniem nawigacji:

    • relativeTo (ActivatedRoute) – określa korzeń dla względnej nawigacji
    • skipLocationChange (boolean) – nawigacja bez dodawania nowego stanu do historii (URL pozostaje niezmieniony)
    • replaceUrl (boolean) – nawigacja zastępująca bieżący stan w historii
    • state (object) – wartość, która ma być zapisana w właściwości History.state przeglądarki. Można ją pobrać z obiektu extras zwróconego przez metodę getCurrentNavigation
    • info (unknown) – używane do przekazywania tymczasowych informacji o danej nawigacji. Jest przypisane do bieżącej nawigacji, więc różni się od zapisywanej wartości stanu

    RouterLinkActive

    Dla lepszego UX użytkownik powinien wiedzieć, który link jest związany z aktywnym adresem. Dyrektywa routerLinkActive pozwala określić klasy CSS stosowane do elementu, gdy powiązana adres jest aktywny:

    <a routerLink="/users" routerLinkActive="class1 class2">Users</a>
    <a routerLink="/orders" [routerLinkActive]="['class1', 'class2']">Orders</a>
    

    Aby bezpośrednio sprawdzić status linku, możesz przypisać instancję RouterLinkActive do zmiennej w templacie:

    <a routerLink="/users" routerLinkActive #active="routerLinkActive">Users {{ active.isActive ? '(active)' : '' }}</a>
    

    Dyrektywa ta także udostępnia output, aby powiadomić, gdy link staje się aktywny lub nieaktywny:

    <a routerLink="/users" routerLinkActive="active-link" (isActiveChange)="onLinkActiveChange($event)">Users</a>
    

    Aby skonfigurować, jak określić, czy link jest aktywny, użyj inputu routerLinkActiveOptions, który przyjmuje obiekt konfiguracji:

    interface IsActiveMatchOptions {
      matrixParams: "exact" | "subset" | "ignored";
      queryParams: "exact" | "subset" | "ignored";
      paths: "exact" | "subset";
      fragment: "exact" | "ignored";
    }

    Jak możesz zauważyć, właściwości mogą mieć następujące wartości:

    • "exact" – parametry muszą dokładnie sobie odpowiadać
    • "subset" – parametry mogą zawierać dodatkowe elementy, ale muszą odpowiadać istniejącym w bieżącym URL
    • "ignored" – parametr jest ignorowany

    Oczywiście nie musisz każdorazowo jawnie definiować wszystkich parametrów. Jeśli przekażesz obiekt {exact: true}, jest to odpowiednik:

    const exactMatchOptions: IsActiveMatchOptions = {
      paths: 'exact',
      fragment: 'ignored',
      matrixParams: 'ignored',
      queryParams: 'exact',
    }
    

    Jeśli przekażesz obiekt {exact: false} lub nic nie przekażesz do wejścia, wynikiem jest następujący kształt obiektu konfiguracji:

    const subsetMatchOptions: IsActiveMatchOptions = {
      paths: 'subset',
      fragment: 'ignored',
      matrixParams: 'ignored',
      queryParams: 'subset',
    }
    

    RouterOutlet

    Teraz użytkownik może nawigować po aplikacji, używając linków, więc musimy wyświetlić wybraną stronę. Dyrektywa RouterOutlet wstawia komponent dopasowany do URL:

    <nav>
      <ul>
        <li><a routerLink="/users" routerLinkActive="active">Users</a></li>
        <li><a routerLink="/orders" routerLinkActive="active">Orders</a></li>
      </ul>
    </nav>
    
    <!-- Wyświetla UserComponent jeśli "/users" pasuje do URL -->
    <!-- Wyświetla OrdersComponent jeśli "/orders" pasuje do URL -->
    <router-outlet></router-outlet>
    

    Dyrektywa RouterOutlet udostępnia cztery outputy:

    • activate – emituje, gdy instancjonowany jest nowy komponent
    • deactivate – emituje, gdy komponent jest niszczony
    • attach – emituje, gdy dołączona jest instancja komponentu, gdy strategia ponownego użycia nakazuje ponowne dołączenie wcześniej odłączonego poddrzewa
    • detach – emituje, gdy odłączana jest instancja komponentu, gdy strategia ponownego użycia nakazuje odłączyć poddrzewo

    Nawigacja z wieloma outletami przy użyciu named outlet

    Każdy outlet może mieć unikalną nazwę zdefiniowaną przez input name. Nazwa musi być statyczną wartością. Jeśli nie jest ustawiona, domyślną nazwą jest "primary". W większości przypadków nie zależy nam na nazwie, ponieważ używamy tylko jednego outletu w komponencie.

    Jednakże możesz zdefiniować wiele outletów w jednym komponencie, nadać im nazwy i utworzyć osobne gałęzie w drzewie nawigacji, ponieważ każdy outlet jest niezależny od innych. Stwórzmy prosty przykład i załóżmy, że dzielimy widok na pół:

<div class="users-container">
   <!-- Musisz mieć jeden primary outlet -->
   <router-outlet></router-outlet>
</div>
<div class="orders-container">
   <router-outlet name="orders"></router-outlet>
</div>

Następnie przypisz nazwę outletu do pola outlet, aby je rozróżnić:

const ROUTES: Routes = [
  {
    path: 'users',
    component: UsersSectionComponent,
    children: [
      { path: '', component: UsersListComponent },
      { path: ':id', component: UserDetailsComponent },
    ],
  },
  {
    path: 'orders',
    component: OrdersSectionComponent,
    outlet: 'orders',
    children: [
      { path: '', component: OrdersListComponent },
      { path: ':id', component: OrderDetailsComponent },
    ],
  }
];

Ponieważ sekcja użytkowników jest wyświetlana przez primary outlet, nawigacja działa jak zwykle. Ale dla sekcji zamówień dyrektywa RouterLink wymaga pewnych dostosowań. Aby zdefiniować link do nawigacji przez nazwany outlet, użyj obiektu konfiguracji, który zawiera właściwość outlets. Ta właściwość definiuje pary klucz-wartość z nazwą outletu i tablicą ścieżek (tak jak zazwyczaj używa się RouterLink):

<a [routerLink]="['', {outlets: { orders: ['orders', order.id] } }]">#{{order.id}}</a>

Ten link ma otworzyć szczegóły zamówienia w sekcji zamówień bez zmiany strony w sekcji użytkowników.

Ciekawą rzeczą jest tutaj kształt URL. Część związana z primary outlet nie różni się, ale część URL związana z nazwanym outletem jest wewnątrz nawiasów poprzedzona nazwą outletu. W tym przykładzie otwarcie szczegółów użytkownika w lewej sekcji i szczegółów zamówienia w prawej sekcji skutkowałoby URL o następującym kształcie: my-app/users/123(orders:orders/345)

Router Service

Opisane dyrektywy są przydatne do obsługi nawigacji za pomocą elementów templatu, ale czasami musimy wykonywać bardziej złożoną logikę lub korzystać z nawigacji w serwisie lub funkcji. Aby wchodzić w interakcje z routerem w takich przypadkach, używamy serwisu Router.

Najbardziej przydatną funkcjonalnością jest oczywiście wykonywanie nawigacji. Można to zrobić za pomocą dwóch metod. Pierwsza to navigate, której konfiguracja jest bardzo podobna do dyrektywy RouterLink. Pierwszym argumentem jest tablica komend (segmentów URL) – jak input routerLink dyrektywy. Drugi argument to obiekt NavigationExtras zawierający wszystkie parametry, które mogą być zdefiniowane przez inne wejścia RouterLink, takie jakqueryParams, fragment, obsługa parametrów itp.

@Component({ ... })
export class SomeComponent {
  private readonly router = inject(Router);

  navigateToUserOrders(userId: string) {
    this.router.navigate(['users', userId, 'orders'], {
      queryParams: { showCompletedOrders: true },
    });
  }
}

Druga metoda to navigateByUrl, która przyjmuje URL i obiekt NavigationBehaviorOptions. URL musi być stringiem lub UrlTree, więc zawiera wszystkie informacje o miejscu docelowym:

@Component({ ... })
export class SomeComponent {
  private readonly router = inject(Router);

  navigateToUserOrders(userId: string) {
    const url = `/users/${userId}/orders?showCompletedOrders=true`;
    this.router.navigateByUrl(url);
  }
}

Serwis Router daje nam także bardzo przydatne metody narzędziowe do transformacji URL:

  • serializeUrl przekształca UrlTree w string
  • parseUrl przekształca string w UrlTree
  • createUrlTree tworzy UrlTree z tablicy segmentów

Jak możesz już zauważyć, nawigacja jest skomplikowanym procesem, podczas którego wiele się dzieje. Każdy krok tego procesu jest reprezentowany przez RouterEvent. Jeśli chcesz śledzić te enety, aby na nie reagować, serwis Router daje dostęp do ich strumienia przez właściwość events.

Eventy routera w kolejności wywoływania:

  • NavigationStart – nawigacja jest inicjowana. Zawiera informacje o tym, co wywołało nawigację (navigationTrigger):

  – "imperative" – wywołane za pomocą metod Router (Router zawsze nawigacje do przodu)

   – "popstate" – wywołane przez specyficzną akcję przeglądarki, jak kliknięcie przycisku wstecz przeglądarki, użycie obiektu window.history lub serwisu Location (nawigacja do poprzedniego elementu w historii)

   – "hashchange" – wywołane przy zmianie fragmentu

  • RouteConfigLoadStart – tuż przed lazy-loadingiem konfiguracji
  • RouteConfigLoadEnd – konfiguracja została załadowana
  • RoutesRecognized – Router dopasował route do URL
  • GuardsCheckStart – rozpoczynają się kontrola przez guardy
  • ChildActivationStart – aktywacja potomnych routów
  • ActivationStart – aktywacja routu
  • GuardsCheckEnd – wszystkie guardy udzieliły dostępu
  • ResolveStart – tuż przed pobraniem danych przez resolvery
  • ResolveEnd – wszystkie resolvery zakończyły swoje zadanie
  • ChildActivationEnd – wywołane, gdy router kończy aktywację tras dzieci dla danej trasy
  • ActivationEnd – gdy router kończy aktywację routu
  • NavigationEnd – nawigacja zakończona sukcesem
  • Scroll – przywracanie pozycji przewijania 

Oczywiście nawigacja nie zawsze kończy się sukcesem, dlatego mamy do dyspozycji jeszcze dwa eventy:

  • NavigationCanceled – nawigacja anulowana z powodu odmowy dostępu (guard) lub przekierowania
  • NavigationError – wystąpienie błędu

Te eventy były już używane w kilku przykładach w tym artykule, ale najbardziej powszechnym jest implementacja wskaźnika ładowania:

function showNavigationLoadingIndicator(): Signal<boolean> {
  return toSignal(
    inject(Router).events.pipe(
      filter(
        (e) =>
          e instanceof NavigationStart ||
          e instanceof NavigationEnd ||
          e instanceof NavigationCancel ||
          e instanceof NavigationError
      ),
      map((e) => e instanceof NavigationStart),
      debounceTime(200),
      distinctUntilChanged(),
    ),
    { initialValue: false },
  );
}

Kolejną bardzo znaczącą właściwością jest routerState. Stan routera jest reprezentowany jako drzewo, gdzie każdy węzeł jest instancją ActivatedRoute. Używając jego właściwości, możesz przechodzić przez drzewo z dowolnego węzła. Skoro o tym mowa…

ActivatedRoute Service

Serwis ActivatedRoute jest prawdziwą skarbnicą wiedzy o roucie powiązanym z komponentem załadowanym w outlecie. Dostarcza wszystkie informacje o URL (url, params i paramMap, queryParams i queryParamsMap, fragment, data), konfiguracji routu (title, routeConfig) oraz jego pozycji w drzewie routera (root, parent, firstChild, children, pathFromRoot).

Właściwości związane z URL są Observable, ponieważ mogą zmieniać się w czasie. Jeśli potrzebujesz statycznych wartości, użyj właściwości snapshot. Klasa ActivatedRouteSnapshot ma ten sam kształt i przechowuje najnowsze wartości z tych Observable.

Konfiguracja Injection Tokenów

Częstym wzorcem w Angularze jest przekazywanie konfiguracji przez dostarczanie jej jako Injection Token. Dependency Injection oferuje dużo elastyczności i zdecydowanie warto z tego korzystać. Używaliśmy już tego wzorca do obsługi tytułu, ale to nie jedyny token, którego możemy użyć.

UrlSerializer

Serializowanie i deserializowanie URL może być łatwo konfigurowane przy użyciu UrlSerializer. Na przykład: chcemy zamienić kod znaku spacji (%20) na znak plusa (+). Wszystko, co musimy zrobić, to stworzyć własny serializer:

class CustomUrlSerializer implements UrlSerializer {
  private readonly defaultUrlSerializer = new DefaultUrlSerializer();

  parse(url: string): UrlTree {
    return this.defaultUrlSerializer.parse(url.replace(/\+/g, '%20'));
  }

  serialize(tree: UrlTree): string {
    return this.defaultUrlSerializer.serialize(tree).replace(/%20/g, '+');
  }
}

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(ROUTES),
    { provide: UrlSerializer, useClass: CustomUrlSerializer },
  ],
});

Teraz użycie "my param" jako wartości query param skutkuje adresem "some-url?query=my+param" zamiast "some-url?query=my%20param".

RouteReuseStrategy

Nawigowanie między stronami oznacza, że Angular musi usunąć obecnie wyświetlany komponent z DOM, zniszczyć jego instancję, a następnie utworzyć nowy komponent do wyświetlenia w DOM. Jest to kosztowny proces, ponieważ wymaga wykonania skryptu JS. Domyślnie dzieje się to przy każdej nawigacji, z wyjątkiem nawigacji do tej samej trasy, i może prowadzić do problemów z wydajnością, jeśli rozmiar komponentu jest duży.

Aby rozwiązać ten problem, Angular pozwala na zdefiniowanie niestandardowej strategii ponownego użycia komponentu, pozwalając na jego przechowanie zamiast niszczyć go i ponownie tworzyć przy ponownym otwarciu strony. To zachowanie można zdefiniować przez klasę implementującą RouteReuseStrategy. Zaimplementujmy strategię ponownego użycia opartą na fladze w definicji routu.

Definiuje ona pięć metod:

  • shouldDetach – określa, czy route powinien być odłączony, aby można było go później ponownie użyć
  • store – odpowiada za przechowywanie odłączonego routu
  • shouldAttach – określa, czy route może być odzyskany z pamięci
  • retrieve – zwraca instancję do ponownego użycia
  • shouldReuseRoute – decyduje, czy route powinna być ponownie użyty
export class FlagBasedReuseStrategy implements RouteReuseStrategy {
  private readonly storage = new Map<string, DetachedRouteHandle>();

  shouldDetach(route: ActivatedRouteSnapshot): boolean {
    return route.routeConfig?.data?.['reusable'];
  }

  store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle | null) {
    const routeComponentName = route.routeConfig?.component?.name;
    if (routeComponentName && handle)


      this.storage.set(routeComponentName, handle);
  }

  shouldAttach(route: ActivatedRouteSnapshot): boolean {
    return this.storage.has(route.routeConfig?.component?.name ?? '')
  }

  retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
    return this.storage.get(route.routeConfig?.component?.name ?? '') ?? null
  }

  shouldReuseRoute(
    future: ActivatedRouteSnapshot,
    curr: ActivatedRouteSnapshot,
  ): boolean {
    return (
      future.routeConfig === curr.routeConfig ||
      future.routeConfig?.data?.['reusable']
    );
  }
}

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(ROUTES),
    { provide: RouteReuseStrategy, useClass: FlagBasedReuseStrategy },
  ],
});

Zakończenie

Routing w Angularze jest kluczową funkcją, która wspiera rozwój dynamicznych aplikacji SPA. Opanowanie możliwości routingu w Angularze pozwala programistom na tworzenie wysoce interaktywnych i płynnych interfejsów, naśladujących wydajność i płynność aplikacji natywnych. W tym artykule przeanalizowaliśmy działanie routingu w Angularze, od podstawowych konfiguracji po zaawansowane techniki.

Zrozumienie i efektywne wdrażanie routingu w Angularze nie tylko poprawia wydajność i responsywność Twoich aplikacji, ale także zwiększa ich łatwość utrzymania i skalowalność. Dzięki zaawansowanym narzędziom oferowanym przez Angular, programiści mogą tworzyć skomplikowane rozwiązania routingu dostosowane do specyficznych potrzeb ich projektów.

Kontynuując rozwijanie swoich umiejętności w Angularze, eksperymentuj z konfiguracjami routingu i zaawansowanymi funkcjami, aby w pełni wykorzystać możliwości frameworka. Solidne opanowanie routingu umożliwi Ci tworzenie bardziej złożonych i przyjaznych użytkownikowi aplikacji, co pozwoli Ci stać się wszechstronnym programistą w dynamicznie zmieniającym się krajobrazie rozwoju webowego.

Podziel się artykułem

Zapisz się na nasz newsletter

Dołącz do community Angular.love i bądź na bieżąco z trendami.