Skip to content

บทที่ 4 — Routing

← บทที่ 3 | สารบัญ | บทที่ 5 →

หลังจบบท คุณจะ:

  • กำหนด 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":

typescript
import { 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 }
];

ลิงก์ภายในแอป ห้ามใช้ 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>
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 ออกมาใช้

มีสามวิธีหลัก — เรียงจากที่แนะนำไปหาที่เก่ากว่า:

  1. Input Binding (Angular 16+) — ผูก param เข้า input() โดยตรง (สะอาดสุด ดูหัวข้อข้างล่าง)
  2. Signal — อ่านผ่าน ActivatedRoute + toSignal() (อัปเดตเมื่อ param เปลี่ยน)
  3. 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 ถัดไปที่ตรง — เหมือนการลองกุญแจทีละดอก ถ้าดอกนี้เปิดไม่ได้ก็ลองดอกถัดไป

🎯 canMatch vs canActivate — เลือกตัวไหน:

  • 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 บรรทัด:

typescript
provideRouter(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 load
  • RoutesRecognized — จับคู่ route ได้แล้ว
  • GuardsCheckStart / End — กำลังเช็ค guard
  • ResolveStart / End — กำลังดึงข้อมูล resolver
  • NavigationEnd — เปลี่ยนหน้าสำเร็จ ⭐
  • NavigationCancel — guard คืน false (ถูกยกเลิก)
  • NavigationError — เกิด error

11. Route Title (Angular 16+)

ตั้งแต่ Angular 16 กำหนด <title> ของแต่ละหน้า (ที่เห็นบน tab เบราว์เซอร์) ได้ตรงใน route เลย — เป็นค่าคงที่ หรือ strategy รวม (เติม " | My App" ทุกหน้า):

⚠️ title field รับแค่ 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

  • /admin lazy 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


← บทที่ 3 | บทที่ 5 → Forms