Skip to content

บทที่ 1 — Components และ Templates

← บทที่ 0 | สารบัญ | บทที่ 2 →

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

  • สร้าง component + template ที่ถูกต้องตามแบบฉบับของ Angular (idiomatic = เขียนถูกสไตล์ Angular)
  • เข้าใจ binding (การผูกข้อมูลระหว่าง TypeScript กับ HTML) ทุกแบบ (property, event, two-way)
  • ใช้ @if, @for, @switch ของ Angular 17+
  • เขียน reusable component ผ่าน input / output

ก่อนอ่าน: ควรผ่าน บทที่ 0 (setup + รู้จัก signal/template เบื้องต้น) มาก่อน

📖 ศัพท์/สัญลักษณ์ที่จะเจอในบทนี้ — กางก่อน:

  • @Decorator = TypeScript syntax ที่ "แปะป้าย" บน class/method เช่น @Component({...}) บอก Angular ว่า "นี่คือ component"
  • Signal = ค่า reactive ของ Angular ใหม่ — อ่านค่าด้วย วงเล็บ name() ไม่ใช่ name เฉย ๆ (ดูบทที่ 2)
  • input.required<T>() / input<T>() = วิธีรับข้อมูลจาก component แม่ (signal-based)
  • NgModule = ระบบจัดกลุ่มแบบเก่า — Angular 20+ default standalone แล้ว ตอนนี้ ไม่ต้องสนใจ
  • inject() = วิธีขอ service เข้ามาใช้ (ดูบทที่ 3)
  • $ ท้ายชื่อตัวแปร (users$) = ธรรมเนียม (convention) บอกว่าเป็น Observable — ไม่ใช่ syntax พิเศษ (ตอนนี้แค่จำว่า users$ = ชื่อตัวแปร — รายละเอียดของ Observable ในบทที่ 6 · $ ไม่ใช่ syntax พิเศษของ Angular/JS เหมือน jQuery/PHP — เป็นแค่ธรรมเนียมของทีม RxJS)
  • ! ใน user!: User = TypeScript "ฉันรับรองว่าไม่ใช่ null" (ดู TypeScript Recap ใน README)
  • State = ข้อมูล/สถานะของแอปที่อาจเปลี่ยนแปลงได้ เช่น รายชื่อ todo, ชื่อผู้ใช้ที่กำลัง login

🗺️ บทนี้ยาว — มือใหม่อ่านถึงไหนพอ

บทนี้ปูพื้นแล้วไต่ไปถึงเทคนิคขั้นสูงในบทเดียว แบ่งเป็น 2 โซน:

โซนหัวข้อหมายเหตุ
แกนหลัก (ต้องเข้าใจ)ข้อ 1–11 — component, template syntax, control flow (@if/@for), communication (input/output), two-way binding, pipe, directive, lifecycle, content projectionจบโซนนี้ = สร้าง component ใช้งานจริงได้
ขั้นสูง (ข้ามได้)ข้อ 12 (ViewChild), 12.5 (Host bindings), 12.6 (Host Directive), 14 (Advanced inputs)เทคนิคของซับซ้อน — มือใหม่ข้ามไปข้อ 15–19 (ตัวอย่างเต็ม) + Checkpoint ได้

👉 เพิ่งเริ่ม Angular: โฟกัสข้อ 1–11 ให้แน่น (โดยเฉพาะ template syntax + input()/output()) — ViewChild/Host Directive ไว้กลับมาทีหลังไม่ผิด


1. Component คืออะไร

Component คือ "ชิ้นส่วน" ของหน้าจอ ที่รวม 3 อย่างไว้ด้วยกันเป็นก้อนเดียว:

  • Template (HTML) — หน้าตาที่เห็น
  • Logic (TypeScript class) — ข้อมูลและพฤติกรรม
  • Style (CSS/SCSS) — การตกแต่ง เฉพาะของชิ้นนี้

แต่ละ component มี selector (ชื่อ tag ของตัวเอง เช่น <app-user-card>) ที่เอาไปวางซ้อนในชิ้นอื่นได้

🔍 เปรียบเทียบ: component เหมือน ตัวต่อ Lego — แต่ละชิ้นทำหน้าที่ของมัน เอามาประกอบกันเป็นของใหญ่ได้ และถอดไปใช้ที่อื่นซ้ำได้ · ทั้งแอป Angular = component หลาย ๆ ชิ้นซ้อนกันเป็นต้นไม้ (มี AppComponent เป็นราก)

ดูตัวอย่าง component ง่าย ๆ ตัวหนึ่ง — สังเกตว่าทั้ง HTML, class, style อยู่ในไฟล์เดียว:

typescript
@Component({
    selector: 'app-user-card',
    standalone: true,
    template: `
        <div class="card">
            <h3>{{ user.name }}</h3>
            <p>{{ user.email }}</p>
        </div>
    `,
    styles: `
        .card { padding: 16px; border: 1px solid #ddd; }
    `
})
export class UserCardComponent {
    user: { name: string; email: string } = { name: 'Anna', email: 'anna@x.com' };
}

→ ใช้: <app-user-card /> ใน parent template


2. @Component Decorator

@Component({...}) คือ "ป้ายกำกับ" ที่บอก Angular ว่า class นี้เป็น component พร้อมตั้งค่าต่าง ๆ — นี่คือ option ที่เจอบ่อย (ดูคอมเมนต์อธิบายแต่ละบรรทัด):

