โหมดมืด
บทที่ 1 — Components และ Templates
หลังจบบท คุณจะ:
- สร้าง 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 ที่มี propertyidtrack 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 ของ TabsComponent — viewChildren มองไม่เห็นพวกมันเลย ต้องใช้ 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