Skip to content

บทที่ 7 — Change Detection + Performance

← บทที่ 6 | สารบัญ | บทที่ 8 →

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

  • เข้าใจ 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 เฉพาะเมื่อ":

  1. input() / @Input เปลี่ยน reference (เป็น object ใหม่ ไม่ใช่แก้ของเดิม) — signal input ใช้ได้ทันที

    📖 reference คืออะไร? reference = "ที่อยู่" ของ object ในหน่วยความจำ — ถ้าทำ obj.name = 'new' ที่อยู่เดิม (reference เท่าเดิม) แต่ถ้าทำ obj = {...obj, name: 'new'} จะสร้าง object ใหม่ที่อยู่ใหม่ (reference เปลี่ยน) → OnPush เห็น

  2. มี event เกิดใน component (click ฯลฯ)

  3. Observable (สตรีมข้อมูลแบบเป็นชุด ดูบทที่ 6) ส่งค่าผ่าน async pipe (| async ในเทมเพลต — ถ้ายังไม่ได้อ่านบทที่ 6 รู้แค่ว่า async pipe คือ syntax ใน HTML สำหรับฟัง Observable อัตโนมัติ)

  4. สั่งเอง: 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 ไม่ครอบ) ใช้ ChangeDetectorRefmarkForCheck() (สั่งตรวจรอบหน้า) ที่ใช้บ่อยสุด · ปัจจุบัน 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 คือ library ที่ทำให้การใช้ Web Worker ง่ายขึ้น — แทนที่จะส่ง postMessage ไปมาเอง Comlink ให้เรียก function ใน Worker เหมือน async function ปกติ:

bash
npm install comlink
typescript
// 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/cdk
typescript
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 production

optimization ที่เปิดให้อัตโนมัติ:

  • 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.json

17. 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: < 800ms

19. 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]);  // signal

3. @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 loadlazy load route
track ทั้งก้อน item ใน @fortrack ด้วย 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


← บทที่ 6 | บทที่ 8 → Testing