typescript
// ⚠️ ต้อง import ครบ — ตัวอย่างนี้แสดง option ทั้งหมด เลือกใช้เฉพาะที่ต้องการ
import { Component, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core';

@Component({
    selector: 'app-user',                              // ชื่อ tag ที่ใช้เรียก component นี้
    standalone: true,                                   // standalone = ไม่ต้องอยู่ใน NgModule (default ใน Angular 19+)
    imports: [/* component, directive, pipe ตัวอื่น ๆ */],

    // ⚠️ เลือกใช้ template หรือ templateUrl อย่างหนึ่งเท่านั้น — ใส่ทั้งคู่จะ error
    template: `...`,                                     // template เขียนในไฟล์เดียวกัน (inline)
    // หรือ:
    // templateUrl: './user.component.html',
    
    // ⚠️ เลือกใช้ styles หรือ styleUrl อย่างหนึ่งเท่านั้น
    styles: `...`,                                       // style เขียนในไฟล์เดียวกัน (inline)
    // หรือ:
    // styleUrl: './user.component.scss',               // ⭐ ไฟล์เดียว (Angular 17+) — ใช้แบบนี้เป็นหลัก
    // styleUrls: ['./a.scss', './b.scss'],             // (legacy/หลายไฟล์) ใช้เมื่อมีหลาย stylesheet
    
    // ⚠️ มือใหม่ข้ามบรรทัดนี้ไปก่อน — OnPush มีผลต่อพฤติกรรม change detection (รายละเอียดบทที่ 7)
    changeDetection: ChangeDetectionStrategy.OnPush,    // OnPush = ตรวจจับเฉพาะเมื่อจำเป็น (เร็วกว่า — ดูบทที่ 7)
    encapsulation: ViewEncapsulation.Emulated,          // จำกัดขอบเขต CSS ไม่ให้รั่วไปกระทบที่อื่น
    providers: [/* DI เฉพาะขอบเขต component นี้ */]
})

3. Template — HTML + Angular Syntax

Template คือ HTML ปกติ + ไวยากรณ์พิเศษของ Angular ที่ทำให้ "ผูก" ข้อมูลกับหน้าจอได้ มี 4 แบบหลักที่ต้องจำ: {{ }} แสดงค่า, [prop] ส่งค่าเข้า element, (event) ดักเหตุการณ์, [(ngModel)] ผูกสองทาง:

ก่อนดูโค้ด ลองนึกภาพ "ท่อ" ที่เชื่อม TypeScript กับ HTML — แต่ละแบบมีทิศทางต่างกัน:

  • {{ }} และ [prop] = ท่อส่งข้อมูลจาก TypeScript ลงมาที่ HTML (ทิศเดียว — ลงล่าง) เช่น ตัวแปร name = 'Anna' ใน class ไหลออกมาแสดงบนหน้าจอ
  • (event) = ท่อส่งสัญญาณจาก HTML ขึ้นไปหา TypeScript (ทิศเดียว — ขึ้นบน) เช่น ผู้ใช้คลิกปุ่ม → Angular เรียก method ที่ระบุไว้
  • [(ngModel)] = ท่อสองทาง — ข้อมูลไหลได้ทั้งลงและขึ้นพร้อมกัน เหมาะกับช่อง input ที่ต้องการให้ TypeScript รู้ทุกครั้งที่ผู้ใช้พิมพ์

ลองดูตัวอย่างสั้นๆ ทีละแบบเพื่อให้เห็นชัด สมมติมี name = 'Anna' ใน class:

html
<!-- ① แสดงค่าออกมา ({{ }}) -->
<p>สวัสดี, {{ name }}</p>
<!-- → แสดง: สวัสดี, Anna -->

<!-- ② ส่งค่าเข้า attribute ([prop]) -->
<input [placeholder]="name">
<!-- → placeholder ของ input จะเป็น "Anna" -->

<!-- ③ ดักเหตุการณ์ ((event)) -->
<button (click)="name = 'Default'">reset</button>
<!-- → กดปุ่ม → name กลายเป็น 'Default' → {{ name }} อัปเดตตาม -->

<!-- ④ ผูกสองทาง ([(ngModel)]) — ต้อง import FormsModule -->
<input [(ngModel)]="name">
<!-- → พิมพ์ใน input → name เปลี่ยน → {{ name }} เปลี่ยนตามทันที -->

ตัวอย่างรวมทั้งหมด (โค้ดจริงในไฟล์) ดูได้ด้านล่าง:

html
<!-- Interpolation: เอาค่ามาแสดง -->
<p>Hello, {{ name }}</p>
<p>Sum: {{ 1 + 2 }}</p>
<p>{{ user.address?.city }}</p>          <!-- safe navigation operator (?.) = ตัวดำเนินการเข้าถึงอย่างปลอดภัย — ถ้า address เป็น null/undefined ก็ไม่พัง คืน undefined แทน (ใน JavaScript/TypeScript เรียกว่า optional chaining) -->

<!-- Property binding: ผูกค่าเข้ากับ property ของ element -->
<img [src]="imageUrl" [alt]="description">
<button [disabled]="isLoading">Click</button>

<!-- Class binding: เพิ่ม/ลบ class ตามเงื่อนไข -->
<div [class.active]="isActive"></div>
<div [class]="['box', 'large']"></div>                              <!-- [class] รับเป็น array (รายชื่อ class) ก็ได้ -->
<div [class]="{ active: isActive, disabled: isDisabled }"></div>     <!-- หรือเป็น object ({class: เงื่อนไข}) ก็ได้ -->

<!-- Style binding: ผูกค่า style โดยตรง -->
<div [style.color]="textColor"></div>
<div [style.width.px]="boxWidth"></div>
<div [style]="{ color: 'red', fontSize: '14px' }"></div>

<!-- Event binding: ดักเหตุการณ์ -->
<button (click)="onClick()">Click</button>
<input (input)="onChange($event)">
<form (submit)="onSubmit()">

<!-- Two-way binding: ผูกสองทาง (ต้อง import FormsModule ใน imports ของ @Component — ดูตัวอย่างในข้อ 6) -->
<input [(ngModel)]="name">

$event Object

html
<input (input)="onChange($event)">
typescript
onChange(event: Event) {
    const input = event.target as HTMLInputElement;
    console.log(input.value);
}

4. Built-in Control Flow (Angular 17+)

"Control flow" คือการสั่งให้ template แสดงผลตามเงื่อนไข (if), วน loop (for), หรือเลือกกรณี (switch) — ตั้งแต่ Angular 17 ใช้ @if/@for/@switch ที่เขียนเหมือน JavaScript (อ่านง่ายกว่า *ngIf/*ngFor แบบเก่า):

@if / @else if / @else

html
@if (user) {
    <p>Welcome, {{ user.name }}</p>
} @else if (loading) {
    <p>Loading...</p>
} @else {
    <p>Please login</p>
}

@for (REQUIRES track)

💡 track คืออะไร และทำไม Angular บังคับ

ลองนึกถึงเลขบัตรประชาชน — ถ้าเราไม่รู้ว่าคนในลิสต์แต่ละคนเป็นใคร พอเรียงรายชื่อใหม่หรือลบคนออก เราต้องปั้นหน้าใหม่ทั้งหมด (ช้า) แต่ถ้ารู้ว่า "คนที่มี id=3 ยังอยู่ แค่ย้ายไปอยู่ตำแหน่งอื่น" — เราแค่เลื่อนมัน ไม่ต้องสร้างใหม่ (เร็ว)

track ทำหน้าที่เดียวกันนั้นให้ Angular — ต้องส่ง ค่าที่ไม่ซ้ำกัน ในลิสต์:

  • track item.id — ใช้เมื่อ item เป็น object ที่มี property id
  • track item — ใช้เมื่อ item เป็น string หรือ number ธรรมดา (ตัวมันเองไม่ซ้ำกันอยู่แล้ว)

❌ ถ้าใส่ track $index (ลำดับที่) แทน — Angular จะยังทำงานได้ แต่ประสิทธิภาพเท่ากับไม่ใส่ track เพราะทุกครั้งที่ลิสต์เปลี่ยน ลำดับทุกตัวก็เปลี่ยนตาม

html
@for (item of items; track item.id) {
    <li>{{ item.name }}</li>
}

<!-- ตัวแปรเสริม: ลำดับที่ ($index), ตัวแรก/สุดท้าย/คู่/คี่ ($first/$last/$even/$odd)
     หมายเหตุ: ตัวแปรที่ขึ้นต้นด้วย $ = ตัวที่ Angular เตรียมไว้ให้ใช้ใน @for โดยเฉพาะ
     ($ ตรงนี้ต่างจาก $ ต่อท้ายชื่อตัวแปร Observable เช่น users$ ในหน้า glossary เบื้องต้น) -->
@for (item of items; track item.id; let i = $index, let isFirst = $first) {
    <li [class.first]="isFirst">{{ i }}: {{ item.name }}</li>
}

<!-- กรณีไม่มีข้อมูลเลย (empty state) -->
@for (item of items; track item.id) {
    <li>{{ item.name }}</li>
} @empty {
    <p>No items</p>
}

⚠️ track = บังคับ — บอก Angular ว่าจะ identify แต่ละ item ยังไง (performance)

@switch

html
@switch (status) {
    @case ('active') { <p>Active</p> }
    @case ('inactive') { <p>Inactive</p> }
    @default { <p>Unknown</p> }
}

Old Syntax (Deprecated — ไม่ต้องใช้)

html
<!-- แบบเก่า: *ngIf -->
<div *ngIf="user">{{ user.name }}</div>

<!-- แบบใหม่: @if -->
@if (user) { <div>{{ user.name }}</div> }

→ ใช้ @if/@for/@switch เท่านั้น ใน code ใหม่


5. Component Communication

component ไม่ได้อยู่โดดเดี่ยว — มันต้องส่งข้อมูลหากัน หลักการคือ ข้อมูลไหลลงจากแม่สู่ลูกผ่าน input() และลูกส่งเหตุการณ์กลับขึ้นแม่ผ่าน output() (ข้อมูลไหลทางเดียวลง เหตุการณ์แจ้งกลับขึ้น — ทำให้ตามรอยง่าย):

ลองนึกภาพ component แม่ = บอส, component ลูก = พนักงาน — บอสส่งแฟ้มงาน (ข้อมูล) ให้พนักงานผ่าน input() และพนักงานรายงานผลกลับให้บอสผ่าน output() พนักงานทำงานตามที่บอสส่งมาให้ — ไม่ได้ไปหยิบข้อมูลเองจากตู้เก็บของบอส และบอสก็รู้ผลงานผ่านรายงาน ไม่ได้ไปยืนดูพนักงานตลอดเวลา

Parent → Child: Input

💡 ใช้ interface User { name: string; email: string; } ที่ไหนสักที่ในโปรเจกต์ (เช่น models/user.ts) แล้ว import { User } เข้ามา — ตัวอย่างต่อจากนี้จะอ้าง type นี้

สร้างไฟล์ interface ด้วย CLI: ng generate interface models/user (หรือ ng g i models/user) จะได้ไฟล์ src/app/models/user.ts ให้เติม property ลงไป

typescript
// component ลูก
import { Component, input } from '@angular/core';

@Component({
    selector: 'app-user-card',
    standalone: true,
    template: `
        <div>
            <h3>{{ user().name }}</h3>
            <p>{{ user().email }}</p>
        </div>
    `
})
export class UserCardComponent {
    user = input.required<User>();           // ⭐ input แบบ signal (บังคับต้องส่งมา) (Angular 17.1+)
    // <User> ตรงนี้ = generic type parameter — บอก TS ว่าค่าที่จะรับเป็นชนิด User (บังคับให้ parent ส่ง object ชนิด User มา)

    // หรือแบบไม่บังคับ มีค่า default (อาร์กิวเมนต์แรก = ค่า default ตรง ๆ):
    // user = input<User>({ name: 'Default', email: '' });
}
typescript
// component แม่
import { Component } from '@angular/core';
import { User } from './models/user';    // import type ที่นิยามไว้

// หมายเหตุ: @Component decorator ในตัวอย่างย่อจะละไว้เพื่อความกระชับ — ในโค้ดจริงต้องมีครบเสมอ
@Component({
    standalone: true,
    template: `
        <app-user-card [user]="currentUser" />
    `,
    imports: [UserCardComponent]
})
export class ParentComponent {
    currentUser: User = { name: 'Anna', email: 'anna@x.com' };
}

Older Syntax — @Input() (legacy — สำหรับเทียบเท่านั้น)

typescript
// แบบเก่า — Angular < 17.1 ยังเจอใน codebase เก่า — โค้ดใหม่ให้ใช้ input() signal API ข้างบนแทน
@Component({...})
export class UserCardComponent {
    @Input() user!: User;             // ! = definite assignment = ยืนยันกับ TypeScript ว่าตัวแปรนี้จะมีค่าแน่ ๆ (กันไม่ให้เตือนภายใต้ strict null — โหมด strict ใน tsconfig ที่บังคับให้ประกาศว่าตัวแปรอาจเป็น null หรือไม่)
    @Input() isRequired = true;    // หมายเหตุ: หลีกเลี่ยงชื่อ 'required' เพราะชนกับ HTML attribute มาตรฐานและ input.required() API ใหม่
}

โค้ดใหม่ใช้ input() signal API เป็นหลัก@Input() decorator แสดงไว้เพื่ออ่าน codebase เก่าได้เท่านั้น

Child → Parent: Output

output() สร้าง "ช่องสัญญาณ" ที่ลูกใช้แจ้ง parent ว่า "มีเหตุการณ์เกิดขึ้นแล้ว" — ต่างจาก return ตรงที่ return ส่งค่าคืนให้ผู้เรียก (caller) โดยตรง แต่ .emit() กระจายสัญญาณออกไปและ parent ที่ "ฟัง" อยู่ถึงจะรับได้ ลูกไม่รู้ว่า parent จะทำอะไรกับสัญญาณนั้น — แค่ส่งออกไป

typescript
// component ลูก
import { Component, output } from '@angular/core';

@Component({
    selector: 'app-btn',
    standalone: true,
    template: `<button (click)="handleClick()">Click</button>`
})
export class ButtonComponent {
    clicked = output<string>();          // ⭐ output() = ฟังก์ชันสร้าง event emitter (ตัวส่งสัญญาณเหตุการณ์ — เรียก .emit() เพื่อแจ้ง parent) รุ่นใหม่ ใช้ API style เดียวกับ signal แต่ไม่ใช่ตัวเก็บค่า (Angular 17.3+ — ตรวจสอบสถานะ/เวอร์ชันล่าสุดในเอกสารทางการก่อนใช้ เพราะการ stable ของ sub-feature บางตัวอาจต่างไปจากเวอร์ชันหลัก)
    // หมายเหตุ: output() ไม่ใช่ Observable — ถ้าต้องการแปลงเป็น Observable ใช้ outputToObservable() จาก @angular/core/rxjs-interop
    
    handleClick() {
        this.clicked.emit('hello from button');
    }
}
typescript
// component แม่ (decorator ย่อ — โค้ดจริงต้องมี standalone: true และ selector ด้วย)
@Component({
    standalone: true,
    template: `<app-btn (clicked)="onMessage($event)" />`,
    imports: [ButtonComponent]
})
export class ParentComponent {
    onMessage(msg: string) {
        console.log(msg);
    }
}

Older Syntax — @Output() (legacy — สำหรับเทียบเท่านั้น)

typescript
// แบบเก่า — โค้ดใหม่ให้ใช้ output() ข้างบนแทน
import { Component, EventEmitter, Output } from '@angular/core';

@Component({...})
export class ButtonComponent {
    @Output() clicked = new EventEmitter<string>();
    
    handleClick() {
        this.clicked.emit('hello');
    }
}

6. Two-way Binding ([(...)])

Two-way binding คือการผูกค่า "สองทาง" — พิมพ์ใน input แล้วตัวแปรเปลี่ยนตาม และถ้าตัวแปรเปลี่ยน input ก็เปลี่ยนตามด้วย · เขียนด้วย [(ngModel)] (เรียกเล่น ๆ ว่า "banana in a box" เพราะมองว่า () อยู่ใน [] — วงเล็บกล้วยอยู่ในกล่อง) จริง ๆ มันคือ property binding + event binding รวมกัน:

html
<input [(ngModel)]="name">

<!-- = เทียบเท่ากับ (property binding + event binding รวมกัน) -->
<input [ngModel]="name" (ngModelChange)="name = $event">
typescript
import { FormsModule } from '@angular/forms';

@Component({
    standalone: true,
    imports: [FormsModule],                   // ⭐ FormsModule = ชุดของ directive เกี่ยวกับฟอร์ม (เช่น ngModel) ที่ Angular แยกไว้เป็นกลุ่ม — ต้อง import ก่อนใช้ ngModel
    template: `
        <input [(ngModel)]="name">
        <p>Hello, {{ name }}</p>
    `
})
export class HelloComponent {
    name = 'World';
    // ⚠️ ตัวอย่างนี้ใช้ plain property กับ [(ngModel)] ซึ่งทำงานได้ใน Zone.js mode เท่านั้น
    // ถ้าใช้ zoneless (provideZonelessChangeDetection) ให้ใช้ model('') แทน — ดูตัวอย่างใน Custom Two-way Binding ข้างล่าง
}

Custom Two-way Binding (Angular 17.2+)

model() คือ signal ชนิดพิเศษที่ทั้ง parent และ child แก้ค่าได้พร้อมกัน — เปรียบกับ Google Doc ที่เปิดสองหน้าต่างพร้อมกัน: คนหนึ่งพิมพ์ปุ๊บ อีกคนเห็นทันที ไม่มีใครเป็นเจ้าของค่า — ทั้งคู่อ่านและแก้ได้เท่ากัน

ใน template ของ component ลูก เราต้องเชื่อมสองทิศเองด้วยมือ: [value]="val()" ทำให้ input แสดงค่าปัจจุบัน และ (input)="val.set(...)" อัปเดตค่าเมื่อผู้ใช้พิมพ์ ส่วน $any($event) เป็นวิธีบอก TypeScript ว่า "ไม่ต้องตรวจ type ให้ฉันจัดการเอง" — เอาไว้เข้าถึง .target.value ของ event ที่ TypeScript ไม่แน่ใจว่าเป็น HTMLInputElement หรือเปล่า

typescript
// component ลูก
import { Component, model } from '@angular/core';

@Component({
    selector: 'app-input',
    standalone: true,
    // $any($event) = บอก TypeScript "ข้ามการตรวจ type บรรทัดนี้" เพื่อเข้าถึง .target.value ได้
    // เทียบเท่า: ($event.target as HTMLInputElement).value — เขียนยาวกว่าแต่ TypeScript-safe กว่า
    template: `<input [value]="value()" (input)="value.set($any($event).target.value)">`
})
export class InputComponent {
    value = model('');                           // ⭐ model signal = signal ที่ผูกสองทางได้ (2-way)
}
typescript
// component แม่
import { Component, signal } from '@angular/core';

@Component({
    standalone: true,
    template: `<app-input [(value)]="name" />`,
    imports: [InputComponent]
})
export class ParentComponent {
    name = signal('Anna');
}

7. Template Reference Variable

บางทีอยากอ้างถึง element หรือ component ในเทมเพลตเดียวกันโดยตรง — ใช้ #ชื่อ ติดไว้บน element นั้น แล้วเรียก ชื่อ.value หรือ ชื่อ.method() จากที่อื่นในเทมเพลตได้เลย:

html
<input #usernameInput type="text">
<button (click)="logValue(usernameInput.value)">Log</button>

<app-child #child />
<button (click)="child.doSomething()">Call child</button>

#name = ตัวอ้างอิง (reference) ไปยัง DOM element หรือ instance ของ component


8. Pipe — Transform Display

Pipe คือ "ตัวแปลงค่า" ตอนแสดงผล — ใช้เครื่องหมาย | ในเทมเพลตเพื่อจัดรูปแบบข้อมูลโดยไม่ต้องแก้ค่าจริง เช่น แปลงวันที่ จัดรูปเงิน ทำตัวพิมพ์ใหญ่ (คล้าย format function ใน Excel — ไม่แก้ข้อมูลจริง แค่จัดรูปตอนแสดง; คล้าย filter ใน Vue ด้วย):

html
<!-- ตัวอย่าง class ที่ใช้ใน template ด้านล่าง:
     today = new Date(); price = 99.99; ratio = 0.5; value = 3.14159;
     items = [{...}]; longText = '...'; name = 'Anna'; date = new Date();
     (ตัวแปรเหล่านี้ต้องประกาศใน component class ก่อนใช้ใน template) -->

<!-- pipe ที่มีมาให้ในตัว (built-in) -->
<p>{{ name | uppercase }}</p>                     <!-- ANNA -->
<p>{{ name | lowercase }}</p>                     <!-- anna -->
<p>{{ name | titlecase }}</p>                      <!-- Anna -->

<p>{{ today | date }}</p>                          <!-- เช่น: Jun 12, 2026 (ขึ้นอยู่กับวันปัจจุบันและ locale ที่ตั้งค่าไว้) -->
<p>{{ today | date:'yyyy-MM-dd HH:mm' }}</p>      <!-- เช่น: 2026-06-12 14:30 -->
<!-- ⚠️ output ของ DatePipe ขึ้นกับ locale ของ app — ถ้าไม่ได้ตั้งค่าจะใช้ en-US เป็น default (ถ้า register locale ไทย จะได้รูปแบบต่างออกไป เช่น 12 มิ.ย. 2569) -->

<p>{{ price | currency }}</p>                      <!-- $99.99 -->
<p>{{ price | currency:'THB':'symbol-narrow' }}</p>

<p>{{ ratio | percent }}</p>                       <!-- 50% -->
<p>{{ value | number:'1.2-2' }}</p>                <!-- 3.14 -->

<p>{{ items | json }}</p>                          <!-- แปลงเป็น JSON ไว้ debug -->
<p>{{ longText | slice:0:50 }}...</p>

<!-- ต่อ pipe หลายตัว (chain) -->
<p>{{ name | uppercase | slice:0:5 }}</p>

<!-- ใส่ argument (พารามิเตอร์) ให้ pipe -->
<p>{{ today | date:'short' }}</p>    <!-- ใช้ today แทน date เพื่อหลีกเลี่ยงชื่อซ้ำกับ DatePipe -->

Async Pipe

html
<!-- async pipe: รับ Observable หรือ Promise มาแสดงค่าให้
     (Observable = สายข้อมูลที่ส่งค่าได้หลายค่าตามเวลา / Promise = ค่าเดียวที่ได้ในอนาคต)
     async pipe จะ "subscribe" (ลงทะเบียนเพื่อรับค่า) ให้อัตโนมัติ และ "unsubscribe" (ยกเลิกการรับ) เมื่อ component หายไป (รายละเอียด Observable + RxJS เต็ม ๆ ในบทที่ 6)
     ⚠️ อย่าเพิ่งสนใจรายละเอียดตอนนี้ — Observable + RxJS เต็ม ๆ ในบทที่ 6 · กลับมาอ่านส่วนนี้ใหม่หลังจบบท 6 -->
<p>{{ user$ | async | json }}</p>

@if (user$ | async; as user) {    <!-- "; as user" = เก็บค่าที่ได้จาก async ไว้ในตัวแปร user เพื่อใช้ภายใน block -->
    <p>{{ user.name }}</p>
}

Custom Pipe

typescript
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
    name: 'truncate',
    standalone: true
})
// implements PipeTransform = บอกว่า class นี้ทำตาม TypeScript interface PipeTransform — ซึ่งกำหนดว่าต้องมี method transform()
export class TruncatePipe implements PipeTransform {
    transform(value: string, limit = 50): string {
        if (value.length <= limit) return value;
        return value.slice(0, limit) + '...';
    }
}
html
<p>{{ longText | truncate:30 }}</p>

→ Import + ใส่ใน imports ของ component ที่จะใช้:

typescript
@Component({
    standalone: true,
    imports: [TruncatePipe],
    template: `<p>{{ longText | truncate:30 }}</p>`
})
export class MyComponent { ... }

9. Directive — Behavior on Element

Directive คือ "พฤติกรรม" ที่แปะลงบน element ได้ — ต่างจาก component ตรงที่ไม่มี template ของตัวเอง แต่ไปเพิ่มความสามารถให้ element ที่มันเกาะ (เช่น เปลี่ยนสีตอน hover, เพิ่ม/ลบ class):

Structural Directive (Old — replaced by @if/@for)

html
<!-- แบบเก่า — *ngIf -->
<div *ngIf="condition">show</div>

<!-- แบบเก่า — *ngFor -->
<li *ngFor="let item of items">{{ item }}</li>

<!-- แบบใหม่ — @if/@for -->
@if (condition) { <div>show</div> }
@for (item of items; track item) { <li>{{ item }}</li> }

Attribute Directive

html
<!-- ngClass -->
<div [ngClass]="{ active: isActive, disabled: isDisabled }"></div>

<!-- ngStyle -->
<div [ngStyle]="{ color: 'red', 'font-size.px': fontSize }"></div>

Custom Directive

typescript
import { Directive, ElementRef, inject, input } from '@angular/core';

@Directive({
    selector: '[appHighlight]',
    standalone: true,
    host: {
        // host: {} = วิธีใหม่ในการดักเหตุการณ์/ผูก attribute บน element ที่ directive นี้เกาะอยู่
        '(mouseenter)': 'onEnter()',
        '(mouseleave)': 'onLeave()'
    }
})
export class HighlightDirective {
    color = input('yellow', { alias: 'appHighlight' });    // alias = ชื่อแฝง — ทำให้ใน template เขียน [appHighlight]="'lightblue'" แทน [color] ได้ เพื่อให้ดูเหมือน attribute directive ธรรมดา
    private el = inject(ElementRef);              // inject() = วิธีขอ service/อ้างอิงเข้ามาใช้ (แทน constructor injection แบบเก่า)
    // ElementRef = ตัวอ้างถึง DOM (Document Object Model = โครงสร้าง HTML ในหน่วยความจำของเบราว์เซอร์) element จริงที่ directive เกาะอยู่

    onEnter() {
        this.el.nativeElement.style.backgroundColor = this.color();
    }

    onLeave() {
        this.el.nativeElement.style.backgroundColor = '';   // ใช้ '' (หรือ removeProperty) แทน null — นี่คือกฎของ DOM API เอง (CSSStyleDeclaration ของ browser กำหนดให้ property นี้เป็น string) ไม่เกี่ยวกับ TypeScript strict mode
    }
}

📌 บางตำราใช้ @HostListener('mouseenter') decorator แทน — ทำงานได้เหมือนกัน แต่ Angular แนะนำให้ใช้ host: {} metadata (ตัวกำหนดค่าภายใน @Component/@Directive decorator) เป็นหลัก (สะอาดกว่า เห็น binding ทั้งหมดในที่เดียว)

html
<p [appHighlight]="'lightblue'">Hover me</p>

10. Lifecycle Hooks

component มี "ช่วงชีวิต" ตั้งแต่เกิด (สร้าง) จนตาย (ถูกลบออกจากหน้า) — Angular ให้เราแทรกโค้ดในแต่ละช่วงผ่าน lifecycle hook เช่น ngOnInit (ตอนเริ่ม เหมาะดึงข้อมูล) และ ngOnDestroy (ตอนถูกลบ เหมาะ cleanup):

typescript
import { Component, OnInit, OnDestroy, OnChanges, AfterViewInit, AfterContentInit, SimpleChanges } from '@angular/core';

@Component({...})
export class MyComponent implements OnInit, OnDestroy, OnChanges, AfterViewInit, AfterContentInit {
    
    ngOnInit() {
        // component พร้อมแล้ว — เหมาะดึงข้อมูล/ตั้งค่าเริ่มต้น
        console.log('init');
    }
    
    ngOnDestroy() {
        // ก่อนถูกลบ — เก็บกวาด เช่น unsubscribe, เคลียร์ timer
        console.log('destroy');
    }
    
    ngOnChanges(changes: SimpleChanges) {
        // เมื่อค่า @Input() decorator เปลี่ยน
        // ⚠️ สำคัญ: ngOnChanges ทำงานกับ @Input() decorator เท่านั้น — ไม่ทำงานกับ input() signal API (ซึ่งบทนี้แนะนำ)
        // สำหรับ signal input ให้ใช้ effect() แทน (ดูส่วน Modern Equivalent ข้างล่าง)
        console.log('changes:', changes);
    }
    
    ngAfterViewInit() {
        // หลัง @ViewChild พร้อมใช้งาน
    }
    
    ngAfterContentInit() {
        // หลังเนื้อหาที่ project เข้ามา (ng-content) พร้อม
    }
}

Lifecycle order:

ตอนเกิด (Init phase) — ตามลำดับ:

text
1. constructor()
2. ngOnChanges()         — ถ้ามี @Input (ก่อน ngOnInit ครั้งแรก, และทุกครั้งที่ @Input เปลี่ยน)
3. ngOnInit()
4. ngAfterContentInit()  — หลังเนื้อหาที่แม่ project เข้ามาผ่าน <ng-content> พร้อมแล้ว (ใช้เมื่อต้องการนับหรืออ่านเนื้อหาที่พ่อแม่ยัดเข้ามา เช่น จำนวน <app-tab> ใน <app-tabs> — รายละเอียดใน section 11)
5. ngAfterViewInit()     — หลัง viewChild (DOM ของตัวเอง) พร้อมใช้งานแล้ว (ใช้เมื่อต้องสั่ง focus() หรือเรียก method บน child component ที่อ้างผ่าน viewChild() — รายละเอียดใน section 12)

ตอนตาย (Destroy phase) — แยกออกมา ไม่ใช่ส่วนของลำดับ init:

text
ngOnDestroy()            — ทำเฉพาะตอน component ถูกลบออกจากหน้า (ใช้ cleanup: unsubscribe, clear timer)

Modern Equivalent — Effect

typescript
import { Component, effect } from '@angular/core';

@Component({...})
export class MyComponent {
    name = input.required<string>();
    
    constructor() {
        effect(() => {
            // effect() = ฟังก์ชันที่จะรันทุกครั้งที่ signal ที่อ้างถึงข้างในเปลี่ยนค่า (รายละเอียดเต็มในบทที่ 2)
            console.log('name changed:', this.name());
        });
    }
}

→ Effect = รุ่น reactive ของ ngOnChanges — ทำงานทุกครั้งที่ signal ที่อ้างถึงเปลี่ยน (ใช้กับ signal-based input)

⚠️ จังหวะต่างกัน: ngOnChanges ยิง ก่อน ngOnInit (ตั้งแต่ครั้งแรก), แต่ effect() รัน หลัง change detection รอบแรกเสร็จ — ถ้าต้องการโค้ดที่รันตอน init ก่อน DOM พร้อม ให้ใช้ ngOnInit หรือ afterNextRender (Angular API สำหรับรันโค้ดหลัง DOM render เสร็จครั้งแรก — ดูรายละเอียดในบทขั้นสูง) ไม่ใช่ effect


11. Content Projection — Slot

Content projection คือการให้ component "เว้นช่อง" ไว้ให้ผู้ใช้ component ยัดเนื้อหาของตัวเองเข้ามา — เหมือนการ์ดเปล่าที่ใครจะใส่อะไรลงไปก็ได้ ใช้ <ng-content> เป็นช่องรับ (คล้าย children ใน React หรือ slot ใน Vue):

Single Slot

typescript
// card.component.ts
@Component({
    selector: 'app-card',
    standalone: true,
    template: `
        <div class="card">
            <ng-content></ng-content>
        </div>
    `
})
export class CardComponent { }
html
<!-- วิธีใช้ -->
<app-card>
    <h3>Title</h3>
    <p>Content</p>
</app-card>

HTML ใน <app-card>...</app-card> ถูก project (ส่งต่อ/ฉาย) ไปวางที่ตำแหน่ง <ng-content>

Multi-slot

select ใน <ng-content> รับ CSS selector ธรรมดา — [card-header] หมายถึง "element ใดก็ได้ที่มี attribute ชื่อ card-header อยู่" ไม่ใช่ชื่อพิเศษของ Angular และไม่ต้อง register ที่ไหนทั้งนั้น ถ้าอยากตั้งชื่อ slot ว่า my-footer ก็แค่เขียน select="[my-footer]" แล้วแปะ my-footer บน element ที่ต้องการส่งเข้าไป Angular จะกรองเฉพาะ element ที่มี attribute นั้นเข้าช่องที่ตรงกัน ส่วน element ที่ไม่มี attribute พิเศษจะตกเข้า <ng-content> ที่ไม่มี select (ช่องกลาง):

typescript
@Component({
    template: `
        <div class="card">
            <header>
                <ng-content select="[card-header]"></ng-content>
            </header>
            <main>
                <ng-content></ng-content>
            </main>
            <footer>
                <ng-content select="[card-footer]"></ng-content>
            </footer>
        </div>
    `
})
html
<app-card>
    <div card-header>Title</div>
    Default content
    <div card-footer>Footer</div>
</app-card>

12. ViewChild — Access Child

ขั้นสูง — ข้ามได้ถ้าเพิ่งเริ่ม กลับมาเมื่อต้องเข้าถึง DOM/child component โดยตรง

บางครั้งต้องเข้าถึง element หรือ component ลูกในเทมเพลตของตัวเองโดยตรง (เช่น สั่ง focus ช่อง input, เรียก method ของลูก) — ใช้ viewChild() ดึงมันมา:

typescript
import { Component, viewChild, ElementRef } from '@angular/core';

@Component({
    template: `
        <input #nameInput>
        <app-child />
    `
})
export class MyComponent {
    // ElementRef<HTMLInputElement> = wrapper รอบ <input> ตัวจริง — generic (TypeScript syntax ที่ระบุชนิดข้อมูลเพิ่มเติมด้วย <>) ซ้อนกันเพื่อให้ TS รู้ว่า element ข้างในเป็น HTMLInputElement
    nameInput = viewChild<ElementRef<HTMLInputElement>>('nameInput');
    // import { ChildComponent } from './child/child.component';  ← ต้อง import ก่อนใช้
    childComponent = viewChild(ChildComponent);
    
    focus() {
        this.nameInput()?.nativeElement.focus();
    }
}

viewChild() signal-based (Angular 17.3+)

Old Syntax — @ViewChild (legacy — สำหรับเทียบเท่านั้น)

typescript
// แบบเก่า — โค้ดใหม่ให้ใช้ viewChild() signal API ข้างบนแทน
@ViewChild('nameInput') nameInput!: ElementRef<HTMLInputElement>;
@ViewChild(ChildComponent) child!: ChildComponent;
// ⚠️ อ่านได้ตั้งแต่ ngAfterViewInit เป็นต้นไป (default static: false) — ถ้าอยากใช้ใน ngOnInit ต้องส่ง { static: true } เป็นกับดักที่มือใหม่เจอบ่อย

โค้ดใหม่ใช้ viewChild() signal API เป็นหลัก@ViewChild decorator แสดงไว้เพื่ออ่าน codebase เก่าได้เท่านั้น

viewChildren — Multiple Children

typescript
import { Component, viewChildren, ElementRef, computed, signal } from '@angular/core';
// import { CardComponent } from './card/card.component';  ← ต้อง import ก่อนใช้ viewChildren(CardComponent)

@Component({
    standalone: true,
    template: `
        @for (item of items(); track item.id) {
            <div #row>{{ item.name }}</div>
        }
        <button (click)="focusFirst()">Focus first</button>
    `
})
export class ListComponent {
    items = signal([{ id: 1, name: 'Item 1' }, { id: 2, name: 'Item 2' }]);  // ตัวอย่างข้อมูล
    rows = viewChildren<ElementRef<HTMLDivElement>>('row');
    
    // หรืออ้างด้วยชนิดของ component
    childCards = viewChildren(CardComponent);
    
    focusFirst() {
        this.rows()[0]?.nativeElement.focus();
    }
    
    totalChildren = computed(() => this.rows().length);
}

→ Returns readonly array (signal)

contentChild / contentChildren — Projected Content

ทำไมถึงต้องมี contentChildren แยกต่างหากจาก viewChildren? ลองนึกสถานการณ์นี้:

text
[ParentTemplate]
    └── <app-tabs>
            └── <app-tab> ← tab พวกนี้ถูก parent ส่งเข้ามา (projected)
                           ← ไม่ได้อยู่ใน template ของ TabsComponent เลย

ถ้า <app-tabs> ใส่ <app-tab> ไว้ใน template ของตัวเองโดยตรง — ใช้ viewChildren ได้เลย แต่ในความเป็นจริง ผู้ใช้ component เป็นคนส่ง <app-tab> เข้ามาจากข้างนอก <app-tab> เหล่านั้นจึงไม่ได้อยู่ใน template ของ TabsComponentviewChildren มองไม่เห็นพวกมันเลย ต้องใช้ contentChildren แทน ซึ่งมองหา element ที่ถูก project เข้ามาผ่าน <ng-content> โดยเฉพาะ

typescript
import { Component, contentChild, contentChildren } from '@angular/core';

// Parent template:
// <app-tabs>
//     <app-tab title="One">...</app-tab>
//     <app-tab title="Two">...</app-tab>
// </app-tabs>

@Component({
    selector: 'app-tabs',
    template: `
        <div class="tab-headers">
            @for (tab of tabs(); track tab.title) {
                <button 
                    [class.active]="tab === activeTab()" 
                    (click)="select(tab)"
                >{{ tab.title }}</button>
            }
        </div>
        <ng-content />
    `
})
export class TabsComponent {
    // ดึง <app-tab> ทุกตัวที่ถูก project เข้ามาเป็นลูก
    // (TabComponent ต้อง import ก่อน เช่น: import { TabComponent } from './tab/tab.component')
    tabs = contentChildren(TabComponent);
    
    activeTab = signal<TabComponent | null>(null);
    
    constructor() {
        // เปิด tab แรกหลังเนื้อหาพร้อม
        effect(() => {
            if (!this.activeTab() && this.tabs().length > 0) {
                this.activeTab.set(this.tabs()[0]);
            }
        });
    }
    
    select(tab: TabComponent) {
        this.activeTab.set(tab);
    }
}
text
viewChild       — element ใน template ของตัวเอง
contentChild    — element ที่แม่ project เข้ามาผ่าน <ng-content>

Read Different Token

⚠️ ขั้นสูงมาก — ข้ามไปข้อ 13 ได้เลย ถ้าเพิ่งเริ่ม · เนื้อหาตั้งแต่นี้จนจบ section นี้เกี่ยวกับ dependency injection token ขั้นสูง

typescript
// ดึง instance ของ directive จาก element ที่มี directive นั้น
formGroup = viewChild('myForm', { read: FormGroupDirective });

// ดึง TemplateRef (อ้างอิงไปยัง template block — ใช้สร้าง view แบบ dynamic = สร้าง/โหลด ณ ขณะรัน ไม่ใช่ตอน compile)
tpl = viewChild('myTpl', { read: TemplateRef });

// ดึง ViewContainerRef (จุดที่ใช้ insert template/component แบบ dynamic — ใช้เมื่อไม่รู้ล่วงหน้าว่าจะแสดง component ไหน)
container = viewChild('host', { read: ViewContainerRef });

💡 TemplateRef / ViewContainerRef = ขั้นสูงมาก (ใช้สร้าง component แบบ dynamic) — ข้ามได้แน่นอนถ้าเพิ่งเริ่ม


12.5. Host Bindings + Host Listeners

"Host" คือ element ตัวนอกสุดของ component เอง (เช่น <app-card>) — บางทีเราอยากผูก class/attribute หรือดักเหตุการณ์บนตัวมันเอง ไม่ใช่ element ข้างใน · ทำผ่าน host: {} ใน metadata:

Host Metadata (Modern Way)

typescript
@Component({
    selector: 'app-card',
    standalone: true,
    template: `<ng-content />`,
    host: {
        // class คงที่
        'class': 'app-card',
        
        // binding แบบ reactive (signal/expression)
        '[class.elevated]': 'elevated()',
        '[class.disabled]': 'disabled()',
        '[attr.role]': '"region"',
        '[attr.aria-label]': 'label()',
        '[style.--card-color]': 'color()',
        
        // ดักเหตุการณ์ (event listener)
        '(click)': 'onClick()',
        '(keydown.enter)': 'onClick()',
        '(mouseenter)': 'onHover(true)',
        '(mouseleave)': 'onHover(false)'
    }
})
export class CardComponent {
    elevated = input(false);
    disabled = input(false);
    label = input('');
    color = input('blue');
    
    onClick() { /* ... */ }
    onHover(hovering: boolean) { /* ... */ }
}

