Angular router: маршрутизація, охоронці та ледача завантаження
Angular Router зв'язує URL-шляхи з компонентами, контролює доступ через guards і завантажує модулі на вимогу за допомогою lazy loading.
Теорія
TL;DR
- Router - як готельна стійка реєстрації: зіставляє URL (номер кімнати) з компонентом (кімнатою), перевіряє токен (guard) перед входом, завантажує поверхи (модулі) лише коли гість прибуває
- Головна різниця: eager loading пакує все в один файл при старті; lazy loading ділить код на окремі чанки, які завантажуються при переході на відповідний маршрут
loadComponent- для одного standalone-компонента,loadChildren- для цілого файлу маршрутів- Wildcard
{ path: '**' }завжди повинен бути останнім - він ловить усе canMatch(Angular 14+) блокує завантаження чанку повністю;canActivateлише блокує рендер
Швидкий приклад
// app.routes.ts
export const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'admin',
canMatch: [authGuard], // блокує завантаження для неавторизованих
loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES)
},
{ path: '**', redirectTo: '' } // wildcard завжди останній
];<a routerLink="/" routerLinkActive="active">Головна</a>
<router-outlet></router-outlet>Переходиш на /admin авторизованим: адмін-чанк завантажується, guard пропускає, компонент рендериться. Переходиш без авторизації: guard спрацьовує, редирект на /login, чанк так і не завантажується.
Lazy loading проти eager loading
Eager loading пакує кожен компонент в один JS-файл при старті. 2MB бандл їде при кожному першому завантаженні, навіть якщо користувач ніколи не відкриє адмін-панель. Lazy loading ділить це на main.js (наприклад 100KB) і admin.js (300KB), який завантажується лише при переході на /admin. Для будь-якого застосунку з більш ніж 10-15 маршрутами різниця у часі першого відображення стає помітною.
loadComponent вказує на один standalone-компонент. loadChildren вказує на файл маршрутів з кількома sub-маршрутами, їхніми компонентами та guards. Використовуй loadChildren коли фіча має власне дерево маршрутів.
Параметри маршруту
export class UserDetailComponent {
private route = inject(ActivatedRoute);
ngOnInit() {
// Observable - правильно обробляє повторну навігацію на той самий компонент
this.route.paramMap.subscribe(params => {
const id = params.get('id')!;
this.loadUser(id);
});
}
}snapshot.paramMap.get('id') читає значення один раз при ініціалізації. Якщо користувач переходить з /users/1 на /users/2 і Angular перевикористовує той самий екземпляр компонента, ngOnInit більше не викликається і snapshot не оновлюється. Observable обробляє це коректно.
Guards
Guards виконують перевірки перед активацією маршруту. З Angular 14+ замість класових guards використовуються функціональні, що звертаються до сервісів через inject().
// auth.guard.ts
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
return authService.isLoggedIn()
? true
: router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
};Три типи guards, які зустрічаються найчастіше:
canActivate: запускається перед рендером компонента. Стандартна перевірка авторизації.canDeactivate: запускається при виході з маршруту. Використовується для попередження про незбережені зміни у формі.canMatch: запускається перед завантаженням lazy-чанку. Краще ніжcanActivateдля захищених lazy-маршрутів - неавторизовані користувачі взагалі не завантажують код модуля.
// canDeactivate приклад
export const unsavedChangesGuard: CanDeactivateFn<EditFormComponent> = (component) => {
if (component.hasUnsavedChanges()) {
return confirm('Покинути без збереження?');
}
return true;
};Resolvers
Resolver отримує дані перед рендером маршруту. Компонент отримує дані вже готовими через ActivatedRoute.snapshot.data.
// user.resolver.ts
export const userResolver: ResolveFn<User> = (route) => {
return inject(UserService).getUserById(route.paramMap.get('id')!);
};
// маршрути
{
path: 'users/:id',
component: UserDetailComponent,
resolve: { user: userResolver }
}
// компонент - дані вже готові
export class UserDetailComponent {
user = inject(ActivatedRoute).snapshot.data['user'] as User;
}Компроміс реальний: resolver затримує навігацію до отримання відповіді від API. На повільній мережі користувач довше дивиться на попередню сторінку. В більшості продакшн-проектів, де мені доводилось працювати, показ скелетону всередині компонента давав кращий UX - навігація відчувалась миттєвою, а завантаження відбувалось у фоні.
Вкладені маршрути
{
path: 'settings',
component: SettingsLayoutComponent,
children: [
{ path: '', redirectTo: 'profile', pathMatch: 'full' },
{ path: 'profile', component: ProfileSettingsComponent },
{ path: 'security', component: SecuritySettingsComponent },
]
}SettingsLayoutComponent повинен мати власний <router-outlet> для рендеру дочірніх компонентів. Outlet на рівні застосунку рендерить SettingsLayoutComponent, а вкладений outlet рендерить активну вкладку налаштувань.
Програмна навігація
export class AppComponent {
private router = inject(Router);
goToUser(id: string) {
this.router.navigate(['/users', id]);
}
search(term: string) {
this.router.navigate(['/search'], {
queryParams: { q: term, page: 1 }
});
}
}Типові помилки
Читання параметрів через snapshot у компоненті з повторною навігацією.
// неправильно - ламається при переході з /users/1 на /users/2
ngOnInit() {
const id = this.route.snapshot.paramMap.get('id');
}
// правильно
ngOnInit() {
this.route.paramMap.subscribe(params => {
this.id = params.get('id')!;
});
}Angular може перевикористати екземпляр компонента при навігації по тому ж маршруту. Snapshot не оновлюється. Observable - так.
Відсутність pathMatch: 'full' на порожніх маршрутах-редиректах.
// неправильно - /home теж збігається з '' за prefix-стратегією
{ path: '', redirectTo: 'home' }
// правильно
{ path: '', redirectTo: 'home', pathMatch: 'full' }Стратегія збігу за замовчуванням - префіксна, тому порожній шлях збігається з будь-яким URL. Це найпоширеніше питання по Angular-роутингу на Stack Overflow.
Використання canActivate замість canMatch на lazy-маршрутах.
// неправильно - чанк завантажується до запуску guard
{ path: 'admin', loadChildren: () => import('./admin'), canActivate: [authGuard] }
// правильно - guard спрацьовує перед завантаженням
{ path: 'admin', canMatch: [authGuard], loadChildren: () => import('./admin') }Wildcard у неправильному місці.
// неправильно - wildcard перехоплює все, інші маршрути не досягаються
{ path: '**', component: NotFoundComponent },
{ path: 'home', component: HomeComponent }, // мертвий код
// правильно
{ path: 'home', component: HomeComponent },
{ path: '**', component: NotFoundComponent },Де застосовується
- E-commerce checkout:
canActivateперевіряє, чи є товари у кошику перед переходом на/checkout - Адмін-панелі:
canMatchблокує завантаження адмін-бандлу для звичайних користувачів - Сторінки редагування профілю:
canDeactivateзапобігає втраті даних форми при випадковому переході - Багатокрокові майстри (wizards): resolvers завантажують необхідні дані перед першим кроком
- Великі SPA (патерн NGX-Admin): вкладені lazy-маршрути для шляхів на кшталт
/crm/clients/:id/orders
Питання на співбесіді
Q: У чому різниця між canActivate і canMatch?
A: canActivate запускається після завантаження lazy-чанку. canMatch - до. Для захищених lazy-маршрутів canMatch є кращим вибором: неавторизовані користувачі взагалі не завантажують код фічі.
Q: Коли варто використовувати resolver замість завантаження даних всередині компонента?
A: Resolver підходить, коли дані потрібні до рендеру - наприклад, заголовок сторінки або структура layout залежать від отриманих даних. Завантаження всередині компонента краще, коли API повільний і потрібно показати скелетон одразу.
Q: Як Angular обробляє оновлення сторінки (F5) на lazy-маршруті?
A: Router ініціалізується з поточного URL через APP_BASE_HREF, повторно завантажує відповідний lazy-чанк і виконує повний процес активації включно з guards. Без SSR нічого додаткового робити не потрібно.
Q: Навіщо потрібен патерн forRoot/forChild в NgModule-застосунках?
A: RouterModule.forRoot() реєструє провайдери роутера глобально і викликається рівно один раз. RouterModule.forChild() додає маршрути без повторної реєстрації провайдерів. Виклик forRoot всередині lazy-модуля створює другий екземпляр роутера і ламає навігацію.
Q: Як налагодити проблеми зі збігом маршрутів?
A: Додай enableTracing: true до RouterModule.forRoot() або withDebugTracing() в standalone-конфігурації. Router логуватиме кожен крок зіставлення в консоль - які маршрути перевірялись, що збіглося і чому решта була відхилена.
Приклади
Базове налаштування маршрутизації
export const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'about', component: AboutComponent },
{ path: 'users/:id', component: UserDetailComponent },
{ path: '**', component: NotFoundComponent }, // обов'язково останній
];<nav>
<a routerLink="/" routerLinkActive="active">Головна</a>
<a routerLink="/about" routerLinkActive="active">Про нас</a>
<a [routerLink]="['/users', user.id]">Профіль</a>
</nav>
<router-outlet></router-outlet>routerLinkActive додає CSS-клас, коли шлях посилання збігається з поточним URL. Синтаксис [routerLink] з масивом зручний для динамічних сегментів - не потрібно склеювати рядки.
Auth guard із ледаче завантаженим адмін-модулем
// auth.guard.ts
export const authGuard: CanActivateFn = (route, state) => {
const authService = inject(AuthService);
const router = inject(Router);
return authService.isLoggedIn()
? true
: router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
};
// app.routes.ts
export const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'login', component: LoginComponent },
{
path: 'admin',
canMatch: [authGuard],
loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES),
},
];
// admin/admin.routes.ts
export const ADMIN_ROUTES: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'dashboard',
loadComponent: () => import('./dashboard.component').then(m => m.DashboardComponent),
},
];Неавторизовані користувачі при переході на /admin отримують редирект на /login?returnUrl=/admin. Адмін-бандл так і не завантажується. Після входу читаємо returnUrl з query params і відправляємо користувача туди, куди він йшов.
Resolver із вкладеними маршрутами та canDeactivate
// user.resolver.ts
export const userResolver: ResolveFn<User> = (route) => {
return inject(UserService).getUser(route.paramMap.get('id')!);
};
// unsaved-changes.guard.ts
export const unsavedChangesGuard: CanDeactivateFn<UserEditComponent> = (component) => {
return component.hasUnsavedChanges() ? confirm('Покинути без збереження?') : true;
};
// user.routes.ts
export const USER_ROUTES: Routes = [
{
path: ':id',
component: UserShellComponent,
resolve: { user: userResolver },
children: [
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
{ path: 'overview', component: UserOverviewComponent },
{ path: 'edit', component: UserEditComponent, canDeactivate: [unsavedChangesGuard] },
],
},
];
// user-shell.component.ts
export class UserShellComponent {
user = inject(ActivatedRoute).snapshot.data['user'] as User;
// дані готові одразу, стан завантаження не потрібен
}UserShellComponent відображає ім'я користувача в спільному заголовку, який бачать обидва дочірні маршрути. Resolver отримує дані до завершення навігації. Guard canDeactivate на маршруті редагування запитує підтвердження при виході, якщо форма містить незбережені зміни.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.