โหมดมืด
บทที่ 7 — Change Detection + Performance
หลังจบบท คุณจะ:
- เข้าใจ Change Detection — Angular update DOM ยังไง
- ใช้ OnPush strategy
- เข้าใจว่า Angular รู้ได้ยังไงว่าข้อมูลเปลี่ยน และวิธีทำให้แอปเร็วขึ้นโดยไม่ต้องพึ่ง Zone.js (Zoneless, Angular 18+)
- Optimize performance ด้วย signals + tracking
ก่อนอ่าน: ควรเข้าใจ signal (บทที่ 2) มาก่อน
🗺️ บทนี้ยาว 1300+ บรรทัด — มือใหม่อ่านโซนไหนพอ
โซน หัวข้อ ใครควรอ่าน แกนหลัก (ต้องเข้าใจ) ข้อ 1-4 (CD basics, Zone, OnPush, Immutability) + ข้อ 5 (Signal-based CD) + ข้อ 6.4.1 (error ที่ Angular แจ้งเตือนเมื่อค่าเปลี่ยนระหว่าง render) ทุกคน ขั้นสูง (กลับมาเมื่อแอปช้า) ข้อ 6.5+ (NgZone runOutsideAngular), ข้อ 6.6 (Web Worker), Performance optimization, Bundle, Web Vitals, CDN เก็บไว้ทีหลัง 👉 มือใหม่: อ่านจบข้อ 5 + Common Pitfalls (ข้อ 26) + Checkpoint (ข้อ 28) ก็พอ — performance optimization ลึก ๆ ค่อยกลับมาเมื่อทำแอปจริงแล้วเจอช้า
1. Change Detection คืออะไร
Change Detection (CD) คือกลไกที่ Angular ใช้ "เช็คว่าข้อมูลเปลี่ยนไหม แล้วอัปเดตหน้าจอตาม" — หัวใจที่ทำให้ UI sync (= synchronize, ทำให้ตรงกัน/อัปเดตพร้อมกัน) กับ state อัตโนมัติ
📖 binding คืออะไร? binding คือตัวเชื่อม component class กับ HTML template — เช่น
{{ count }}แสดงค่าตัวแปร หรือ[disabled]="isLoading"ผูก property ของ HTML element กับค่าใน class
🔍 เปรียบเทียบ: CD เหมือนบรรณาธิการที่ไล่ตรวจทั้งหนังสือว่ามีหน้าไหนต้องแก้ไหม ทุกครั้งที่มีอะไรเกิดขึ้น (แต่ละ "หน้า" ในนี้ = component หนึ่งตัวในแอป) · ปัญหาคือถ้าไล่ตรวจ "ทุกหน้า" ทุกครั้งจะช้า — บทนี้ว่าด้วยการทำให้มันตรวจ "เฉพาะหน้าที่เปลี่ยนจริง"
text
ทุกครั้งที่ data เปลี่ยน — Angular ต้องรู้ → update DOM
ต้องตรวจ:
- @Input เปลี่ยนไหม?
- property เปลี่ยนไหม?
- ค่าที่ method คืนเปลี่ยนไหม?
- ...
- → ไล่ทั้งต้นไม้ component → อัปเดต binding
คำถาม:
"เมื่อไหร่ Angular เริ่ม CD?"
→ คำตอบสั้น: Angular เริ่ม CD เมื่อมี async event เกิดขึ้น (ค่าเริ่มต้น)
หรือเมื่อ signal เปลี่ยน (แบบใหม่ที่แนะนำ) — รายละเอียดในหัวข้อถัดไป2. Default — Zone.js
ค่าเริ่มต้น Angular ใช้ Zone.js — library ที่ "ดักจับทุกงาน async" (setTimeout, Promise, คลิก, XMLHttpRequest) แล้วบอก Angular ให้รัน CD · ข้อดีคือ "ทำงานเอง" ไม่ต้องสั่ง แต่ข้อเสียคือตรวจบ่อยเกิน (ทุก async = ไล่เช็คทั้งต้นไม้):
text
Zone.js ดักจับ (patch = เขียนทับฟังก์ชัน async มาตรฐานของ browser เพื่อให้รู้ว่าทำงานเสร็จเมื่อไหร่):
- setTimeout, setInterval
- Promise.then
- addEventListener
- XMLHttpRequest ← patch โดย default
- fetch ← ไม่ patch โดย default ใน Zone.js — ถ้าใช้ fetch ตรง ๆ (ไม่ผ่าน withFetch ของ HttpClient) อาจไม่ trigger CD อัตโนมัติ (ตรวจสอบ plugin/เวอร์ชัน zone.js ปัจจุบันถ้าต้องพึ่งพา fetch patch)
- ... (ทุกอย่างที่ async ที่ Zone รู้จัก)
พองาน async เสร็จ → Zone บอก Angular
→ Angular รัน change detection
→ อัปเดต DOM ถ้าจำเป็นtypescript
// ที่ไหนก็ได้ — Angular ตรวจให้อัตโนมัติ
setTimeout(() => {
this.count++;
// → Zone บอก Angular → CD รัน → DOM อัปเดต
}, 1000);→ "ทำงานเองไม่ต้องสั่ง" — แต่เปลือง (รัน CD ทุกครั้งที่มีงาน async)
3. ChangeDetectionStrategy.OnPush
OnPush คือการบอก Angular ว่า "component นี้ตรวจเฉพาะเมื่อจำเป็นจริง ๆ พอ" — ตรวจเมื่อ input เปลี่ยน reference, มี event ในตัว, หรือ async pipe emit · ผลคือ component ส่วนใหญ่ถูกข้าม → เร็วขึ้นมาก (best practice คู่กับ immutable data หรือ signal):
typescript
import { ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-user',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<div>{{ user().name }}</div>`
})
export class UserComponent {
user = input.required<User>();
}→ OnPush = "ตรวจ CD เฉพาะเมื่อ":
input()/@Inputเปลี่ยน reference (เป็น object ใหม่ ไม่ใช่แก้ของเดิม) — signal input ใช้ได้ทันที📖 reference คืออะไร? reference = "ที่อยู่" ของ object ในหน่วยความจำ — ถ้าทำ
obj.name = 'new'ที่อยู่เดิม (reference เท่าเดิม) แต่ถ้าทำobj = {...obj, name: 'new'}จะสร้าง object ใหม่ที่อยู่ใหม่ (reference เปลี่ยน) → OnPush เห็นมี event เกิดใน component (click ฯลฯ)
Observable (สตรีมข้อมูลแบบเป็นชุด ดูบทที่ 6) ส่งค่าผ่าน
asyncpipe (| asyncในเทมเพลต — ถ้ายังไม่ได้อ่านบทที่ 6 รู้แค่ว่า async pipe คือ syntax ใน HTML สำหรับฟัง Observable อัตโนมัติ)สั่งเอง:
ChangeDetectorRef.markForCheck()— ตัวช่วย (class helper) สั่งให้ Angular ตรวจ component นี้ในรอบ CD ถัดไป (รายละเอียดใน Section 6)
💡 มือใหม่อ่านที่นี่ก่อน: ใช้
input()signal + signal state จะปลอดภัยที่สุด — เพราะ signal บอก Angular เองว่าค่าเปลี่ยน ไม่ต้องห่วงเรื่อง reference
ทำไม OnPush ถึงเร็วกว่า
text
ไม่ใช้ OnPush:
- คลิกที่ไหนก็ได้ → CD ไล่ตรวจ "ทุก" component
- ตั้งค่าตัวแปรอะไรก็ตาม → CD ไล่ตรวจ binding ทั้งหมด
ใช้ OnPush:
- คลิกที่ไหนก็ได้ → CD ตรวจเฉพาะ component ในเส้นทางที่ร้องขอ
- component ส่วนใหญ่ถูกข้าม → เร็วขึ้นมาก→ best practice: OnPush + ข้อมูลแบบ immutable (แก้ไม่ได้ ต้องสร้างใหม่)
4. Immutability with OnPush
จุดพลาดที่มือใหม่เจอบ่อยกับ OnPush: ถ้า "แก้ของเดิม" (เช่น array.push()) OnPush จะไม่เห็นเพราะ reference เท่าเดิม · ต้อง "สร้างใหม่" ([...arr, item]) ให้ reference เปลี่ยน OnPush ถึงจะอัปเดต:
typescript
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (user of users(); track user.id) {
<div>{{ user.name }}</div>
}
`
})
export class UserListComponent {
users = input<User[]>([]);
}❌ แก้ของเดิม (mutation) — OnPush มองไม่เห็น
typescript
// ฝั่งแม่
this.users.push(newUser); // แก้ของเดิม — reference เท่าเดิม
// ลูกที่เป็น OnPush — จะไม่อัปเดต✅ สร้าง reference ใหม่
typescript
this.users = [...this.users, newUser]; // array ใหม่ (reference เปลี่ยน)
// OnPush เห็น → อัปเดต💡 Signal input ลัดปัญหานี้ให้แทบทั้งหมด: ถ้าใช้
users = input<User[]>([])แล้วฝั่งแม่อัปเดตด้วย signal (this.users.set([...])หรือ.update(u => [...u, x])) — Angular notify ตามค่าของ signal เอง ไม่ใช่ตาม reference เปล่า ๆ ทำให้ลืม immutability ไปได้บ่อย ๆ (แต่ก็ยัง "ไม่ mutate" ดีกว่า เพื่อหลีกเลี่ยงปัญหาอื่น)
5. Signal — Solves OnPush Problem
signal แก้ปัญหาความยุ่งยากของ OnPush + immutability ให้หมด — เพราะ signal "รู้ตัวเอง" ว่าค่าเปลี่ยน แล้วบอก Angular ให้อัปเดตเฉพาะจุดที่ใช้ค่านั้น โดยไม่ต้องกังวลเรื่อง reference · signal + OnPush = คู่ที่ดีที่สุด (เร็ว + เขียนง่าย):
📖 ข้อแตกต่างสำคัญ:
@Inputธรรมดา + OnPush: ต้อง immutable (สร้าง object/array ใหม่เสมอ)- signal + OnPush: ไม่ต้อง immutable เพราะ signal notify Angular โดยตรงว่าค่าเปลี่ยน
signal สั่ง CD เฉพาะจุด (fine-grained = เฉพาะจุดแม่นยำ ตรงข้ามกับ coarse-grained ที่ตรวจเป็นกลุ่มกว้าง) ให้อัตโนมัติ:
typescript
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div>Count: {{ count() }}</div>
<button (click)="increment()">+</button>
`
})
export class CounterComponent {
count = signal(0);
increment() {
this.count.update(c => c + 1);
// signal เปลี่ยน → Angular ทำเครื่องหมายว่า component นี้ต้องตรวจ → CD รัน
}
}→ Signal + OnPush = ได้ทั้งสองอย่าง — เร็ว + เขียนสบาย
6. ChangeDetectorRef
ถ้าต้องคุม CD เองแบบ manual (กรณีหายากเมื่อ signal/async pipe ไม่ครอบ) ใช้ ChangeDetectorRef — markForCheck() (สั่งตรวจรอบหน้า) ที่ใช้บ่อยสุด · ปัจจุบัน signal แก้ปัญหาพวกนี้ให้แล้วเป็นส่วนใหญ่:
คุมเอง (กรณีหายาก):
typescript
import { ChangeDetectorRef } from '@angular/core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MyComponent {
private cdr = inject(ChangeDetectorRef);
private http = inject(HttpClient);
data: string = '';
loadData() {
// ⚠️ simplified example เพื่อโฟกัสที่ CDR — ใน production ให้ใส่ HTTP call ใน service แยก (ดูบทที่ 6)
this.http.get('/api/data', { responseType: 'text' }).subscribe(text => {
this.data = text;
this.cdr.markForCheck(); // บอก Angular ให้ตรวจ component นี้ใหม่
});
}
}Methods
typescript
cdr.markForCheck(); // ทำเครื่องหมายว่าต้องตรวจ (CD รอบหน้า) — ปลอดภัย, async
cdr.detectChanges(); // รัน CD ทันที (sync) — ⚠️ เสี่ยงเจอ ExpressionChangedAfter ด้านล่าง
cdr.detach(); // ปิด CD ของ component นี้
cdr.reattach(); // เปิดกลับ⚠️ ระวัง detach/reattach: ถ้าเรียก
cdr.detach()แล้วลืมcdr.reattach()UI จะไม่อัปเดตเลย — ใช้เฉพาะเมื่อจำเป็นจริง ๆ
⚠️ ใช้น้อย — มัก signal solve ปัญหา
6.4.1 🔴 ExpressionChangedAfterItHasBeenCheckedError — error ค่าเปลี่ยนหลังตรวจแล้ว
💡 อธิบายแบบมือใหม่ก่อน: ใน dev mode Angular ตรวจหน้าจอ 2 รอบ เพื่อจับบั๊กบางตัว — ถ้า component ลูกแอบเปลี่ยนค่าที่แม่กำลังอ่านอยู่ระหว่างรอบแรก-รอบสอง Angular จะแจ้งเตือนทันที · วิธีแก้ที่ง่ายสุด: ใช้ signal (signal มี schedule ที่ฉลาดกว่า ไม่ทำให้เกิดสภาพนี้)
🔰 ถ้ายังไม่เคยเรียน
@ViewChild: ข้ามตัวอย่างที่พูดถึง@ViewChildไปได้เลย จำแค่หลักการสั้น ๆ พอ — "ใช้ signal แล้วจะไม่เจอ error นี้"
อาการ: ใน dev mode console พ่น error
text
NG0100: ExpressionChangedAfterItHasBeenCheckedError:
Expression has changed after it was checked.
Previous value: 'X'. Current value: 'Y'.ความหมาย: Angular ตรวจรอบแรกเห็นค่า X แต่รอบสองเห็นค่า Y — แปลว่ามีอะไรบางอย่างแก้ค่าระหว่างสองรอบ
สาเหตุ: ใน dev mode Angular รัน CD 2 รอบ (เพื่อจับ inconsistency) — ถ้า child component แก้ค่า ที่ parent อ่านอยู่ ระหว่าง CD รอบแรก → รอบที่ 2 เห็นค่าใหม่ → error
- เกิดบ่อยใน:
@ViewChildที่อ่าน + เขียน, lifecycle hook ที่แก้ parent state, getter ใน template ที่คืนค่าใหม่ทุกครั้ง
ทางแก้ (เรียงจากดีสุด):
📖 หมายเหตุ: ทาง 1 ใช้ signal (แนะนำสำหรับ OnPush) · ทาง 2-4 ใช้สำหรับ plain property ธรรมดา (ไม่ใช่ signal)
typescript
import { signal, afterNextRender } from '@angular/core';
// ✅ ทาง 1 — ใช้ signal (Angular 16+) — Angular จัดการ schedule ให้ (แนะนำ)
private label = signal('initial');
// เปลี่ยนค่าด้วย: this.label.set('updated')
// ✅ ทาง 2 — afterNextRender (Angular 17+) — รันหลัง render รอบนี้จบ
// (สำหรับ plain property ธรรมดา ไม่ใช่ signal)
private label = 'initial';
constructor() {
afterNextRender(() => {
this.label = 'updated'; // plain property — กำหนดตรงได้
});
}
// ✅ ทาง 3 — push เข้า microtask queue
// Promise.resolve().then() เป็น microtask ที่รันหลัง Angular CD รอบปัจจุบันเสร็จสิ้น
// (สำหรับ plain property ธรรมดา ไม่ใช่ signal)
ngAfterViewInit() {
Promise.resolve().then(() => {
this.label = 'updated'; // plain property
});
}
// ⚠️ ทาง 4 (last resort) — บังคับ CD เพิ่ม
ngAfterViewInit() {
this.value = 'new';
this.cdr.detectChanges();
}💡 ทำไม dev mode เท่านั้น: production mode Angular run CD รอบเดียว → ไม่ throw แต่จะมี bug ที่ UI ค้างค่าเก่า — error ใน dev คือการช่วยจับ bug ก่อนขึ้น prod อย่าหลีกเลี่ยง error นี้ด้วยการปิด dev mode (
enableProdMode()หรือ build แบบ production) — มันเป็นเครื่องเตือนให้แก้บั๊กก่อนขึ้น production จริง (ใน production จะไม่ throw แต่หน้าจอจะค้างค่าเก่าแทน ซึ่งหายากกว่ามาก)
6.5. NgZone — runOutsideAngular
🚧 โซนขั้นสูง — มือใหม่ข้ามได้ ข้อ 6.5 ถึงท้ายบทเป็น performance optimization สำหรับ animation/heavy computation/SSR/CDN — กลับมาอ่านเมื่อแอปทำงานช้าจริง ๆ ตอนนี้ข้ามไปอ่าน Common Pitfalls (ข้อ 26) + Checkpoint ได้เลย
ถ้ามี loop ที่ทำงานถี่มาก (เช่น animation วาด chart ทุกเฟรม) แล้วแต่ละรอบไป trigger CD จะทำให้แอปอืด · runOutsideAngular() สั่งให้โค้ดส่วนนั้นทำงาน "นอกสายตา Zone" → ไม่ trigger CD แล้วค่อย zone.run() กลับเข้ามาเฉพาะตอนต้องอัปเดต UI:
📖 requestAnimationFrame = ฟังก์ชัน browser ที่เรียก callback ทุกเฟรม (~60 ครั้ง/วินาที) สำหรับทำ animation ให้ลื่น
typescript
import { NgZone, inject } from '@angular/core';
@Component({...})
export class ChartComponent {
private zone = inject(NgZone);
ngAfterViewInit() {
// ❌ อยู่ใน zone — ทุกเฟรม trigger CD (อืด)
const animate = () => {
this.updateChart();
requestAnimationFrame(animate);
};
animate();
// ✅ อยู่นอก zone — ไม่ trigger CD (ไม่เปลือง)
this.zone.runOutsideAngular(() => {
const animate = () => {
this.updateChart();
requestAnimationFrame(animate);
};
animate();
});
}
// กลับเข้า zone เฉพาะตอนต้องอัปเดต UI (หายาก)
onClickFromCanvas(data: any) {
this.zone.run(() => {
this.selectedData.set(data); // อัปเดต signal ใน zone
});
}
}เมื่อไหร่ควรใช้ runOutsideAngular
text
✅ loop ของ requestAnimationFrame (chart, canvas, three.js)
✅ DOM event ที่เกิดถี่ (mousemove, scroll, resize, drag)
✅ setInterval ที่ไม่ต้อง update UI
✅ ตัวจัดการ postMessage (ข้อความที่ส่งระหว่าง Web Worker กับ main thread) ของ Web Worker (งานที่ไม่เกี่ยว UI — ดู section 6.6 สำหรับรายละเอียด Web Worker)
✅ loop วาด WebGL
❌ อย่าใช้:
- ใน Zoneless mode (NgZone = ไม่ทำอะไร/no-op)
- เพื่อ "optimize" โค้ดที่ update UI (มันจะไม่ trigger CD แล้วหน้าจอไม่อัปเดต)Modern Angular (Zoneless)
typescript
// ในโหมด zoneless, NgZone มีให้แต่ runOutsideAngular = ไม่ทำอะไร (no-op)
// แค่อย่าอ่าน/เขียน signal ใน loop ที่ทำงานถี่ ก็พอ
@Component({...})
export class CanvasComponent {
counter = signal(0);
ngAfterViewInit() {
const animate = () => {
// คำนวณหนัก — ไม่ trigger CD เพราะไม่ได้ set/update signal ที่ template ใช้อยู่
this.draw();
requestAnimationFrame(animate);
};
animate();
}
onClick() {
this.counter.update(c => c + 1); // อัปเดต signal แบบชัดเจน → trigger CD
}
}📖 ชัดเจนกว่านี้อีกนิด: ในโหมด zoneless, CD จะถูกสั่งตอนที่มี signal ที่ template เอาไปใช้แสดงผล ถูก
.set()/.update()— ไม่ใช่แค่ "อ่าน" signal เฉยๆ ที่จะไม่ trigger CD เพราะการอ่านไม่ได้บอก Angular ว่าอะไรเปลี่ยน มีแต่การ "เขียน" (set/update) เท่านั้นที่ trigger
→ Zoneless = ชัดเจนโดย default — รู้แน่ ๆ ว่าอะไร trigger CD
6.6. Web Worker — Heavy Computation
JavaScript รันบน thread เดียว (thread = เหมือนพนักงานคนหนึ่งที่ทำงานได้ทีละอย่าง — JavaScript มีพนักงานคนเดียว ถ้าให้ทำงานหนักมาก ๆ งานอื่นเช่น response การคลิกจะต้องรอ) — ถ้าคำนวณหนัก ๆ บน main thread หน้าจอจะค้าง (กดอะไรไม่ได้) · Web Worker ย้ายงานหนักไปทำใน thread แยกเบื้องหลัง แล้วส่งผลกลับมา ทำให้ UI ลื่นต่อ · ส่วนนี้เป็น ขั้นสูง:
งานหนักบน main thread = ทำให้ UI ค้าง → Web Worker ทำงานใน thread เบื้องหลังแยกต่างหาก
สร้าง Worker
bash
ng generate web-worker app→ สร้าง src/app/app.worker.ts + อัปเดต tsconfig.worker.json ให้
Worker Code
typescript
// src/app/app.worker.ts
/// <reference lib="webworker" />
addEventListener('message', ({ data }) => {
const result = heavyCompute(data.input);
postMessage({ result });
});
function heavyCompute(numbers: number[]): number {
return numbers.reduce((sum, n) => sum + Math.sqrt(n), 0);
}Use in Component
⚠️ โค้ดด้านล่างใช้ได้ต่อเมื่อรัน
ng generate web-worker appมาก่อนแล้ว (สร้างtsconfig.worker.jsonให้อัตโนมัติ) — ถ้า copy เฉพาะโค้ดส่วนนี้ไปวางโดยไม่รันคำสั่งข้างต้นก่อนnew URL('./app.worker', import.meta.url)จะ build error
typescript
@Component({
template: `
<button (click)="run()" [disabled]="busy()">Compute</button>
@if (busy()) { <p>Computing...</p> }
@if (result(); as r) { <p>Result: {{ r }}</p> }
`
})
export class ComputeComponent {
busy = signal(false);
result = signal<number | null>(null);
private worker?: Worker;
constructor() {
if (typeof Worker !== 'undefined') {
this.worker = new Worker(
new URL('./app.worker', import.meta.url),
{ type: 'module' }
);
this.worker.onmessage = ({ data }) => {
this.result.set(data.result);
this.busy.set(false);
};
}
}
run() {
if (!this.worker) return;
this.busy.set(true);
this.result.set(null);
const big = Array.from({ length: 10_000_000 }, (_, i) => i);
this.worker.postMessage({ input: big });
}
ngOnDestroy() {
this.worker?.terminate();
}
}Service Wrapper
💡 ตัวอย่างนี้สมมติว่าคุ้นกับ
Promiseและasync/awaitของ JavaScript พื้นฐานมาก่อนแล้ว (ไม่ได้สอนในบทนี้) — ถ้ายังไม่คุ้น อ่านผ่าน ๆ ก่อนได้ แล้วกลับมาอีกทีตอนต้องเขียนจริง
typescript
@Injectable({ providedIn: 'root' })
export class HeavyComputeService {
private worker?: Worker;
constructor() {
if (typeof Worker !== 'undefined') {
this.worker = new Worker(
new URL('./app.worker', import.meta.url),
{ type: 'module' }
);
}
}
compute(input: number[]): Promise<number> {
return new Promise((resolve, reject) => {
if (!this.worker) {
reject(new Error('Worker not supported'));
return;
}
const id = crypto.randomUUID();
const handler = (e: MessageEvent) => {
if (e.data.id === id) {
this.worker!.removeEventListener('message', handler);
resolve(e.data.result);
}
};
this.worker.addEventListener('message', handler);
this.worker.postMessage({ id, input });
});
}
}Use Cases
text
✅ ประมวลผลรูป/วิดีโอ (filter, decode)
✅ Crypto (hash, เข้ารหัส)
✅ sort/search/รวมผล array ขนาดใหญ่
✅ ทำ syntax highlight ให้ Markdown/โค้ด
✅ parse JSON ก้อนใหญ่
✅ WebAssembly bridge (C++/Rust ใน browser — เช่น TensorFlow.js, FFmpeg.wasm)
✅ long-polling (เรียก server ค้างไว้รอข้อมูล) / WebSocket (ช่องสื่อสาร 2 ทางตลอดเวลา) ที่มี shared state
⚠️ อย่าใช้:
- งานเบา (< 16ms) — overhead ไม่คุ้ม
- ต้องเข้าถึง DOM (worker เข้า DOM ไม่ได้)
- แชร์ state กัน (ขั้นสูงต้องใช้ SharedArrayBuffer — บล็อกหน่วยความจำที่แชร์ระหว่าง thread)Comlink — Easier Worker
Comlink คือ library ที่ทำให้การใช้ Web Worker ง่ายขึ้น — แทนที่จะส่ง postMessage ไปมาเอง Comlink ให้เรียก function ใน Worker เหมือน async function ปกติ:
bash
npm install comlinktypescript
// worker.ts
import * as Comlink from 'comlink';
const api = {
add(a: number, b: number) { return a + b; },
async heavy(input: number[]) {
// ...
return result;
}
};
Comlink.expose(api);typescript
// service.ts
import * as Comlink from 'comlink';
@Injectable({ providedIn: 'root' })
export class WorkerService {
private api = Comlink.wrap<any>(
new Worker(new URL('./worker', import.meta.url), { type: 'module' })
);
async add(a: number, b: number) {
return await this.api.add(a, b); // เรียก worker เหมือน method ในเครื่อง
}
}→ Comlink = เรียก worker เหมือนฟังก์ชัน async ปกติ (ไม่ต้องเขียน postMessage ซ้ำ ๆ)
7. Zoneless (Angular 18+)
ทิศทางอนาคตของ Angular — เลิกใช้ Zone.js แล้วพึ่ง signal บอก Angular ตรง ๆ ว่าอะไรเปลี่ยน · ได้ bundle เล็กลง (~30KB จาก zone.js ที่ตัดออก) + เร็วขึ้น + ชัดเจนว่าอะไร trigger CD · ข้อแม้: ต้องใช้ signal (หรือ async pipe) เพราะ property ธรรมดาจะไม่อัปเดตอีกต่อไป:
typescript
// app.config.ts — Angular 20 (stable)
import { provideZonelessChangeDetection } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection()
]
};
// หมายเหตุ: Angular 18 เริ่มใช้ provideExperimentalZonelessChangeDetection() (มีคำว่า Experimental)
// → ในเวอร์ชัน 18.1+ เป็น stable และ Angular 20 ใช้ชื่อ provideZonelessChangeDetection() (ไม่มี Experimental)typescript
// main.ts — เอา import zone.js ออก
// ⚠️ ถ้าโปรเจกต์สร้างด้วย `ng new` ปกติ บรรทัดนี้อยู่ใน src/polyfills.ts หรือ angular.json ไม่ใช่ main.ts โดยตรง
import 'zone.js' // ❌ ลบทิ้งjson
// angular.json — เอาออกจาก polyfills
// path: projects.{ชื่อแอป}.architect.build.options.polyfills
"polyfills": [
"zone.js" // ❌ ลบทิ้ง
]→ bundle เล็กลง (~30KB จาก zone.js ที่ตัดออก — ราว 10% ของ bundle มาตรฐาน ทำให้เปิดหน้าเว็บเร็วขึ้น), เร็วขึ้น, reactivity ชัดเจน
Zoneless = Signal Required
text
ไม่มี Zone.js → Angular ไม่รู้ว่างาน async เกิดเมื่อไร
→ ต้องใช้ signal (หรือ RxJS ผ่าน async pipe) เพื่อให้ reactive
❌ property ธรรมดา — ไม่อัปเดต
@Component({...})
export class Counter {
count = 0;
increment() { this.count++; } // ในโหมด zoneless จะไม่ trigger CD!
}
✅ ใช้ signal
export class Counter {
count = signal(0);
increment() { this.count.update(c => c + 1); }
}→ Zoneless = อนาคตของ Angular — เขียนด้วย signal ตั้งแต่ต้น
8. CD Tree Strategy
ภาพรวมกลยุทธ์: ตั้งเป้าให้ component เป็น OnPush ทั้งต้นไม้ (หรือไปทาง zoneless + signal เลย) — เมื่อทุกตัวเป็น OnPush, Angular จะตรวจเฉพาะกิ่งที่ข้อมูลเปลี่ยนจริง ไม่ไล่ทั้งต้น:
text
App
├── Header (OnPush)
├── Sidebar (Default)
└── MainContent (OnPush)
├── ListView (OnPush)
│ ├── ItemCard 1 (OnPush)
│ ├── ItemCard 2 (OnPush)
│ └── ItemCard 3 (OnPush)
└── DetailPanel (OnPush)→ เป้าหมาย: OnPush ทั้งหมด (หรือไป zoneless + signal เลย)
9. Performance Patterns
รวมเทคนิคเพิ่ม performance ที่ทำได้เลย — เริ่มจากที่สำคัญสุดคือ track ใน @for (บอก Angular ว่าจะระบุแต่ละ item ด้วยอะไร ไม่งั้นมันวาด list ใหม่ทั้งหมดเมื่อข้อมูลเปลี่ยน):
Track in @for
html
<!-- ❌ track ทั้งก้อน item (Angular ต้องเช็คทุก property ของ item ทุกรอบ — ช้ากว่า track แค่ id ที่เปรียบเทียบตัวเลขเดียว) -->
@for (item of items; track item) { }
<!-- ✅ track ด้วย id ที่ไม่ซ้ำ -->
@for (item of items; track item.id) { }
<!-- ✅ หรือใช้ index (ถ้า item ไม่ถูกสลับลำดับ) -->
@for (item of items; track $index) { }⚠️ ทำไม track $index อันตรายถ้า list ถูกสลับ/ลบ: ถ้าลบ item ตัวแรกออก index ของทุกตัวที่เหลือจะขยับ (ตัวที่เคยเป็น index 1 กลายเป็น 0) — Angular จะจับคู่ DOM node ผิดตัว ทำให้ข้อมูลในจอสลับสับสน ใช้
track $indexได้เฉพาะ list ที่ไม่มีการเพิ่ม/ลบ/สลับลำดับเท่านั้น
→ Angular ใช้ DOM node เดิมซ้ำเมื่อ track ตรงกัน — เร็วขึ้นมาก
Use Signal for State
typescript
// ❌ property ธรรมดา
count = 0;
// ✅ Signal
count = signal(0);Avoid Function in Template
html
<!-- ❌ ฟังก์ชันถูกเรียกทุกรอบ CD -->
<p>{{ getTotal() }}</p>
<!-- ✅ computed signal — จำผลไว้ (memoized) -->
<p>{{ total() }}</p>typescript
// In component
total = computed(() => this.items().reduce((s, i) => s + i.price, 0));Avoid Heavy Pipe
📖 pure vs impure pipe: pure pipe (ค่าเริ่มต้น) = รันใหม่เฉพาะเมื่อ input เปลี่ยน · impure pipe (pure: false) = รันใหม่ทุกรอบ CD ทุกครั้ง
html
<!-- ⚠️ pipe ที่ impure จะรันทุกรอบ CD -->
<p>{{ data | heavyTransform }}</p>
<!-- ✅ ใช้ computed -->
<p>{{ transformed() }}</p>typescript
transformed = computed(() => heavyTransform(this.data()));Defer Heavy Components
📖 viewport = พื้นที่หน้าจอที่มองเห็นได้ในขณะนั้น (ไม่รวมส่วนที่ scroll ออกไป)
html
@defer (on viewport) {
<app-heavy-chart [data]="data()" />
} @placeholder {
<p>Scroll to load chart</p>
} @loading {
<p>Loading...</p>
} @error {
<p>Failed to load</p>
}→ วาดเฉพาะตอนเลื่อนมาถึง (อยู่ในจอ)
ตัวกระตุ้น (trigger) ของ @defer
html
@defer (on viewport) <!-- เมื่อเลื่อนมาถึง -->
@defer (on idle) <!-- เมื่อ browser ว่าง -->
@defer (on immediate) <!-- ทันทีในรอบ CD ถัดไป -->
@defer (on timer(2s)) <!-- หลังผ่านไป 2 วินาที -->
@defer (on interaction) <!-- เมื่อ hover/คลิก -->
@defer (on hover) <!-- เมื่อ hover -->
@defer (when condition) <!-- เมื่อ expression เป็นจริง -->
@defer (prefetch on hover; on viewport) <!-- โหลดล่วงหน้าตอน hover + วาดตอนอยู่ในจอ -->→ lazy load + ลดขนาด bundle ตอนเปิดครั้งแรก
10. trackBy (Old Syntax)
trackBy คือเวอร์ชันเก่าของ track (ใช้กับ *ngFor directive เก่า จาก Angular 2-16) — เจอในโค้ดเก่า · *ngFor ยังใช้งานได้แต่ @for เป็น built-in control flow ใหม่ (Angular 17+) ที่สั้นกว่าและ Angular แนะนำให้ใช้ในโค้ดใหม่:
html
<!-- *ngFor (รูปแบบเก่า — แนะนำให้ใช้ @for แทน) -->
<div *ngFor="let item of items; trackBy: trackById">{{ item.name }}</div>typescript
// trackById รับ (index, item) เสมอ — Angular ส่งมาให้แบบนี้แม้ไม่ได้ใช้ index
trackById(index: number, item: Item) {
return item.id;
}📖 หมายเหตุ:
*ngForเป็น directive จากCommonModule— ถ้าใช้ standalone component ต้องใส่imports: [CommonModule](หรือNgForตรง ๆ) ในตัว component ด้วย ต่างจาก@forที่เป็น built-in ไม่ต้อง import
→ Modern: @for (item of items; track item.id)
11. Image Optimization
รูปภาพมักเป็นตัวถ่วงความเร็วหน้าเว็บ · ใช้ ngSrc (NgOptimizedImage) แทน src แล้ว Angular จัดการ lazy load, srcset (หลายขนาด), format ใหม่ และ priority hint ให้อัตโนมัติ:
html
<!-- img ปกติ -->
<img src="hero.jpg" alt="...">
<!-- NgOptimizedImage — ทำ srcset/lazy/priority ให้อัตโนมัติ -->
<img ngSrc="hero.jpg" alt="..." width="800" height="600" priority>typescript
import { NgOptimizedImage } from '@angular/common';
@Component({
imports: [NgOptimizedImage]
})→ อัตโนมัติ: lazy load, srcset (= บอก browser ว่ามีรูปหลายขนาด ให้โหลดขนาดที่พอดีกับหน้าจอ ประหยัด bandwidth), format ใหม่, priority hint (บอก browser ว่ารูปไหนสำคัญ)
12. Standalone — Tree Shaking
standalone component + lazy load ช่วยให้ bundle หลักเล็กลง — โหลด feature เฉพาะตอนใช้ และ tree-shaking (= กระบวนการที่ build tool ตรวจและตัดโค้ดส่วนที่ไม่ถูกเรียกใช้ออกจาก bundle — เหมือนสลัดใบแห้งออกจากต้นไม้) ตัด import ที่ไม่ได้ใช้ออก:
typescript
// lazy load ทั้ง feature
{
path: 'reports',
loadComponent: () => import('./reports/reports.component').then(m => m.ReportsComponent)
}→ แยกโค้ด (code split) — bundle หลักเล็กลง, โหลดเมื่อต้องการ
Standalone + lazy import
typescript
// reports.component.ts
import { Chart } from 'chart-library';
@Component({
standalone: true,
imports: [Chart], // ประกาศ import แยกต่อ component
})
export class ReportsComponent { }→ tree-shaking ตัด import ที่ไม่ได้ใช้ออก (ตัดโค้ดส่วนที่ไม่ถูกเรียกใช้จาก bundle)
13. Virtual Scrolling
list ที่ยาวมาก (พันหมื่นแถว) ถ้าวาดทุกแถวพร้อมกันจะอืด · virtual scrolling วาดเฉพาะแถวที่อยู่ในจอ (ที่เหลือสร้างตอนเลื่อนถึง) ทำให้ list หมื่นแถวยังลื่น — ใช้ cdk-virtual-scroll-viewport:
สำหรับ list ยาว (เกิน 100 รายการ):
📖 @angular/cdk (Component Dev Kit) คือ library เสริมของ Angular ที่มี building block สำเร็จรูป เช่น virtual scroll, drag-and-drop, overlay
bash
npm install @angular/cdktypescript
import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
imports: [ScrollingModule],
template: `
<cdk-virtual-scroll-viewport itemSize="50" style="height: 500px;">
<!-- ✅ ต้องใช้ *cdkVirtualFor ไม่ใช่ @for — CDK ต้องควบคุม DOM node ด้วยตัวเอง -->
<div *cdkVirtualFor="let item of items" class="item">{{ item.name }}</div>
</cdk-virtual-scroll-viewport>
`
})→ วาดเฉพาะรายการที่เห็นในจอ — รับมือ list หมื่นแถวได้ลื่น ๆ
14. Memoization Patterns
Memoization = "จำผลที่คำนวณแล้ว ไม่คำนวณซ้ำถ้า input เท่าเดิม" · ใน Angular ทำได้ด้วย computed (signal) และ pure pipe — เลี่ยง impure pipe ที่รันใหม่ทุก CD:
Computed (Built-in)
typescript
total = computed(() =>
this.items().reduce((sum, i) => sum + i.price * i.qty, 0)
);→ จำผลไว้ (memoized) — คำนวณใหม่เฉพาะตอน items เปลี่ยน
Pure Pipe (Default)
typescript
@Pipe({
name: 'formatPrice', // ใช้ชื่ออื่นเพื่อไม่ชนกับ Angular built-in CurrencyPipe จาก @angular/common
standalone: true,
pure: true // default — รันใหม่เฉพาะเมื่อ reference เปลี่ยน
})
export class FormatPricePipe implements PipeTransform {
transform(value: number, code: string): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: code
}).format(value);
}
}→ cache ตามค่า input
เลี่ยง impure pipe
typescript
@Pipe({ pure: false }) // รันใหม่ทุกรอบ CD — ช้า!15. Detect Slow Renders — หาจุดที่ช้า
ก่อน optimize ต้อง "วัด" ก่อนว่าช้าตรงไหน (อย่าเดา) · เครื่องมือหลักคือ Angular DevTools (ส่วนขยาย browser) ที่มี Profiler ดูว่า component ไหนใช้เวลา CD นานสุด:
Angular DevTools
Angular DevTools คือ extension สำหรับ Chrome/Edge — ดาวน์โหลดได้จาก Chrome Web Store (ค้นหา "Angular DevTools") หรือดู angular.dev/tools/devtools
ส่วนขยายของ browser:
- Profiler — ดูความถี่ CD, เวลาต่อรอบ
- ผังต้นไม้ component
- ชี้ component ที่ช้า
text
เปิด DevTools → แท็บ Angular → Profiler → กด Record
ใช้แอป → กด Stop
ดูว่า component ไหนใช้เวลานานสุดperformance.mark
typescript
performance.mark('render-start');
// ... ช่วงที่กำลัง render
performance.mark('render-end');
performance.measure('render', 'render-start', 'render-end');16. Production Build Optimization
ng build --configuration production เปิด optimization ให้อัตโนมัติ (AOT, tree shaking, minify) · ใช้ source-map-explorer/bundle-analyzer ส่องว่าอะไรกินขนาด bundle เยอะ:
bash
ng build --configuration productionoptimization ที่เปิดให้อัตโนมัติ:
- AOT compilation (Ahead of Time = compile ก่อนตอน build ไม่ใช่ตอนรัน)
- tree shaking (ตัดโค้ดที่ไม่ได้ใช้)
- minification (ย่อโค้ดให้เล็ก)
- ตัด dead code (โค้ดที่ไม่มีทางถูกเรียก)
- แยก bundle (bundle splitting)
Bundle Analysis
📖 source map คือไฟล์ที่ map code ที่ถูก minify/compile กลับไปหา code ต้นฉบับ ใช้สำหรับ debug และวิเคราะห์ว่า bundle ของเราประกอบด้วยอะไรบ้าง
bash
ng build --configuration production --source-map
npm install -g source-map-explorer
source-map-explorer dist/my-app/browser/*.js→ ดูว่ามีอะไรใน bundle บ้าง หา dependency ที่กินขนาดเยอะ
Build Stats
bash
ng build --configuration production --stats-json
npm install -g webpack-bundle-analyzer
webpack-bundle-analyzer dist/my-app/stats.json17. Performance Budget
ตั้ง "งบขนาด bundle" ใน angular.json — ถ้า build แล้วเกินกำหนดจะเตือน/fail · ช่วยกันไม่ให้แอปค่อย ๆ อ้วนขึ้นโดยไม่รู้ตัว:
📖 ประเภท budget:
initial= ขนาด bundle หลักที่โหลดตอนเปิดเว็บครั้งแรก ·anyComponentStyle= ขนาดไฟล์ style ของแต่ละ component
json
// angular.json
{
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
]
}
}
}→ build จะ fail ถ้า bundle เกินงบที่ตั้งไว้
18. Web Vitals
Web Vitals คือตัวชี้วัดประสบการณ์ผู้ใช้จริงที่ Google ใช้ (และมีผลต่อ SEO) — 3 ตัวหลัก: LCP (เนื้อหาหลักเห็นเร็วไหม), INP (กดแล้วตอบสนองเร็วไหม), CLS (หน้ากระตุก/เด้งไหม) · เป้าหมาย "Good" อยู่ใต้ตัวอย่าง · วัดของจริงด้วย library web-vitals:
typescript
// app.component.ts or main.ts
// ใช้ web-vitals v4+ — onFID ถูกลบออกแล้วตั้งแต่ v4 เพราะ FID วัดเฉพาะ interaction แรก
// ตรวจสอบเวอร์ชันล่าสุดของ web-vitals ใน npm/เอกสารทางการก่อนใช้ (ไลบรารีนี้อัปเดตเรื่อย ๆ)
// INP ดีกว่าเพราะวัดทุก interaction สะท้อน UX จริงได้ดีกว่า
import { onLCP, onCLS, onINP, onTTFB } from 'web-vitals';
onLCP(console.log); // Largest Contentful Paint — เนื้อหาใหญ่สุดโหลดเสร็จเมื่อไร
onCLS(console.log); // Cumulative Layout Shift — หน้าเด้ง/กระตุกแค่ไหน
onINP(console.log); // Interaction to Next Paint — ตอบสนองการกดเร็วแค่ไหน (แทน FID)
onTTFB(console.log); // Time to First Byte — server ตอบ byte แรกเร็วแค่ไหนส่งไป analytics:
typescript
onLCP((metric) => {
sendToAnalytics(metric);
});Good Targets
text
LCP: < 2.5s
INP: < 200ms
CLS: < 0.1
TTFB: < 800ms19. Common Performance Mistakes
6 ข้อผิดที่ทำให้ Angular ช้าโดยไม่รู้ตัว — ที่เจอบ่อยสุดคือเรียกฟังก์ชันในเทมเพลต (รันทุก CD) และ mutate ข้อมูลกับ OnPush · อ่านไว้กันพลาด:
1. Function in Template
html
<!-- ❌ ถูกเรียกทุกรอบ CD -->
<p>{{ getName() }}</p>
<!-- ✅ -->
<p>{{ name() }}</p>2. แก้ของเดิม (mutation) กับ OnPush
typescript
// ❌ OnPush จะไม่อัปเดต
this.users.push(newUser);
// ✅
this.users = [...this.users, newUser];
// ดีกว่า: this.users.update(u => [...u, newUser]); // signal3. @for ไม่มี track
html
<!-- ❌ -->
@for (item of items; track item) { }
<!-- ✅ -->
@for (item of items; track item.id) { }4. คำนวณหนักในเทมเพลต
html
<!-- ❌ -->
<p>{{ users.filter(u => u.active).length }}</p>
<!-- ✅ -->
<p>{{ activeCount() }}</p>typescript
activeCount = computed(() =>
this.users().filter(u => u.active).length
);5. Large Bundle
typescript
// ❌ import ทั้ง library
import * as _ from 'lodash';
// ✅ import เฉพาะที่ใช้ (tree-shake ได้)
import { debounce } from 'lodash-es'; // lodash-es = เวอร์ชัน ES module ของ lodash ที่ tree-shake ได้ดีกว่า6. Synchronous Heavy Work
typescript
// ❌ ทำให้ UI ค้าง
processHeavy() {
for (let i = 0; i < 1000000; i++) { /* ... */ }
}
// ✅ ใช้ Web Worker
const worker = new Worker(new URL('./heavy.worker', import.meta.url));
worker.postMessage(data);
worker.onmessage = ({ data }) => { /* result */ };20. Animation Performance
animation ลื่นหรือกระตุกขึ้นกับว่าแก้ property ไหน — แก้ width/height/top ทำให้ browser คำนวณ layout ใหม่ (ช้า) · แก้ transform/opacity ใช้ GPU (ลื่น 60fps) เป็นกฎทองของ animation บนเว็บ:
scss
/* ❌ animate width/height — บังคับให้คำนวณ layout ใหม่ (ช้า) */
.box {
transition: width 0.3s;
}
/* ✅ animate transform/opacity — ใช้ GPU (ลื่น) */
.box {
transition: transform 0.3s, opacity 0.3s;
}scss
/* ดันขึ้นเป็น layer แยก (ให้ GPU จัดการ) */
.box {
will-change: transform;
transform: translateZ(0); /* บังคับสร้าง layer */
}21. SSR Performance
SSR (render HTML ฝั่ง server) ทำให้ผู้ใช้เห็นเนื้อหาเร็วขึ้น (ไม่ต้องรอ JS โหลดเสร็จก่อน) + ดีกับ SEO · ลงรายละเอียดในบทที่ 9:
Server-Side Rendering — โหลดครั้งแรกเร็วขึ้น:
bash
ng add @angular/ssr→ render HTML แรกที่ server → First Contentful Paint (เห็นเนื้อหาแรก) เร็วขึ้น
Angular ยุคใหม่ = "hydration" — server ส่ง HTML มา + ฝั่ง client มารับช่วงต่อ
ดูบทที่ 9
22. Lazy Load Module
แบ่งโค้ดเป็นชิ้น ๆ โหลดตอนใช้ (lazy load) ช่วยให้เปิดเว็บครั้งแรกเร็วขึ้น · เสริมด้วย preload strategy เพื่อแอบโหลด route ที่เหลือไว้เบื้องหลังหลัง bundle หลักพร้อม:
typescript
// แบบเก่า (NgModule)
{ path: 'admin', loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule) }
// แบบใหม่ (Standalone)
{ path: 'admin', loadComponent: () => import('./admin/admin.component').then(m => m.AdminComponent) }
{ path: 'admin', loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes) }Preload Strategy
typescript
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
provideRouter(routes, withPreloading(PreloadAllModules))→ แอบโหลด lazy route ไว้เบื้องหลังหลัง bundle หลักพร้อม
23. CDN + Asset Optimization
บอก browser ให้โหลด asset สำคัญก่อน (preload), เชื่อมต่อ server ล่วงหน้า (preconnect/dns-prefetch) — ลดเวลารอโหลดรูป/ฟอนต์/API:
html
<!-- preload: โหลด asset สำคัญล่วงหน้า -->
<link rel="preload" href="/assets/hero.webp" as="image">
<link rel="preload" href="/assets/font.woff2" as="font" type="font/woff2" crossorigin>
<!-- dns-prefetch: หา IP ของ domain ไว้ล่วงหน้า -->
<link rel="dns-prefetch" href="https://api.example.com">
<!-- preconnect: เชื่อมต่อ server ล่วงหน้า -->
<link rel="preconnect" href="https://cdn.example.com">typescript
// ในเทมเพลต Angular
<img ngSrc="hero.webp" alt="..." priority>→ บอก browser ให้จัดลำดับโหลดสิ่งสำคัญก่อน
24. Bundle Size Tips
bundle ยิ่งเล็ก เปิดเว็บยิ่งเร็ว · วิธีลดขนาดที่ได้ผลสุด: standalone, zoneless (ตัด zone.js ~30KB), tree-shake, lazy load, optimize รูป:
text
แอป Angular 19 มาตรฐาน:
- bundle เริ่มต้น: ~140 KB (gzip แล้ว)
- hello world: ~70 KB (gzip แล้ว)
วิธีลดขนาด:
- Standalone (ไม่ใช้ NgModule)
- Zoneless (ไม่มี zone.js — ลดได้ ~30 KB)
- tree-shake ตัดที่ไม่ได้ใช้
- lazy load feature
- optimize รูป (WebP/AVIF)
- ใช้ ngOptimizedImage
- minify JS + CSS (เปิดอัตโนมัติตอน production)25. ตัวอย่างเต็ม — Optimized List
รวมเทคนิคทั้งบทเป็น list จริงที่เร็ว — OnPush + signal (state) + computed (กรองแบบ memoized) + virtual scroll (list ยาว) + @defer (โหลดส่วนเสริมตอนเลื่อนถึง) + ngOptimizedImage · นี่คือหน้าตาของ component ที่ optimize ครบ:
typescript
@Component({
selector: 'app-product-list',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgOptimizedImage, ProductCardComponent, ScrollingModule],
// ScrollingModule จาก @angular/cdk/scrolling สำหรับ cdk-virtual-scroll-viewport
// FormsModule ไม่จำเป็นเพราะใช้ signal-based input แทน [(ngModel)]
template: `
<div class="filter">
<!-- ใช้ signal-based input แทน [(ngModel)] ซึ่งไม่รองรับ signal โดยตรง -->
<input [value]="search()" (input)="search.set($any($event.target).value)" placeholder="Search">
</div>
<div class="stats">
<span>{{ filteredCount() }} results</span>
</div>
<!-- virtual scroll สำหรับ list ยาว — ต้องใช้ *cdkVirtualFor ไม่ใช่ @for -->
<cdk-virtual-scroll-viewport itemSize="120" class="list">
<app-product-card
*cdkVirtualFor="let product of filtered()"
[product]="product"
(selected)="onSelect($event)"
/>
</cdk-virtual-scroll-viewport>
<!-- defer: โหลดส่วนสินค้าที่เกี่ยวข้องตอนเลื่อนถึง -->
@defer (on viewport) {
<app-related-products [productId]="selectedId()" />
} @placeholder {
<div style="height: 200px">Related products</div>
}
`
})
export class ProductListComponent {
// ProductStore = custom service ที่ต้องสร้างเอง (ดูตัวอย่างใน section ก่อนหน้า)
private store = inject(ProductStore);
search = signal('');
selectedId = signal<number | null>(null);
// ตัวกรองแบบ memoized (จำผลไว้)
filtered = computed(() => {
const q = this.search().toLowerCase();
return this.store.products().filter(p =>
p.name.toLowerCase().includes(q)
);
});
filteredCount = computed(() => this.filtered().length);
onSelect(id: number) {
this.selectedId.set(id);
}
}typescript
@Component({
selector: 'app-product-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgOptimizedImage],
template: `
<div class="card" (click)="selected.emit(product().id)">
<img [ngSrc]="product().imageUrl"
[alt]="product().name"
width="200"
height="200"
loading="lazy">
<h3>{{ product().name }}</h3>
<p>{{ formattedPrice() }}</p>
</div>
`
})
export class ProductCardComponent {
product = input.required<Product>();
selected = output<number>();
formattedPrice = computed(() =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD' // ปรับเป็น 'th-TH' + 'THB' สำหรับราคาบาทไทย
}).format(this.product().price)
);
}26. ⚠️ Common Pitfalls
สรุปกับดัก performance ทั้งบทไว้ในตารางเดียว — ซ้าย "สิ่งที่ทำให้ช้า" ขวา "ทางแก้" ใช้เป็น quick reference:
| ❌ ที่ทำให้ช้า | ✅ ทางแก้ |
|---|---|
| ใช้ CD แบบ default ทั้งหมด | OnPush + signal |
| แก้ array/object ของเดิม | สร้างใหม่ (immutable update) |
| เรียกฟังก์ชันในเทมเพลต | ใช้ computed signal |
| ทำงานหนักแบบ synchronous | ใช้ Web Worker |
| import lodash ทั้งก้อน | import เฉพาะที่ใช้ (tree-shake) |
| ไม่ lazy load | lazy load route |
| track ทั้งก้อน item ใน @for | track ด้วย item.id |
| bundle เริ่มต้นหนัก | แยกโค้ด (code split) |
| ยิง HTTP ย่อย ๆ หลายครั้ง | รวม + ยิงทีเดียว (batch) |
| ไม่มี memoization | ใช้ computed + pure pipe |
27. Performance Checklist
เช็กลิสต์ก่อน deploy — ไล่ติ๊กทีละข้อเพื่อให้แอปเร็วครบทุกมิติ (ไม่ต้องทำทุกข้อตั้งแต่แรก แต่ใช้เป็นแนวทางตอนแอปเริ่มช้า):
text
□ component ทุกตัวใช้ OnPush (หรือ Zoneless)
□ ใช้ signal เก็บ state
□ @for มี track item.id
□ lazy load route ของ feature
□ ใช้ ngOptimizedImage กับรูป
□ virtual scroll สำหรับ list ยาว
□ @defer สำหรับ component หนักที่อยู่นอกจอ
□ Web Worker สำหรับงานที่กิน CPU หนัก
□ tree-shake ตัด import ที่ไม่ได้ใช้
□ ตั้ง performance budget ใน angular.json
□ วิเคราะห์ bundle แล้ว (source-map-explorer)
□ ทำ SSR ถ้าสน SEO
□ ติดตาม Web Vitals ใน production
□ ใช้ CDN กับ asset นิ่ง
□ บีบอัด response (gzip/brotli)28. Checkpoint
ลองวัด-แล้ว-optimize จริง 5 ข้อ ไล่จากแปลงเป็น OnPush ไปจนถึงทำ zoneless — เน้นข้อ 7.2 (profile) เพราะ "วัดก่อนแก้" คือหัวใจของงาน performance:
🛠️ Checkpoint 7.1 — แปลง component เป็น OnPush หยิบ component ที่เคยเขียน:
- เปลี่ยนเป็น
changeDetection: ChangeDetectionStrategy.OnPush - ตรวจให้แน่ใจว่าทุก update ใช้ signal หรือ reference ใหม่ (ไม่ mutate)
- ทดสอบว่า UI ยัง update ปกติไหม
🛠️ Checkpoint 7.2 — ใช้ Profiler หา bottleneck
- เปิด Angular DevTools → Profiler tab
- บันทึกการใช้งาน (กด, ค้นหา, scroll)
- หา component ที่ช้าสุด
- Optimize แล้ววัดผลก่อน-หลัง
🛠️ Checkpoint 7.3 — Virtual Scroll สำหรับ list ใหญ่
- สร้าง list 10,000 รายการ
- Render ผ่าน
<cdk-virtual-scroll-viewport> - เทียบกับ
@forปกติ — สังเกตความต่างของความเร็ว
🛠️ Checkpoint 7.4 — Lazy load + Defer
- หา component ที่หนัก (chart, ฟอร์มซับซ้อน)
- Lazy load ผ่าน
loadComponent - ใช้
@defer (on viewport)เพื่อ render ตอนเห็นเท่านั้น - วัดขนาด bundle เริ่มต้นก่อน-หลัง
🛠️ Checkpoint 7.5 — แปลงเป็น Zoneless
- แปลงแอปเล็ก ๆ ให้ใช้
provideZonelessChangeDetection() - ลบ
zone.jsออกจาก polyfills - ใช้ signal กับทุก state
- ทดสอบว่ายังทำงานปกติ + วัดขนาด bundle
29. สรุปบท
ทบทวนแกนของบท — เข้าใจว่า Angular ตรวจการเปลี่ยนแปลงยังไง แล้วทำให้มันตรวจน้อยลง · สูตรสำเร็จยุคใหม่คือ OnPush + signal (หรือ zoneless ไปเลย) แล้วค่อยเสริมด้วย track/lazy/defer/virtual scroll เมื่อจำเป็น · จำไว้: วัดก่อน แล้วค่อย optimize:
✅ Change Detection = Angular update DOM เมื่อ state เปลี่ยน ✅ Default: Zone.js patch ทุก async — สะดวกแต่หนัก ✅ OnPush: CD เมื่อ input ref เปลี่ยน / event / async pipe ✅ Signal: CD แบบเฉพาะจุด (fine-grained) — ดีที่สุด ✅ Zoneless (Angular 18+/stable 20) = ไม่พึ่ง Zone.js, ใช้ signal ล้วน, bundle เล็กลง ✅ Pattern: OnPush + signals + immutable update (ไม่ mutate) ✅ track item.id ใน @for เพื่อ update list อย่างมีประสิทธิภาพ ✅ เลี่ยงเรียกฟังก์ชันใน template — ใช้ computed() แทน ✅ @defer สำหรับ component หนัก/ที่ยังไม่เห็น ✅ NgOptimizedImage สำหรับรูป ✅ Virtual scroll (@angular/cdk/scrolling) สำหรับ list ใหญ่ ✅ Lazy load route ผ่าน loadComponent / loadChildren ✅ วิเคราะห์ bundle + ตั้ง performance budget ใน angular.json ✅ ติดตาม Web Vitals (LCP, INP, CLS) ใน production