→ สะอาดกว่าการใช้ decorator @HostBinding / @HostListener แบบเก่า

@HostBinding / @HostListener (Older)

typescript
import { Component, HostBinding, HostListener } from '@angular/core';   // ต้อง import ทั้งสองตัวนี้ก่อนใช้ decorator ข้างล่าง

@Component({...})
export class CardComponent {
    @HostBinding('class.elevated') get isElevated() {
        return this.elevated();
    }
    
    @HostListener('click') onClick() { /* ... */ }
}

→ Modern Angular แนะนำให้ใช้ host: {} metadata แทน decorator แบบเก่า


12.6. Host Directive (Angular 15+)

ขั้นสูงมาก — ข้ามได้แน่นอนถ้าเพิ่งเริ่ม · ใช้แนวคิด "composition over inheritance" ซึ่งควรมีพื้น OOP (Object-Oriented Programming = การเขียนโปรแกรมเชิงวัตถุ) มาก่อน

Host directive ให้เรา "แปะพฤติกรรมสำเร็จรูป" (เช่น hover, tooltip) เข้าไปใน component ได้โดยไม่ต้องสืบทอด (inheritance) — เป็นการใช้ composition แทน ทำให้แชร์พฤติกรรมข้ามหลาย component ได้สะอาด:

typescript
// พฤติกรรมที่ใช้ซ้ำได้ — แปะให้หลาย component โดยไม่ต้องสืบทอด (inheritance)
@Directive({
    selector: '[appHover]',
    standalone: true,
    host: {
        '(mouseenter)': 'hover.set(true)',
        '(mouseleave)': 'hover.set(false)',
        '[class.hovering]': 'hover()'
    }
})
export class HoverDirective {
    hover = signal(false);
}

