โหมดมืด
บทที่ 4 — Routing
หลังจบบท คุณจะ:
- กำหนด route config (basic + nested + lazy — โครงสร้าง route)
- ใช้ router navigation + params (เปลี่ยนหน้าและอ่านค่าจาก URL)
- เขียน guards (ยามเฝ้า route) + resolvers (ดึงข้อมูลล่วงหน้า)
- จัดการ route data + auth flow (ระบบ login/logout พร้อม lazy loading)
ก่อนอ่าน: ควรผ่าน บทที่ 0 และ บทที่ 1 (สร้าง component เป็น) และควรรู้จัก Signals พื้นฐานก่อน เพราะบทนี้ใช้
signal(),computed(),effect(),toSignal(), และinput()หนักมาก
Prerequisites: ต้องมี Angular project สร้างอยู่แล้ว (เช่นจากบทที่ 0-1) · ไฟล์ที่จะแก้หลัก ๆ คือ
app.config.ts,app.routes.ts, และapp.component.html
0. Routing คืออะไร และทำไมต้องมี
เว็บสมัยใหม่ที่สร้างด้วย Angular เป็นแบบ SPA (Single Page Application — เว็บหน้าเดียว) — โหลด HTML หน้าเดียวตอนแรก แล้วหลังจากนั้น JavaScript สร้างและอัปเดตเนื้อหาใหม่เองโดยไม่โหลดหน้าใหม่ทั้งหน้า ทำให้ลื่นเหมือนแอป
ตรงข้ามกับเว็บแบบเก่า MPA (Multi-Page Application — เว็บหลายหน้า) ที่กดลิงก์ทีไรเบราว์เซอร์โหลด HTML + JS ใหม่ทั้งหน้า
แต่ปัญหาคือ — ถ้าไม่โหลดหน้าใหม่ แล้วจะ "เปลี่ยนหน้า" (เช่นจากหน้า Home ไปหน้า About) ยังไง? และจะทำให้ปุ่ม back/forward ของ browser กับการ bookmark URL ยังใช้ได้ยังไง?
Routing คือคำตอบ — มันคือระบบที่ จับคู่ URL กับ component: พอ URL เป็น /about ก็แสดง AboutComponent, พอเป็น /users/5 ก็แสดงหน้า user คนที่ 5 — โดยไม่โหลดหน้าใหม่จริง ๆ แต่ผู้ใช้รู้สึกเหมือนเปลี่ยนหน้า
🔍 เปรียบเทียบ: router เหมือน พนักงานต้อนรับในตึก — ลูกค้าบอกชั้นที่อยากไป (URL) พนักงานก็พาไปห้องที่ถูกต้อง (component) โดยไม่ต้องสร้างตึกใหม่ทุกครั้งที่เปลี่ยนชั้น
1. Setup
typescript
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes)
]
};typescript
// app.routes.ts
// สมมติว่าสร้าง component เหล่านี้ไว้แล้ว เช่น: ng generate component home
import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { AboutComponent } from './about/about.component';
import { UserListComponent } from './user-list/user-list.component';
import { NotFoundComponent } from './not-found/not-found.component';
export const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'about', component: AboutComponent },
{ path: 'users', component: UserListComponent },
{ path: '**', component: NotFoundComponent }
];html
<!-- app.component.html -->
<nav>
<a routerLink="/">Home</a>
<a routerLink="/about">About</a>
<a routerLink="/users">Users</a>
</nav>
<router-outlet></router-outlet>⚠️ Standalone component ต้อง import RouterOutlet และ RouterLink ก่อน — ถ้าไม่ใส่จะได้ error "RouterLink is not a known element":
typescriptimport { RouterOutlet, RouterLink } from '@angular/router'; @Component({ imports: [RouterOutlet, RouterLink], templateUrl: './app.component.html' }) export class AppComponent { }
→ <router-outlet> คือ "ช่องว่าง" ที่ Angular เอา component ของ route ปัจจุบันมาแสดง — ส่วนอื่นรอบ ๆ (เช่น <nav>) อยู่คงที่ เปลี่ยนแค่ตรงนี้ตาม URL
2. Route Config
routes คือ "ตารางจับคู่" ที่บอกว่า path ไหนแสดง component ไหน — รองรับ path ตายตัว, param แบบ :id, redirect, route ซ้อน (children), lazy load และ wildcard (**) สำหรับหน้า 404:
typescript
export const routes: Routes = [
// ค่าเริ่มต้น (path ว่าง)
{ path: '', component: HomeComponent },
// path คู่กับ component
{ path: 'about', component: AboutComponent },
// param ที่เปลี่ยนได้ (dynamic) เช่น users/5
{ path: 'users/:id', component: UserDetailComponent },
// หลาย param
{ path: 'posts/:postId/comments/:commentId', component: CommentComponent },
// เปลี่ยนเส้นทาง (redirect)
{ path: 'home', redirectTo: '/', pathMatch: 'full' },
// route ซ้อน (children)
{
path: 'admin',
component: AdminLayoutComponent,
children: [
{ path: '', component: AdminDashboardComponent },
{ path: 'users', component: AdminUsersComponent },
{ path: 'settings', component: AdminSettingsComponent }
]
},
// lazy load (โหลดทีหลังเมื่อเข้าถึง)
{
path: 'reports',
loadComponent: () => import('./reports/reports.component').then(m => m.ReportsComponent)
},
// lazy load ทั้งกลุ่ม route (โหลดเป็น section)
{
path: 'feature',
loadChildren: () => import('./feature/feature.routes').then(m => m.featureRoutes)
},
// wildcard ดักทุก path ที่ไม่ตรง (ทำหน้า 404)
{ path: '**', component: NotFoundComponent }
];3. routerLink — Navigation Links
ลิงก์ภายในแอป ห้ามใช้ href (มันจะโหลดหน้าใหม่ทั้งหน้า เสียข้อดีของ SPA) — ใช้ routerLink แทน Angular จะเปลี่ยนหน้าให้แบบไม่ reload · ส่ง path เป็น string หรือ array (สำหรับ param) ก็ได้:
html
<!-- path ตายตัว -->
<a routerLink="/">Home</a>
<a routerLink="/about">About</a>
<!-- path มี param ส่งเป็น array -->
<a [routerLink]="['/users', userId]">User Profile</a>
<a [routerLink]="['/posts', postId, 'comments', commentId]">Comment</a>
<!-- path แบบอ้างอิงตำแหน่งปัจจุบัน (relative) -->
<a routerLink="./edit">Edit (relative)</a>
<a routerLink="../">Parent</a>
<!-- พร้อม query params (ส่วนหลัง ?) -->
<a [routerLink]="['/search']" [queryParams]="{ q: 'angular', page: 1 }">Search</a>
<!-- พร้อม fragment (ส่วน #anchor) -->
<a [routerLink]="['/docs']" [fragment]="'section-2'">Section 2</a>
<!-- เก็บไว้ / รวม query params เดิม -->
<a [routerLink]="['/page']" queryParamsHandling="preserve">Keep params</a>
<a [routerLink]="['/page']" queryParamsHandling="merge">Add new params</a>routerLinkActive — Active Link Class
html
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">
Home
</a>
<a routerLink="/users" routerLinkActive="active">
Users
</a>scss
a.active {
font-weight: bold;
color: #1976d2;
}routerOutlet
html
<!-- outlet หลัก -->
<router-outlet></router-outlet>
<!-- outlet ที่มีชื่อ (สำหรับแสดงหลายส่วนพร้อมกัน) -->
<router-outlet name="sidebar"></router-outlet>
<router-outlet name="popup"></router-outlet>typescript
// ตั้งค่า
{
path: 'main',
component: MainComponent,
outlet: 'sidebar'
}4. Read Route Params
เมื่อ URL มี param (เช่น /users/5) component ต้องอ่านค่า id ออกมาใช้
มีสามวิธีหลัก — เรียงจากที่แนะนำไปหาที่เก่ากว่า:
- Input Binding (Angular 16+) — ผูก param เข้า
input()โดยตรง (สะอาดสุด ดูหัวข้อข้างล่าง) - Signal — อ่านผ่าน
ActivatedRoute+toSignal()(อัปเดตเมื่อ param เปลี่ยน) - Snapshot — อ่านครั้งเดียวตอนเข้าหน้า (ง่ายแต่ไม่อัปเดต)
📖
signal= ค่าที่อัปเดตอัตโนมัติและ Angular ติดตามได้ ·snapshot= อ่านค่า ณ ขณะนั้นครั้งเดียว — ดูบท 1 สำหรับ Signals พื้นฐาน
📖
input()= signal-based input ของ Angular 17+ ใช้รับค่าจาก parent หรือจาก route param ได้ — ต่างจาก HTML<input>ทั่วไป
typescript
import { Component, inject, computed, effect } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({...})
export class UserDetailComponent {
private route = inject(ActivatedRoute);
// แบบ signal (Angular 17+ + toSignal)
// 💡 toSignal = แปลง Observable → signal (ดูบท 2 §11.5 และบท 6)
// ✅ field initializer = injection context ใช้ toSignal() ได้
paramMap = toSignal(this.route.paramMap);
userId = computed(() => Number(this.paramMap()?.get('id') ?? 0));
constructor() {
effect(() => {
console.log('loading user', this.userId());
this.loadUser(this.userId());
});
}
}Snapshot (One-time Read — อ่านค่าครั้งเดียว)
typescript
@Component({...})
export class UserDetailComponent {
private route = inject(ActivatedRoute);
ngOnInit() {
const id = this.route.snapshot.paramMap.get('id');
// ⚠️ อ่านครั้งเดียว ไม่อัปเดตถ้า param เปลี่ยนใน route เดิม
}
}Observable (Old)
📖 Observable = สายข้อมูลที่ปล่อยค่าออกมาเรื่อย ๆ — ดูบท 6 สำหรับรายละเอียด
💡
.subscribe()= "ฟังค่าที่ออกจาก Observable" (ดูบท 6) ·takeUntilDestroyed()= auto-unsubscribe เมื่อ component ถูก destroy — ถ้าไม่ใส่จะ leak ทุกครั้งที่ navigate ออก
⚠️
takeUntilDestroyed()แบบไม่ส่ง argument ใช้ได้เฉพาะใน injection context เท่านั้น (constructor หรือ field initializer) · ถ้าอยู่ในngOnInit()ต้องส่งdestroyRefเข้าไปด้วย:takeUntilDestroyed(this.destroyRef)
typescript
import { Component, inject, DestroyRef } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({...})
export class UserDetailComponent {
private route = inject(ActivatedRoute);
private destroyRef = inject(DestroyRef); // inject ไว้ใน field (injection context)
ngOnInit() {
this.route.paramMap
.pipe(takeUntilDestroyed(this.destroyRef)) // ✅ ส่ง destroyRef เพราะอยู่ใน ngOnInit
.subscribe(params => {
const id = params.get('id');
this.loadUser(Number(id));
});
}
}→ ยุคใหม่ใช้ toSignal() หรือ input binding (ข้างบน) แทน — สั้นกว่า ไม่ต้อง manage subscription เอง
Input Binding จาก Route Param — ผูก param เข้า input() โดยตรง (Angular 16+)
typescript
// app.routes.ts
{
path: 'users/:id',
component: UserDetailComponent
}typescript
// app.config.ts
import { provideRouter, withComponentInputBinding } from '@angular/router';
provideRouter(routes, withComponentInputBinding())
// ⚠️ ต้องเพิ่ม withComponentInputBinding() ตรงนี้ก่อน — ไม่งั้น input('id') จะได้ undefined
// เป็นกับดักยอดฮิตของ feature นี้typescript
// component
import { Component, effect, input } from '@angular/core';
@Component({...})
export class UserDetailComponent {
id = input.required<string>(); // ⭐ ผูกอัตโนมัติจาก :id ใน URL
constructor() {
effect(() => {
this.loadUser(this.id());
});
}
}→ วิธีที่สะอาดที่สุดสำหรับอ่าน route param
5. Query Params
Query param คือส่วนหลัง ? ใน URL (เช่น ?q=angular&page=2) — เหมาะกับ filter/search/หน้าที่ไม่ใช่ส่วนระบุตัวตน · อ่านผ่าน queryParamMap และอัปเดตด้วย router.navigate (ใช้ queryParamsHandling: 'merge' เพื่อเก็บ param เดิม):
typescript
// ตัวอย่าง URL: /search?q=angular&page=2
@Component({...})
export class SearchComponent {
private route = inject(ActivatedRoute);
queryMap = toSignal(this.route.queryParamMap);
q = computed(() => this.queryMap()?.get('q') ?? '');
page = computed(() => Number(this.queryMap()?.get('page') ?? 1));
}Update Query Params
typescript
@Component({...})
export class SearchComponent {
private router = inject(Router);
private route = inject(ActivatedRoute);
onSearch(query: string) {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { q: query },
queryParamsHandling: 'merge' // เก็บ param ตัวอื่นไว้
});
}
nextPage() {
const current = this.page();
this.router.navigate([], {
relativeTo: this.route,
queryParams: { page: current + 1 },
queryParamsHandling: 'merge'
});
}
}6. Programmatic Navigation
นอกจากกดลิงก์ บางทีต้องสั่งเปลี่ยนหน้าจากในโค้ด (เช่น หลัง login สำเร็จ) — ใช้ router.navigate([...]) หรือ router.navigateByUrl('...') ส่ง param/query/state ไปด้วยได้:
typescript
import { Router } from '@angular/router';
@Component({...})
export class SomeComponent {
private router = inject(Router);
goToUser(id: number) {
this.router.navigate(['/users', id]);
}
goToSearch(q: string) {
this.router.navigate(['/search'], { queryParams: { q } });
}
goHome() {
this.router.navigateByUrl('/');
}
// ส่ง state แนบไปด้วย (ไม่โผล่ใน URL)
goWithState() {
this.router.navigate(['/detail'], {
state: { from: 'list' }
});
}
}typescript
// อ่าน state ใน component ปลายทาง
import { Router } from '@angular/router';
@Component({...})
export class DetailComponent {
private router = inject(Router);
constructor() {
const state = this.router.getCurrentNavigation()?.extras.state;
console.log(state);
}
}7. Lazy Loading — Code Splitting
ถ้าโหลดโค้ดทั้งแอปมาตั้งแต่แรก หน้าแรกจะช้า · Lazy loading = "โหลดโค้ดของ route นั้นตอนผู้ใช้เข้าถึงจริง" ทำให้ bundle (ไฟล์ JavaScript ที่รวมโค้ดทั้งหมดมาไว้ด้วยกัน) หลักเล็กลง เปิดเว็บเร็วขึ้น — ใช้ loadComponent/loadChildren กับ dynamic import (การโหลดโค้ดแบบหน่วงเวลา):
typescript
// lazy load component เดี่ยว (standalone)
{
path: 'reports',
loadComponent: () =>
import('./reports/reports.component').then(m => m.ReportsComponent)
}typescript
// lazy load ทั้ง feature พร้อม route ย่อย
// app.routes.ts
{
path: 'admin',
loadChildren: () =>
import('./admin/admin.routes').then(m => m.adminRoutes)
}
// admin/admin.routes.ts
export const adminRoutes: Routes = [
{ path: '', component: AdminDashboardComponent },
{ path: 'users', component: AdminUsersComponent },
{ path: 'settings', component: AdminSettingsComponent }
];→ แยก bundle — bundle หลักเล็กลง, โหลด feature เมื่อต้องการ (on demand)
Preload Strategy
typescript
import { PreloadAllModules, withPreloading } from '@angular/router';
provideRouter(routes, withPreloading(PreloadAllModules))→ โหลด lazy route ทั้งหมดเงียบ ๆ ใน background (เบื้องหลัง) หลังจากหน้าแรกพร้อมแล้ว — ผู้ใช้จะเข้าหน้าอื่นได้เร็วขึ้น
Custom Preload
typescript
import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class SelectivePreloadStrategy implements PreloadingStrategy {
preload(route: Route, fn: () => Observable<any>): Observable<any> {
return route.data?.['preload'] ? fn() : of(null);
}
}
// ใน routes
{ path: 'admin', loadChildren: () => ..., data: { preload: true } }8. Guards — Protect Routes
Guard คือ "ยาม" ที่ตัดสินว่าเข้า/ออก route ได้ไหม — เช่นกันหน้า dashboard ถ้ายังไม่ login · มีหลายประเภทตามช่วงเวลาที่ทำงาน: canActivate (เข้าได้ไหม), canDeactivate (ออกได้ไหม เช่นเตือนงานยังไม่เซฟ), canMatch (route นี้ตรงไหม) · Angular ยุคใหม่เขียนเป็นฟังก์ชัน:
canActivate — Allow Access?
🔴 คำเตือน security ก่อนเริ่ม — อ่านก่อนเขียน guard: Route guard ทำงานบน client-side ทั้งหมด — attacker (ผู้โจมตี) เปิด devtools แก้
auth.isLoggedIn()ให้คืนtrueก็เข้าได้ Guard ไม่ใช่ ระบบ security จริง มันคือ UX optimization (ไม่ render route ที่ user เข้าไม่ได้)Security จริงต้องอยู่ที่ server เสมอ:
- JWT (JSON Web Token) คือข้อมูลยืนยันตัวตนที่เข้ารหัสไว้ ส่งไปกับทุก request
- claim คือฟิลด์ข้อมูลใน JWT (เช่น role ของ user) · endpoint คือ URL ปลายทางหนึ่งจุดของ API
- API ต้องตรวจ JWT signature + role ใน claim ทุก endpoint
- ส่ง 401/403 ถ้าไม่มีสิทธิ์ ไม่ใช่พึ่งให้ frontend "ไม่เรียก"
- Backend assume frontend hostile (มองว่าฝั่ง browser เป็นศัตรู — ไม่เชื่อถือ) เสมอ
ใช้ guard เพื่อ: redirect ผู้ใช้ไป login, hide UI, ลด API call ที่ฟ้องว่าไม่มีสิทธิ์ — ไม่ใช่ เพื่อกันการเข้าถึงข้อมูล
typescript
// auth.guard.ts
import { CanActivateFn, Router } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isLoggedIn()) {
return true;
}
router.navigate(['/login'], {
queryParams: { returnUrl: state.url }
});
return false;
};typescript
// ใน routes
{
path: 'dashboard',
component: DashboardComponent,
canActivate: [authGuard]
}
// ใส่หลาย guard
{
path: 'admin',
component: AdminComponent,
canActivate: [authGuard, adminGuard]
}canDeactivate — Confirm Leave?
typescript
import { CanDeactivateFn } from '@angular/router';
import { Observable } from 'rxjs';
export interface CanDeactivateComponent {
canDeactivate: () => boolean | Observable<boolean>;
}
export const unsavedChangesGuard: CanDeactivateFn<CanDeactivateComponent> =
(component) => component.canDeactivate();typescript
@Component({...})
export class EditFormComponent implements CanDeactivateComponent {
isDirty = signal(false);
canDeactivate() {
if (this.isDirty()) {
// confirm() จะแสดง dialog ของ browser เป็นข้อความอังกฤษ
// ในงานจริงควรทำ custom dialog เอง เพื่อควบคุมภาษาและสไตล์
return confirm('Unsaved changes. Leave?'); // มีการแก้ที่ยังไม่เซฟ จะออกไหม?
}
return true;
}
}
// ใน route
{
path: 'edit/:id',
component: EditFormComponent,
canDeactivate: [unsavedChangesGuard]
}canMatch — Match Route?
typescript
import { CanMatchFn } from '@angular/router';
import { inject } from '@angular/core';
// FeatureFlagService เป็น service สมมติที่ต้องสร้างเอง — มี method isEnabled(featureName: string): boolean
export const featureFlagGuard: CanMatchFn = (route, segments) => {
const flags = inject(FeatureFlagService);
return flags.isEnabled('new-feature');
};→ ถ้าคืน false, Router จะข้าม route นี้แล้วลองรูป route ถัดไปที่ตรง — เหมือนการลองกุญแจทีละดอก ถ้าดอกนี้เปิดไม่ได้ก็ลองดอกถัดไป
🎯
canMatchvscanActivate— เลือกตัวไหน:
canActivateทำงาน หลัง lazy chunk (ก้อนโค้ดย่อยที่ถูกแยกออกจาก bundle หลักสำหรับ lazy load — ดู §7) โหลดเสร็จ → user ที่ไม่มีสิทธิ์ก็ดาวน์โหลด admin bundle (ก้อนโค้ดของหน้า admin) ลงเครื่องไปแล้ว- ผลคือเปลือง bandwidth (ปริมาณข้อมูลที่ส่ง) + leak feature info (ทำให้คนเดาได้ว่ามีฟีเจอร์อะไรซ่อนอยู่)
canMatchทำงาน ก่อน lazy chunk โหลด → ถ้า role ไม่ใช่ admin จะไม่โหลด admin bundle เลย — ดีกว่าทั้ง performance + security- ใช้
canMatchสำหรับ role-based + lazy route · ใช้canActivateสำหรับ check ที่ต้อง async (เช่น token refresh — ขอ token ใหม่ก่อนเข้า)canMatchยังคงเป็น recommended pattern สำหรับ role-based guard บน lazy route เพราะ intent ชัดเจนกว่า- ⚠️ พฤติกรรมช่วง canActivate/lazy chunk อาจต่างกันเล็กน้อยตาม Angular version — ตรวจสอบเอกสารทางการของเวอร์ชันที่ใช้จริงก่อนพึ่งพา timing นี้
💡
withViewTransitions()(Angular 17+ stable) — animation route ใน 1 บรรทัด:typescriptprovideRouter(routes, withViewTransitions())ใช้ browser View Transitions API (API ใหม่ของเบราว์เซอร์ที่ทำ animation transition ระหว่างหน้าให้) → ลื่นกว่า Angular animation 30 บรรทัดของบทเก่า ๆ
canActivateChild
typescript
{
path: 'admin',
component: AdminLayoutComponent,
canActivateChild: [authGuard, adminGuard],
children: [...]
}→ ใช้ guard กับ route ลูกทุกตัว
Class-based Guard (Deprecated — v15.2+)
⚠️ อย่าใช้ในโค้ดใหม่ — interface
CanActivate/CanDeactivate/ ฯลฯ ถูก deprecated อย่างเป็นทางการใน Angular v15.2 (functional guard เพิ่มเข้ามาตั้งแต่ v14.2) · ของใหม่ใช้ functional guard (CanActivateFn) ที่แสดงไปข้างบนเสมอ · ส่วนนี้ใส่ไว้ให้อ่านโค้ดเก่าออกเท่านั้น:
typescript
// ❌ deprecated ตั้งแต่ v15.2 — เจอในโค้ดเก่า (ยังใช้ได้ใน v14.x แต่ถูก deprecated อย่างเป็นทางการใน v15.2)
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
constructor(private auth: AuthService, private router: Router) {}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
// ...
}
}
// ✅ ของใหม่ — functional guard (เพิ่มใน Angular 14.2, deprecated class-based ใน 15.2) — เขียนสั้นกว่า + ใช้ inject() ในตัว
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
// ...
};9. Resolvers — Pre-load Data
Resolver = "ดึงข้อมูลให้เสร็จก่อนเข้าหน้า" — แทนที่จะเข้าหน้าแล้วเห็น loading กระพริบ ข้อมูลพร้อมตั้งแต่หน้าแสดง · ใช้กับข้อมูลที่ "จำเป็น + เร็ว" เท่านั้น (ข้อมูลช้าจะทำให้เปลี่ยนหน้าค้าง):
typescript
// user.resolver.ts
import { ResolveFn } from '@angular/router';
import { inject } from '@angular/core';
import { UserService } from './user.service';
// User คือ interface ที่กำหนดโครงสร้างข้อมูลผู้ใช้ — ต้องสร้างเองหรือดูบทที่ 3
// ตัวอย่าง: interface User { id: number; name: string; email: string; }
// UserService ต้องมี method getById(id: number) คืน Observable<User>
export const userResolver: ResolveFn<User> = (route) => {
const userService = inject(UserService);
const id = Number(route.paramMap.get('id'));
return userService.getById(id);
};typescript
// ใน routes
{
path: 'users/:id',
component: UserDetailComponent,
resolve: {
user: userResolver
}
}typescript
// ใน Component
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
@Component({...})
export class UserDetailComponent {
private route = inject(ActivatedRoute);
// 💡 .pipe(map(...)) = แปลงค่าใน Observable (RxJS — บท 6) · ตรงนี้คือดึง field 'user' ออกจาก data
user = toSignal(
this.route.data.pipe(map(data => data['user'] as User))
);
}→ ดึงข้อมูลเสร็จ ก่อน component แสดง — ไม่มี loading กระพริบ
เมื่อไรไม่ควรใช้ Resolver
text
❌ API ช้า — ทำให้เปลี่ยนหน้าช้าตาม
❌ ข้อมูลที่มีก็ได้ไม่มีก็ได้ — ให้ component จัดการ loading เอง
❌ ข้อมูลที่เปลี่ยนบ่อย
✅ ข้อมูลที่จำเป็น + เร็ว
✅ ตัดสถานะ loading ออกสำหรับหน้าสำคัญ10. Router Events
🚧 ส่วนนี้ใช้ RxJS (
.pipe,filter, type predicate) — ข้ามไปก่อนได้ กลับมาอ่านหลังบท 6
Router ปล่อย "เหตุการณ์" ทุกครั้งที่เปลี่ยนหน้า (เริ่ม/สำเร็จ/ยกเลิก/error) — subscribe ฟังได้ ใช้บ่อยสุดคือ NavigationEnd (เปลี่ยนหน้าสำเร็จ) สำหรับงานอย่างส่ง analytics ทุกครั้งที่เข้าหน้าใหม่:
typescript
import { Router, NavigationEnd, NavigationStart, Event } from '@angular/router';
import { filter } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
private router = inject(Router);
constructor() {
this.router.events.pipe(
filter((e): e is NavigationEnd => e instanceof NavigationEnd)
).subscribe(event => {
// ส่งข้อมูลการเข้าหน้า (page view) ให้ analytics
// ทดสอบตรงนี้ก่อนด้วย console.log — ไม่ error แน่นอน
console.log('page view:', event.urlAfterRedirects);
// ในงานจริง ค่อยเปลี่ยนไปเรียก gtag() (ฟังก์ชันของ Google Analytics)
// ⚠️ gtag() ต้องใส่ script ของ GA ในโปรเจกต์ก่อน ไม่งั้นจะได้ error "gtag is not defined"
// gtag('event', 'page_view', {
// page_path: event.urlAfterRedirects
// });
});
}
}เหตุการณ์ที่เจอบ่อย:
NavigationStart— เริ่มเปลี่ยนหน้าRouteConfigLoadStart/End— กำลัง lazy loadRoutesRecognized— จับคู่ route ได้แล้วGuardsCheckStart/End— กำลังเช็ค guardResolveStart/End— กำลังดึงข้อมูล resolverNavigationEnd— เปลี่ยนหน้าสำเร็จ ⭐NavigationCancel— guard คืน false (ถูกยกเลิก)NavigationError— เกิด error
11. Route Title (Angular 16+)
ตั้งแต่ Angular 16 กำหนด <title> ของแต่ละหน้า (ที่เห็นบน tab เบราว์เซอร์) ได้ตรงใน route เลย — เป็นค่าคงที่ หรือ strategy รวม (เติม " | My App" ทุกหน้า):
⚠️
titlefield รับแค่stringคงที่ — ถ้าต้องการ title แบบ dynamic (ใช้ param) ต้องใช้TitleStrategyแบบ class-based แทน ไม่สามารถส่ง arrow function ตรง ๆ ได้
typescript
// title คงที่ (รองรับ string เท่านั้น)
{
path: 'about',
component: AboutComponent,
title: 'About Us'
}typescript
// title แบบ dynamic — ต้องใช้ TitleStrategy (abstract class จาก @angular/router)
import { Injectable } from '@angular/core';
import { TitleStrategy, RouterStateSnapshot } from '@angular/router';
@Injectable({ providedIn: 'root' })
export class AppTitleStrategy extends TitleStrategy {
// route ตรงนี้คือ ActivatedRouteSnapshot — เข้าถึง paramMap, data, queryParamMap ได้
override updateTitle(snapshot: RouterStateSnapshot) {
const title = this.buildTitle(snapshot);
document.title = title ? `${title} | My App` : 'My App';
}
}
// ใน app.config.ts:
import { withTitleStrategy } from '@angular/router';
provideRouter(routes, withTitleStrategy(AppTitleStrategy))→ อัปเดต <title> ให้อัตโนมัติ
12. Scroll Behavior
ปกติ SPA เปลี่ยนหน้าแล้วตำแหน่ง scroll ค้างที่เดิม (น่ารำคาญ) · เปิด withInMemoryScrolling ให้เลื่อนขึ้นบนสุดเมื่อเปลี่ยนหน้า + จำตำแหน่งเดิมเมื่อกด back + เลื่อนไปยัง #fragment อัตโนมัติ:
typescript
provideRouter(routes, withInMemoryScrolling({
scrollPositionRestoration: 'enabled', // คืนตำแหน่ง scroll เดิมเมื่อกด back
anchorScrolling: 'enabled' // เลื่อนไปยัง #fragment อัตโนมัติ
}))html
<a routerLink="/docs" fragment="section-2">Section 2</a>→ /docs#section-2 — scroll to element id="section-2"
13. Router Outlet — Animations
อยากให้เปลี่ยนหน้าแล้วมี transition (การเปลี่ยนผ่าน/แอนิเมชันระหว่างหน้า เช่น fade = ค่อย ๆ จาง / slide = เลื่อนเข้าออก) สวย ๆ แทนการกระพริบ — ทำได้
ยุคใหม่ (Angular 17+ stable) — ใช้ withViewTransitions() แทบเสมอ (1 บรรทัด ใช้ browser API):
typescript
provideRouter(routes, withViewTransitions())จบ — ไม่ต้องเขียน animation 30 บรรทัด · ส่วนข้างล่างเป็นวิธีเก่าด้วย @angular/animations ใส่ไว้ให้รู้จัก เผื่อต้อง custom transition แปลก ๆ ที่ View Transitions API ทำไม่ได้:
typescript
import { trigger, transition, style, animate, query, group } from '@angular/animations';
import { Component, viewChild } from '@angular/core';
import { RouterOutlet } from '@angular/router';
// trigger() = ตั้งชื่อ animation
const routeAnimation = trigger('routeAnimation', [
// transition() = กำหนดว่า animation นี้ทำงานเมื่อไร ('* <=> *' = ทุกการเปลี่ยนหน้า)
transition('* <=> *', [
style({ position: 'relative' }),
// query(:enter) = จับ component ที่กำลังเข้ามา
// query(:leave) = จับ component ที่กำลังออกไป
query(':enter, :leave', [
style({ position: 'absolute', width: '100%' })
], { optional: true }),
// group() = รัน animation พร้อมกัน (parallel)
group([
query(':leave', [
animate('300ms ease-out', style({ opacity: 0 }))
], { optional: true }),
query(':enter', [
style({ opacity: 0 }),
animate('300ms ease-out', style({ opacity: 1 }))
], { optional: true })
])
])
]);
@Component({
imports: [RouterOutlet],
template: `
<div [@routeAnimation]="getRouteAnimationState()">
<router-outlet #outlet="outlet"></router-outlet>
</div>
`,
animations: [routeAnimation]
})
export class AppComponent {
// ใช้ signal-based viewChild (Angular 17+) — สอดคล้องกับสไตล์ของบท 1
outlet = viewChild.required<RouterOutlet>('outlet');
getRouteAnimationState() {
return this.outlet().activatedRouteData?.['animation'] || '';
}
}14. Route Data — Static Metadata
data คือที่แนบข้อมูลคงที่ติดไปกับ route (เช่น roles ที่อนุญาต, ชื่อ breadcrumb, key ของ animation) — guard/component อ่านไปใช้ต่อได้:
typescript
{
path: 'admin',
component: AdminComponent,
data: {
title: 'Admin',
roles: ['admin'],
breadcrumb: 'Admin'
}
}typescript
// Component
@Component({...})
export class AdminComponent {
private route = inject(ActivatedRoute);
data = toSignal(this.route.data);
}15. Auth Flow — Full Example
รวมทุกอย่างเป็นระบบ auth จริง — AuthService (สถานะ login), guard (กันหน้า + จำ returnUrl), route ซ้อนที่ทั้งกลุ่มต้อง login, และ login ที่เด้งกลับหน้าเดิม · นี่คือแพตเทิร์นที่ใช้ได้เลยในงานจริง:
typescript
// auth.service.ts
@Injectable({ providedIn: 'root' })
export class AuthService {
private _user = signal<User | null>(null);
readonly user = this._user.asReadonly();
readonly isLoggedIn = computed(() => this._user() !== null);
readonly isAdmin = computed(() => this._user()?.role === 'admin');
login(creds: Creds) { /* ... */ }
logout() { /* ... */ }
}typescript
// auth.guard.ts
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isLoggedIn()) return true;
router.navigate(['/login'], {
queryParams: { returnUrl: state.url } // จำหน้าเดิมไว้ เพื่อเด้งกลับหลัง login
});
return false;
};
export const adminGuard: CanActivateFn = () => {
const auth = inject(AuthService);
return auth.isAdmin();
};typescript
// routes
export const routes: Routes = [
{ path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent },
{
path: '',
canActivate: [authGuard],
children: [
{ path: '', component: DashboardComponent },
{ path: 'profile', component: ProfileComponent },
{ path: 'settings', component: SettingsComponent }
]
},
{
path: 'admin',
canActivate: [authGuard, adminGuard],
loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes)
},
{ path: '**', component: NotFoundComponent }
];typescript
// login.component.ts
@Component({...})
export class LoginComponent {
private auth = inject(AuthService);
private router = inject(Router);
private route = inject(ActivatedRoute);
async login(email: string, password: string) {
await this.auth.login({ email, password });
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') || '/';
this.router.navigateByUrl(returnUrl);
}
}16. Breadcrumb Pattern
Breadcrumb (เส้นทางนำทางแบบ Home / Users / Detail) สร้างอัตโนมัติได้จากโครง route — ฟัง NavigationEnd แล้วไล่อ่าน data.breadcrumb ของแต่ละชั้น:
typescript
// breadcrumb.service.ts
// DestroyRef = token ที่ใช้บอกเมื่อ service/component ถูกทำลาย ใช้ร่วมกับ takeUntilDestroyed()
@Injectable({ providedIn: 'root' })
export class BreadcrumbService {
private router = inject(Router);
private destroyRef = inject(DestroyRef);
breadcrumbs = signal<Breadcrumb[]>([]);
constructor() {
// ใส่ takeUntilDestroyed(destroyRef) เป็นนิสัย — แม้เป็น root service ที่ไม่ตายก็ตาม
// ถ้าก็อปแพตเทิร์นไปใส่ใน non-root service จะ leak ทันที
this.router.events.pipe(
filter(e => e instanceof NavigationEnd),
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.breadcrumbs.set(this.build(this.router.routerState.snapshot.root));
});
}
// build() = ฟังก์ชันเรียกตัวเอง (recursive) เพื่อไล่อ่าน route ทุกชั้นจากบนลงล่าง
private build(route: ActivatedRouteSnapshot, path = '', items: Breadcrumb[] = []): Breadcrumb[] {
const label = route.data?.['breadcrumb'];
const segments = route.url.map(u => u.path).join('/');
const url = path + '/' + segments;
if (label) {
items.push({ label, url });
}
if (route.firstChild) {
return this.build(route.firstChild, url, items);
}
return items;
}
}html
<nav class="breadcrumbs">
@for (crumb of breadcrumbService.breadcrumbs(); track crumb.url) {
<a [routerLink]="crumb.url">{{ crumb.label }}</a>
<span> / </span>
}
</nav>17. Common Layout Pattern
แอปจริงมักมีหลาย "เปลือก" (layout) — เช่นหน้า public มี navbar แบบหนึ่ง, หน้าหลัง login มี sidebar อีกแบบ · ทำได้ด้วย route ซ้อน: ให้ route แม่เป็น layout component (มี <router-outlet> ข้างใน) แล้ว route ลูกแสดงในนั้น:
typescript
// app.routes.ts
export const routes: Routes = [
{
path: '',
component: PublicLayoutComponent,
children: [
{ path: '', component: HomeComponent },
{ path: 'about', component: AboutComponent },
{ path: 'login', component: LoginComponent }
]
},
{
path: 'app',
component: AppLayoutComponent,
canActivate: [authGuard],
children: [
{ path: '', component: DashboardComponent },
{ path: 'profile', component: ProfileComponent }
]
},
{
path: 'admin',
component: AdminLayoutComponent,
canActivate: [adminGuard],
children: [
{ path: '', component: AdminDashboardComponent },
{ path: 'users', component: AdminUsersComponent }
]
}
];typescript
// layout สำหรับหน้า public
@Component({
template: `
<header><app-public-nav /></header>
<main><router-outlet /></main>
<footer>...</footer>
`
})
export class PublicLayoutComponent { }
// layout สำหรับหน้าหลัง login (navbar คนละแบบ)
@Component({
template: `
<app-sidebar />
<header><app-app-nav /></header>
<main><router-outlet /></main>
`
})
export class AppLayoutComponent { }18. Router Testing
ทดสอบ component ที่พึ่ง routing ได้ด้วย RouterTestingHarness — จำลองการ navigate ไป URL จริงแล้วเช็คว่า component อ่าน param ถูกไหม
📖 Testing เป็นหัวข้อแยก — บทนี้แสดงเฉพาะตัวอย่าง routing test ·
TestBed= เครื่องมือสร้าง Angular module จำลองสำหรับทดสอบ (ดูบทที่ testing โดยเฉพาะ)
typescript
import { TestBed } from '@angular/core/testing';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
describe('UserDetailComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
// ⚠️ ต้องใส่ withComponentInputBinding() ด้วย ไม่งั้น instance.id() จะเป็น undefined
provideRouter([
{ path: 'users/:id', component: UserDetailComponent }
], withComponentInputBinding())
]
});
});
it('should load user', async () => {
const harness = await RouterTestingHarness.create();
const instance = await harness.navigateByUrl('/users/123', UserDetailComponent);
// input.required<string>('id') → route param เป็น string เสมอ
expect(instance.id()).toBe('123');
});
});19. ⚠️ Common Pitfalls
ข้อผิดพลาดเรื่อง routing ที่เจอบ่อย — อันดับ 1 คือใช้ href แทน routerLink (ทำให้โหลดหน้าใหม่ เสีย state ทั้งหมด) ตารางนี้รวมไว้:
| ❌ ที่มักทำผิด | ✅ ที่ถูก |
|---|---|
ใช้ <a href="/about"> สำหรับลิงก์ภายใน | ใช้ <a routerLink="/about"> (ไม่ reload หน้า) |
routerLink="/users/{{id}}" (แทรกค่าใน attribute) | [routerLink]="['/users', id]" |
| อ่าน snapshot แล้วพลาดตอน param เปลี่ยน | ใช้ observable / signal |
| resolver หนัก | ทำ data ใน resolver เป็น optional + ให้ component แสดง loading state เอง |
| ลืม pathMatch: 'full' ตอน redirect | ใส่สำหรับ path ว่าง |
| lazy load ทีละ component (ย่อยเกิน) | รวม component ที่เกี่ยวกันเป็นกลุ่ม |
| ใช้ angular-router-loader (ของเก่า) | ใช้ loadComponent / loadChildren |
loadChildren แบบ string (เก่า) | ใช้ dynamic import |
20. URL Params Best Practice
จะใส่ข้อมูลใน URL แบบไหนดี? กฎง่าย ๆ: ตัวระบุที่จำเป็น → path param (/users/5), filter/search ที่เลือกได้ → query param (?q=...), ส่วนย่อยในหน้า → fragment (#section) · และอย่าใส่ข้อมูลอ่อนไหวใน URL:
text
✅ ตัวระบุที่จำเป็น → path param
/users/:id, /posts/:id/comments/:cid
✅ filter/search ที่เลือกได้ → query param
/search?q=angular&page=2
✅ fragment สำหรับส่วนย่อยในหน้า
/docs#installation
❌ อย่าทำ:
- ใส่ข้อมูลอ่อนไหวใน URL
- ใส่ array ยาว ๆ ใน path
- เปลี่ยน state ผ่าน URL อย่างเดียว21. ตัวอย่างเต็ม — Complete Routing
รวมทุก feature ของบทเป็น config จริงชุดเดียว — input binding, scroll restoration, preload, route ซ้อน, lazy load, guard, resolver, title, 404 · ใช้เป็นแม่แบบเริ่มโปรเจกต์ได้เลย:
typescript
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import {
provideRouter,
withComponentInputBinding,
withInMemoryScrolling,
withPreloading,
PreloadAllModules
} from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withComponentInputBinding(),
withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),
withPreloading(PreloadAllModules)
)
]
};typescript
// app.routes.ts
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'home' },
{
path: 'home',
component: HomeComponent,
title: 'Home'
},
{
path: 'users',
children: [
{ path: '', component: UserListComponent, title: 'Users' },
{
path: ':id',
component: UserDetailComponent,
resolve: { user: userResolver },
title: 'User Detail'
}
]
},
{
path: 'admin',
loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes),
canActivate: [authGuard, adminGuard]
},
{
path: 'reports',
loadComponent: () => import('./reports/reports.component').then(m => m.ReportsComponent),
canActivate: [authGuard]
},
{ path: 'login', component: LoginComponent, title: 'Login' },
{ path: '404', component: NotFoundComponent },
{ path: '**', redirectTo: '/404' }
];typescript
// user-detail.component.ts
import { Component, inject, input } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
@Component({
imports: [RouterLink], // ⚠️ ต้องใส่ RouterLink ใน imports ของ standalone component
template: `
<div>
@if (user(); as u) { <!-- เรียก signal เป็น function: user() — แล้ว alias เป็น u -->
<h1>{{ u.name }}</h1>
<p>{{ u.email }}</p>
<button [routerLink]="['../']">Back</button>
<button [routerLink]="['edit']">Edit</button>
}
</div>
`
})
export class UserDetailComponent {
// ผูกอัตโนมัติจาก :id ใน route (Angular 16+)
id = input.required<string>();
private route = inject(ActivatedRoute);
// มาจาก resolver — toSignal คืน Signal<User | undefined>
user = toSignal(
this.route.data.pipe(map(d => d['user'] as User))
);
}22. Checkpoint
routing ต้องลองเองถึงจะเข้าใจการไหลของหน้า — ทำ 4 ข้อนี้ไล่จาก routing พื้นฐานไปจนถึง guard + lazy load:
🛠️ Checkpoint 4.1 — Basic Routing
ตั้งค่า:
- 3 routes: home, about, contact
- routerLink + routerOutlet
- ไฮไลต์ลิงก์ที่ active อยู่
🛠️ Checkpoint 4.2 — Dynamic Param
- สร้าง route
/users/:id - อ่านค่า id ด้วย input binding
- แสดงข้อมูล user
- นำทางจากหน้า list ด้วย
[routerLink]
🛠️ Checkpoint 4.3 — Nested + Lazy
/adminlazy loaded (โหลดทีหลัง)/admin/users,/admin/settingsเป็น children- ใช้ AdminLayoutComponent เป็น shell (กรอบหน้า)
🛠️ Checkpoint 4.4 — Guards
- authGuard: redirect ไปหน้า login ถ้ายังไม่ login
- เก็บ returnUrl ไว้เพื่อเด้งกลับหลัง login
- หลัง login สำเร็จ → navigate กลับหน้าเดิม
🛠️ Checkpoint 4.5 — Resolver
- userResolver ดึงข้อมูล user ก่อนเข้าหน้า (route loads)
- Component แสดงข้อมูลทันทีโดยไม่ต้องรอ loading spinner
🛠️ Checkpoint 4.6 — Query Params
- route
/search?q=foo&page=2 - แสดงผลจาก query param
- sync ค่าที่ผู้ใช้พิมพ์กลับไปที่ URL อัตโนมัติ
23. สรุปบท
ทบทวนแกนของบท — routing คือการจับคู่ URL กับ component ใน SPA · 3 สิ่งที่ใช้บ่อยสุดคือ routerLink (เปลี่ยนหน้าไม่ reload), param (:id + input binding), และ guard (กันหน้า) ที่เหลือเป็นของเสริมที่ค่อยหยิบมาใช้:
✅ provideRouter(routes) ใน app.config + <router-outlet> ใน template ✅ Route config: path, component (หรือ loadComponent), children, canActivate, resolve, title, data ✅ <a routerLink="..."> สำหรับลิงก์ (อย่าใช้ href) · [routerLink]="[...]" สำหรับ dynamic ✅ routerLinkActive = ใส่ class เมื่อ link ตรงกับหน้าปัจจุบัน ✅ Component Input Binding (Angular 16+) — :id ใน route → input<string>('id') ใน component ✅ lazy load: loadComponent / loadChildren พร้อม dynamic import ✅ Functional guards (Angular 14.2+) — ใช้ CanActivateFn, CanDeactivateFn, CanMatchFn (ไม่ใช่ class แบบเก่า) ✅ Functional resolver: ResolveFn<T> ✅ Router events — ฟัง NavigationEnd สำหรับงานอย่าง analytics ✅ Route title + scroll behavior มีให้ใช้ใน Router ✅ Layout pattern: parent component + <router-outlet> + canActivate