// ประกอบเข้าไปใน component
import { Component, inject, computed } from '@angular/core';

@Component({
    selector: 'app-card',
    standalone: true,
    hostDirectives: [
        HoverDirective,                    // แบบง่าย
        {
            directive: TooltipDirective,    // แบบมี config
            inputs: ['text: tooltipText'],   // ตั้งชื่อแฝง/ส่งต่อ (alias/forward)
            outputs: ['shown: tooltipShown']
        }
    ],
    template: `<ng-content />`
})
export class CardComponent {
    // Card ได้พฤติกรรม hover + tooltip มาโดยไม่ต้องเขียนโค้ดเอง
    private hover = inject(HoverDirective);
    
    isHovering = computed(() => this.hover.hover());
}

→ ใช้ composition แทน inheritance — แชร์พฤติกรรมข้าม component ได้โดยไม่ต้องสืบทอด

html
<!-- วิธีใช้ — input/output ถูกส่งต่อ (forward) ออกมาที่ตัว card -->
<app-card tooltipText="Click me!" (tooltipShown)="onShown()">
    Card content
</app-card>

→ ใช้บ่อย: CVA (Control Value Accessor = ตัวกลางที่เชื่อม custom input component กับ Angular Forms — เรียนในบทฟอร์ม), ripple effect (เอฟเฟกต์คลื่นตอนคลิก), focus indicator (ตัวบอกว่ากำลัง focus)


13. Component Styles + Encapsulation

จุดเด่นของ Angular: style ที่เขียนใน component มีผลเฉพาะ component นั้น ไม่รั่วไปกระทบที่อื่น (เรียกว่า encapsulation) — Angular ทำให้อัตโนมัติโดยเพิ่ม attribute พิเศษ (เช่น _ngcontent-abc123) ให้แต่ละ element เพื่อจำกัดขอบเขต CSS:

Default — Emulated

scss
/* button.component.scss */
button {
    background: blue;
}

→ Angular เพิ่ม attribute _ngcontent-xxx — style scoped ที่ component นี้เท่านั้น

Encapsulation Modes

typescript
@Component({
    encapsulation: ViewEncapsulation.Emulated     // default — จำกัดขอบเขต (scoped)
    // หรือ
    encapsulation: ViewEncapsulation.None          // มีผลทั้งแอป (global, ไม่จำกัดขอบเขต)
    // หรือ
    encapsulation: ViewEncapsulation.ShadowDom     // ใช้ shadow DOM จริงของเบราว์เซอร์
})

:host Selector

scss
:host {
    display: block;
    padding: 16px;
}

:host(.large) {
    padding: 32px;
}

:host-context(.dark-theme) {    /* dark-theme class มักถูกแปะไว้ที่ <body> หรือ ancestor element เมื่อ user เลือก dark mode */
    background: #000;
}
/* ⚠️ :host-context() มี browser support ไม่ครบ (Firefox ไม่รองรับ) — สำหรับ theming ใน production แนะนำใช้ CSS custom properties (CSS variables) แทน */

:host = component element ตัวเอง

Deep Selector (Avoid)

scss
::ng-deep .child {
    color: red;
}

::ng-deep ข้าม encapsulation (ทะลุขอบเขต) — deprecated (ถูกประกาศว่าจะเลิกรองรับในอนาคต) ควรเลี่ยง

ทางเลือกที่แนะนำแทน ::ng-deep:

  • CSS custom properties (CSS variables)--card-bg: blue; กำหนดใน host แล้วใช้ใน child: เหมาะที่สุดสำหรับ theming
  • ViewEncapsulation.None — ปิด encapsulation ทั้ง component นั้น เหมาะสำหรับ library component ที่ตั้งใจให้ style ทะลุได้
  • ปรับ component ลูกให้รับ input มา style เอง — ดีที่สุดในแง่ encapsulation

14. Component Inputs — Advanced

input() ทำได้มากกว่ารับค่าเฉย ๆ — กำหนดให้บังคับใส่ (required), ใส่ค่า default, ตั้งชื่อแฝง (alias), หรือแปลงค่าตอนรับ (transform) ได้ นี่คือ option ที่ใช้บ่อย:

typescript
import { Component, input } from '@angular/core';

@Component({...})
export class UserCardComponent {
    // บังคับต้องส่งมา (required)
    user = input.required<User>();
    
    // ไม่บังคับ มีค่า default
    showEmail = input(true);
    
    // ตั้งชื่อแฝง (alias) — ใน template ใช้ชื่อ color
    primaryColor = input('blue', { alias: 'color' });
    
    // แปลงค่าตอนรับ (transform)
    age = input<number, string | number>(0, {
        transform: (value) => Number(value)
    });
}
html
<!-- วิธีใช้ -->
<app-user-card [user]="u" />
<app-user-card [user]="u" [showEmail]="false" />
<app-user-card [user]="u" [color]="'red'" />
<app-user-card [user]="u" age="25" />     <!-- ตั้งใจไม่ใส่ [ ] เพื่อโชว์ว่า transform แปลง string "25" เป็น number ให้อัตโนมัติ — ไม่ใช่พิมพ์ผิด -->

15. Container vs Presentational

แพตเทิร์นยอดนิยม: แยก component เป็น 2 ชนิด — Container (smart) จัดการ state + เรียก service, Presentational (dumb = ในบริบทนี้หมายถึงไม่จัดการ state เอง ไม่ได้แปลว่าโง่) แค่รับ input มาแสดงผล ไม่รู้ที่มาข้อมูล · ผลคือ presentational เอาไปใช้ซ้ำที่ไหนก็ได้ + test ง่าย:

Container (Smart)

typescript
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
// import { UserService } from './user.service';     ← ต้อง import service ที่สร้างไว้ (บทที่ 3)
// import { User } from './models/user';              ← ต้อง import type

@Component({
    selector: 'app-user-list-container',
    standalone: true,
    template: `
        <app-user-list 
            [users]="users()" 
            (userSelected)="onSelect($event)" 
        />
    `,
    imports: [UserListComponent]
})
export class UserListContainer {
    private userService = inject(UserService);

    // ⭐ แนะนำ: toSignal() แปลง Observable → signal โดยอัตโนมัติ unsubscribe ตอน component หายไป
    // (toSignal + RxJS รายละเอียดบทที่ 6 — ตอนนี้แค่จำว่ามันปลอดภัยกว่า .subscribe() เปล่า ๆ)
    users = toSignal(this.userService.getAll(), { initialValue: [] as User[] });

    // ❌ แบบเก่า (เสี่ยง memory leak (หน่วยความจำรั่ว — โปรแกรมจองหน่วยความจำไว้แต่ไม่ปล่อยคืน ทำให้แอปช้าลงเรื่อย ๆ) — ลืม unsubscribe):
    //   ngOnInit() { this.userService.getAll().subscribe(users => this.users.set(users)); }
    // ถ้าจำเป็นต้อง .subscribe() เอง ให้ pipe(takeUntilDestroyed()) เสมอ (บทที่ 6)

    onSelect(user: User) {
        console.log('selected', user);
    }
}

Presentational (Dumb)

typescript
import { Component, input, output } from '@angular/core';

@Component({
    selector: 'app-user-list',
    standalone: true,
    template: `
        @for (u of users(); track u.id) {
            <div (click)="userSelected.emit(u)">{{ u.name }}</div>
        }
    `
})
export class UserListComponent {
    users = input.required<User[]>();
    userSelected = output<User>();
}

→ Container = จัดการ state + logic. Presentational = แสดงผลอย่างเดียว + เอาไปใช้ซ้ำได้


16. Form Input Example

ตัวอย่างเล็ก ๆ รวมหลายอย่างที่เพิ่งเรียน — รับค่าจาก input ด้วย [(ngModel)] แล้วแสดงผลแบบ reactive (เปลี่ยนไปพิมพ์ เปลี่ยนไปแสดง):

typescript
import { Component, signal, computed } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
    selector: 'app-greeting',
    standalone: true,
    imports: [FormsModule],
    template: `
        <!-- name() ต้องมีวงเล็บเพราะเป็น signal -->
        <input [ngModel]="name()" (ngModelChange)="name.set($event)" placeholder="Your name">
        
        @if (name().length > 0) {
            <p>{{ greeting() }}</p>
        }
    `
})
export class GreetingComponent {
    name = signal('');    // ⭐ ต้องเป็น signal เพราะ computed() ติดตาม signal เท่านั้น
    
    greeting = computed(() => `Hello, ${this.name()}!`);
    // computed() จะ re-evaluate อัตโนมัติทุกครั้งที่ name() เปลี่ยน
}

💡 computed() ต้องใช้กับ signal เท่านั้น — ถ้า name เป็น plain string (ไม่ใช่ signal) computed จะไม่อัปเดตเมื่อ name เปลี่ยน ดูรายละเอียด signal ในบทที่ 2


17. Common Patterns

3 แพตเทิร์นที่เจอแทบทุกแอป — สลับ class ตามเงื่อนไข, แสดงสถานะ loading/error/data, และทำ list ที่กรองได้ · จำไว้แล้วเอาไปประกอบใช้ได้เลย:

Conditional Class

html
<div [class.active]="isActive"></div>
<div [class.error]="hasError" [class.warning]="hasWarning"></div>

<!-- หลาย class พร้อมกัน -->
<div [ngClass]="{
    active: isActive,
    disabled: isDisabled,
    'highlight-yellow': isHighlighted
}"></div>

Loading State

html
@if (loading()) {
    <app-spinner />
} @else if (error()) {
    <p class="error">{{ error()?.message }}</p>
} @else if (data()) {
    <app-data-view [data]="data()!" />
} @else {
    <p>No data</p>
}

List with Filter

typescript
import { Component, signal, computed } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
    standalone: true,
    imports: [FormsModule],
    template: `
        <!-- search เป็น signal — ต้องใช้ [ngModel] + (ngModelChange) แทน [(ngModel)] -->
        <input [ngModel]="search()" (ngModelChange)="search.set($event)">

        @for (user of filteredUsers(); track user.id) {
            <div>{{ user.name }}</div>
        } @empty {
            <p>No users found</p>
        }
    `
})
export class UserFilterComponent {
    search = signal('');
    users = signal<User[]>([]);

    filteredUsers = computed(() =>
        this.users().filter(u =>
            u.name.toLowerCase().includes(this.search().toLowerCase())
        )
    );
}

💡 [(ngModel)] ใช้กับ plain string ได้ — แต่ถ้า search เป็น signal ต้องแยกเป็น [ngModel]="search()" + (ngModelChange)="search.set($event)" เพราะ [(ngModel)] จะพยายาม assign ค่าลงใน signal object โดยตรงซึ่งไม่ถูกต้อง


18. ⚠️ Common Pitfalls

ข้อผิดพลาดที่มือใหม่ Angular เจอซ้ำ ๆ — ตารางซ้าย "สิ่งที่มักทำผิด" ขวา "สิ่งที่ถูก" อ่านผ่านไว้ช่วยประหยัดเวลา debug:

❌ ที่มักทำผิด✅ ที่ถูก
ลืม standalone: trueใส่ไว้ (เป็น default ตั้งแต่ v17+)
ลืม array importsใส่ให้ครบ เช่น FormsModule, RouterLink
ใช้ *ngIf, *ngFor (ของเก่า)เปลี่ยนไปใช้ @if, @for
@for ไม่มี trackใส่ track เสมอ
ใช้ property ธรรมดาใช้ signal() เพื่อให้ reactive
แก้ค่า input จากในลูกใช้ output แจ้งกลับให้แม่แก้แทน
ลืม unsubscribe (ยกเลิกรับค่า)ใช้ toSignal() (แนะนำ), async pipe, หรือ takeUntilDestroyed() (รายละเอียดบทที่ 6)
แก้ค่า signal ตรง ๆใช้ .set() หรือ .update()
style รั่วไปกระทบ component อื่นใช้แบบ scoped (default) — อย่าใช้ :host-context พร่ำเพรื่อ

19. ตัวอย่างเต็ม — Todo Component

รวมทุกอย่างในบทนี้เป็นแอปจริงตัวเล็ก — Todo ที่เพิ่ม/ติ๊ก/ลบได้ ใช้ signal + computed + control flow + event binding ครบ · ลองอ่านโค้ดแล้วโยงกลับว่าแต่ละส่วนคือ concept ไหนที่เพิ่งเรียน:

⚠️ หมายเหตุ zoneless: ตัวอย่างนี้ใช้ newTodo = '' เป็น plain property กับ [(ngModel)] ซึ่งทำงานได้ใน Zone.js mode (default ใน project เก่า) — ถ้าใช้ provideZonelessChangeDetection() ให้เปลี่ยน newTodo = signal('') และใช้ [ngModel]="newTodo()" (ngModelChange)="newTodo.set($event)" แทน

typescript
import { Component, signal, computed } from '@angular/core';
import { FormsModule } from '@angular/forms';

interface Todo {
    id: number;
    text: string;
    done: boolean;
}

@Component({
    selector: 'app-todo',
    standalone: true,
    imports: [FormsModule],
    template: `
        <div class="todo-app">
            <h1>Todo</h1>
            
            <div class="input">
                <input 
                    [(ngModel)]="newTodo" 
                    (keyup.enter)="add()"
                    placeholder="What to do?"
                >
                <button (click)="add()" [disabled]="!newTodo">Add</button>
            </div>
            
            <div class="stats">
                <span>{{ remaining() }} remaining</span>
                <span>{{ done() }} done</span>
            </div>
            
            <ul>
                @for (todo of todos(); track todo.id) {
                    <li [class.done]="todo.done">
                        <input 
                            type="checkbox" 
                            [checked]="todo.done"
                            (change)="toggle(todo.id)"
                        >
                        <span>{{ todo.text }}</span>
                        <button (click)="remove(todo.id)">×</button>
                    </li>
                } @empty {
                    <p>No todos yet</p>
                }
            </ul>
            
            @if (done() > 0) {
                <button (click)="clearDone()">Clear done</button>
            }
        </div>
    `,
    styles: `
        .todo-app { max-width: 400px; margin: 2rem auto; }
        .input { display: flex; gap: 8px; }
        .input input { flex: 1; padding: 8px; }
        .done { text-decoration: line-through; opacity: 0.5; }
        li { display: flex; align-items: center; gap: 8px; padding: 8px; }
    `
})
export class TodoComponent {
    newTodo = '';                       // plain string + [(ngModel)] ใช้ได้เพราะ FormsModule ยิง event ให้
    todos = signal<Todo[]>([]);
    nextId = 1;
    // 💡 ในโหมด zoneless (บทที่ 0 §7) plain property จะไม่ trigger change detection เอง
    //    ถ้าทำแอป zoneless แท้ ๆ ให้เปลี่ยน newTodo = signal('') และใช้ model('') แทน
    
    remaining = computed(() => this.todos().filter(t => !t.done).length);
    done = computed(() => this.todos().filter(t => t.done).length);
    
    add() {
        if (!this.newTodo.trim()) return;
        this.todos.update(todos => [
            ...todos,
            { id: this.nextId++, text: this.newTodo, done: false }
        ]);
        this.newTodo = '';
    }
    
    toggle(id: number) {
        this.todos.update(todos =>
            todos.map(t => t.id === id ? { ...t, done: !t.done } : t)
        );
    }
    
    remove(id: number) {
        this.todos.update(todos => todos.filter(t => t.id !== id));
    }
    
    clearDone() {
        this.todos.update(todos => todos.filter(t => !t.done));
    }
}

20. Checkpoint

🛠️ Checkpoint 1.1 — Hello Component
สร้าง app-greeting:

  • รับ input ชื่อ name (ชนิด string)
  • แสดงข้อความ "Hello, {name}!"
  • นำไปใช้ใน App โดยส่งชื่อต่างกัน เช่น <app-greeting [name]="'Anna'"> และ <app-greeting [name]="'Bob'"> เพื่อดูว่า component เดียวกันแสดงข้อมูลต่างกันได้

🛠️ Checkpoint 1.2 — Counter

  • app-counter
  • ปุ่ม + / - / reset
  • แสดงค่า count
  • ส่ง output event ชื่อ countChanged กลับไปให้ parent

🛠️ Checkpoint 1.3 — Todo
ทำ Todo ที่ tutorial ข้อ 19 — เพิ่ม:

  • Filter: all / active / done
  • Edit existing todo
  • LocalStorage persistence (bonus สำหรับคนที่รู้ localStorage อยู่แล้ว — hint: localStorage.setItem('todos', JSON.stringify(todos)))

🛠️ Checkpoint 1.4 — User Card

  • app-user-card ที่รับ user input
  • แสดงชื่อ, อีเมล, รูป (ถ้าไม่มีรูปให้แสดงรูป default)
  • ใช้ @if สำหรับกรณีที่ user เป็น null
  • จัด style ด้วย :host

🛠️ Checkpoint 1.5 — Custom Pipe
สร้าง truncate pipe:

  • {{ longText | truncate:50 }} → ตัด + "..."
  • {{ longText | truncate:50:'...' }} → custom suffix (ข้อความต่อท้าย)

21. สรุปบท

ทบทวนสิ่งที่ได้จากบทนี้ — ถ้าข้อไหนยังไม่มั่นใจ กลับไปทำ Checkpoint ข้อที่เกี่ยวก่อนไปบทถัดไป:

Component = template + class + style — composable UI unit
@Component decorator: selector, standalone, imports, template, style
Template binding: {{ }}, [prop], (event), [(ngModel)]
Control flow: @if, @for (need track), @switch (Angular 17+)
input() + output() = signal-based parent-child communication
model() = two-way binding (Angular 17.2+)
signal() + computed() = primary reactivity
async pipe = รับ Observable มาแสดงค่าอัตโนมัติ (ใช้เป็น | async — ไม่ใช่ @async)
Custom pipe = @Pipe + PipeTransform
Directive = behavior on element (attribute directive common)
Lifecycle: ngOnInit, ngOnDestroy, ngOnChanges — or use effect()
Content projection: <ng-content> for slots
Style encapsulation: Emulated (default) — scoped styles


← บทที่ 0 | บทที่ 2 → Signals + State