# Angular 18: Signals และฟีเจอร์ใหม่
> เรียนรู้ Angular 18 Signals, การตรวจจับการเปลี่ยนแปลงแบบ zoneless และ API ใหม่ที่ใช้ signal สำหรับสร้างแอปพลิเคชันที่มีประสิทธิภาพสูงขึ้น
- Published: 2026-01-18
- Updated: 2026-04-10
- Author: SharpSkill
- Tags: angular 18, angular signals, zoneless, signal inputs, reactivity
- Reading time: 12 min
---
Angular 18 ถือเป็นจุดเปลี่ยนสำคัญในวิวัฒนาการของ framework นี้ ด้วยการทำให้ Signals เสถียร reactive primitive ใหม่นี้เปลี่ยนแปลงวิธีการสร้าง component ใน Angular อย่างพื้นฐาน โดยนำเสนอทางเลือกที่ทันสมัยแทน decorator แบบดั้งเดิม พร้อมทั้งปูทางสู่การตรวจจับการเปลี่ยนแปลงโดยไม่ต้องใช้ Zone.js
> **สิ่งที่จะได้เรียนรู้**
>
> API ที่ใช้ signal ของ Angular 18: input(), model(), viewChild() และการตั้งค่า zoneless เพื่อสร้างแอปพลิเคชันที่เบาลงและมีประสิทธิภาพสูงขึ้น
## ทำความเข้าใจ Signals ใน Angular 18
Signals เป็นตัวแทนของแนวทางใหม่สำหรับ reactivity ใน Angular ต่างจาก decorator แบบคลาสสิกอย่าง `@Input()` ที่พึ่งพาการตรวจจับการเปลี่ยนแปลงของ Zone.js นั้น Signals มอบ reactivity ที่แม่นยำและชัดเจน Signal แต่ละตัวห่อหุ้มค่าไว้และแจ้งเตือน consumer โดยอัตโนมัติเมื่อค่านั้นเปลี่ยนแปลง
แนวทางนี้มีข้อดีหลายประการ: ประสิทธิภาพที่ดีขึ้นจากการอัปเดตแบบเจาะจง, การทำงานร่วมกับฟังก์ชัน `computed()` และ `effect()` อย่างเป็นธรรมชาติ, และการเตรียมพร้อมสำหรับอนาคตแบบ zoneless ของ Angular
```typescript
// signals-basics.component.ts
// สาธิตแนวคิดพื้นฐานของ Signals
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
Counter: {{ count() }}
Double: {{ doubleCount() }}
`
})
export class CounterComponent {
// Signal ที่เขียนได้ - สามารถเปลี่ยนค่าได้
count = signal(0);
// Signal computed - คำนวณจาก count โดยอัตโนมัติ
// คำนวณใหม่เฉพาะเมื่อ count เปลี่ยนแปลง
doubleCount = computed(() => this.count() * 2);
constructor() {
// Effect - ทำงานทุกครั้งที่ count เปลี่ยนแปลง
// เหมาะสำหรับ side effects (log, เรียก API เป็นต้น)
effect(() => {
console.log(`ค่า counter ใหม่: ${this.count()}`);
});
}
increment() {
// update() อนุญาตให้เปลี่ยนแปลงจากค่าก่อนหน้า
this.count.update(value => value + 1);
}
decrement() {
this.count.update(value => value - 1);
}
reset() {
// set() แทนที่ค่าโดยตรง
this.count.set(0);
}
}
```
Signals ทำงานเหมือน container ที่มี reactivity: `signal()` สร้าง Signal ที่เขียนได้, `computed()` คำนวณค่าที่สืบทอดมา, และ `effect()` อนุญาตให้ทำงานเพื่อตอบสนองต่อการเปลี่ยนแปลง
## Signal Inputs ด้วย input()
ฟังก์ชัน `input()` ใช้แทน decorator `@Input()` แบบดั้งเดิม ฟังก์ชันนี้คืนค่า `InputSignal` แบบอ่านอย่างเดียว รับประกันว่าข้อมูลไหลจาก parent ไป child เสมอโดยไม่ถูกแก้ไขโดยไม่ตั้งใจ
```typescript
// book-card.component.ts
// Component ที่ใช้ signal inputs
import { Component, input, computed } from '@angular/core';
interface Book {
id: string;
title: string;
author: string;
price: number;
discountPercent?: number;
}
@Component({
selector: 'app-book-card',
standalone: true,
template: `
{{ book().title }}
By {{ book().author }}
@if (hasDiscount()) {
\${{ book().price }}
\${{ discountedPrice() }}
} @else {
\${{ book().price }}
}
@if (featured()) {
Featured
}
`
})
export class BookCardComponent {
// Input ที่จำเป็น - template ไม่สามารถ compile ได้หากไม่มี prop นี้
book = input.required();
// Input ที่เลือกได้พร้อมค่าเริ่มต้น
featured = input(false);
// Computed จาก input - คำนวณใหม่โดยอัตโนมัติ
hasDiscount = computed(() => {
const discount = this.book().discountPercent;
return discount !== undefined && discount > 0;
});
// คำนวณราคาส่วนลด
discountedPrice = computed(() => {
const { price, discountPercent } = this.book();
if (!discountPercent) return price;
return (price * (100 - discountPercent) / 100).toFixed(2);
});
}
```
การใช้งานใน template ของ parent ยังคงคล้ายคลึง แต่มีความปลอดภัยด้านชนิดข้อมูลและ reactivity ของ Signal:
```typescript
// book-list.component.ts
// Component parent ที่ใช้ book-card
import { Component, signal } from '@angular/core';
import { BookCardComponent } from './book-card.component';
@Component({
selector: 'app-book-list',
standalone: true,
imports: [BookCardComponent],
template: `
@for (book of books(); track book.id) {
}
`
})
export class BookListComponent {
books = signal([
{ id: '1', title: 'Clean Code', author: 'Robert C. Martin', price: 35 },
{ id: '2', title: 'The Pragmatic Programmer', author: 'David Thomas', price: 42, discountPercent: 15 }
]);
featuredBookId = signal('1');
}
```
ความแตกต่างหลักจาก `@Input()`: signal inputs เป็นแบบอ่านอย่างเดียว การเรียก `this.book.set()` จาก component child ทำไม่ได้ ซึ่งเสริมความแข็งแกร่งของการไหลของข้อมูลแบบทิศทางเดียว
## Two-Way Binding ด้วย model()
สำหรับกรณีที่ต้องการการซิงค์แบบสองทิศทาง Angular 18 นำเสนอ `model()` ฟังก์ชันนี้สร้าง Signal ที่เขียนได้และส่งต่อการเปลี่ยนแปลงไปยัง component parent โดยอัตโนมัติ
```typescript
// search-input.component.ts
// Component ที่มี binding สองทิศทางผ่าน model()
import { Component, model, output, computed } from '@angular/core';
@Component({
selector: 'app-search-input',
standalone: true,
template: `
@if (query().length > 0) {
}
{{ charCount() }} characters
`
})
export class SearchInputComponent {
// model() สร้าง Signal สองทิศทาง
// การเปลี่ยนแปลงจะส่งต่อไป parent
query = model('');
// Input แบบคลาสสิกสำหรับการตั้งค่า
placeholder = model('Search...');
// Output สำหรับ event เพิ่มเติม
searchSubmitted = output();
// Computed จาก model
charCount = computed(() => this.query().length);
onInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
// อัปเดต model - ส่งต่อไป parent
this.query.set(value);
}
clear() {
this.query.set('');
}
submit() {
if (this.query().length > 0) {
this.searchSubmitted.emit(this.query());
}
}
}
```
Parent ใช้ไวยากรณ์ banana-in-a-box `[()]` สำหรับ binding สองทิศทาง:
```typescript
// app.component.ts
// ใช้ two-way binding กับ model()
import { Component, signal, effect } from '@angular/core';
import { SearchInputComponent } from './search-input.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [SearchInputComponent],
template: `
Current search: {{ searchTerm() }}
@for (result of filteredResults(); track result.id) {
{{ result.name }}
}
`
})
export class AppComponent {
// Signal ที่ซิงค์กับ component child
searchTerm = signal('');
results = signal([
{ id: 1, name: 'Angular 18' },
{ id: 2, name: 'React 19' },
{ id: 3, name: 'Vue 3' }
]);
// กรองแบบ reactive จาก searchTerm
filteredResults = computed(() => {
const term = this.searchTerm().toLowerCase();
if (!term) return this.results();
return this.results().filter(r =>
r.name.toLowerCase().includes(term)
);
});
}
```
> **model() vs input()**
>
> ใช้ `input()` สำหรับข้อมูลแบบอ่านอย่างเดียว (parent ไป child) ใช้ `model()` เมื่อ component child ต้องการเปลี่ยนค่า (สองทิศทาง)
## Signal Queries ด้วย viewChild() และ contentChild()
ฟังก์ชัน `viewChild()`, `viewChildren()`, `contentChild()` และ `contentChildren()` ใช้แทน decorator ที่สอดคล้องกัน คืนค่าเป็น Signals ซึ่งไม่ต้องใช้ lifecycle hooks อย่าง `ngAfterViewInit` อีกต่อไป
```typescript
// form-container.component.ts
// สาธิต signal queries
import {
Component,
viewChild,
viewChildren,
ElementRef,
effect,
signal
} from '@angular/core';
import { FormFieldComponent } from './form-field.component';
@Component({
selector: 'app-form-container',
standalone: true,
imports: [FormFieldComponent],
template: `
`
})
export class FormContainerComponent {
// viewChild คืนค่า Signal
formElement = viewChild('formElement');
// viewChild.required รับประกันว่า element มีอยู่
firstInput = viewChild.required>('firstInput');
// Query บน component - คืนค่าตัว component เอง
firstFormField = viewChild(FormFieldComponent);
// viewChildren สำหรับหลาย element
allFormFields = viewChildren(FormFieldComponent);
constructor() {
// Effect ใช้แทน ngAfterViewInit สำหรับ queries
effect(() => {
// Signal ถูก resolve โดยอัตโนมัติ
const input = this.firstInput();
console.log('First input available:', input.nativeElement);
});
// ตอบสนองต่อการเปลี่ยนแปลงของรายการ
effect(() => {
const fields = this.allFormFields();
console.log(`${fields.length} form fields found`);
});
}
focusFirst() {
// เข้าถึงโดยตรงผ่าน Signal
this.firstInput().nativeElement.focus();
}
onSubmit(event: Event) {
event.preventDefault();
// เข้าถึงฟอร์ม
const form = this.formElement();
if (form) {
console.log('Form submitted');
}
}
}
```
สำหรับการฉายเนื้อหาและเข้าถึงเนื้อหานั้น `contentChild()` ทำงานในลักษณะเดียวกัน:
```typescript
// card.component.ts
// ใช้ contentChild สำหรับเนื้อหาที่ฉายเข้ามา
import { Component, contentChild, contentChildren, TemplateRef } from '@angular/core';
@Component({
selector: 'app-card',
standalone: true,
template: `
`
})
export class CardComponent {
// ตรวจสอบว่ามี footer ถูกฉายเข้ามาหรือไม่
footerContent = contentChild('[card-footer]');
// Computed เพื่อตรวจสอบการมีอยู่ของ footer
hasFooter = computed(() => this.footerContent() !== undefined);
}
```
## การตรวจจับการเปลี่ยนแปลงแบบ Zoneless
Angular 18 นำเสนอการตรวจจับการเปลี่ยนแปลงโดยไม่ต้องใช้ Zone.js ในโหมดทดลอง ฟีเจอร์นี้ลดขนาด bundle ลงประมาณ 13 KB และเพิ่มประสิทธิภาพโดยการกำจัด monkey-patch บน API แบบอะซิงโครนัสของเบราว์เซอร์
```typescript
// main.ts
// ตั้งค่าแอปพลิเคชันในโหมด zoneless
import { bootstrapApplication } from '@angular/platform-browser';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [
// เปิดใช้งานการตรวจจับ zoneless แบบทดลอง
provideExperimentalZonelessChangeDetection()
]
});
```
การตั้งค่าใน `angular.json` ต้องอัปเดตเพื่อเอา Zone.js ออก:
```json
{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"polyfills": []
}
}
}
}
}
}
```
ในโหมด zoneless การตรวจจับการเปลี่ยนแปลงจะทำงานอัตโนมัติในกรณีเหล่านี้: อัปเดต Signal, เรียก `markForCheck()`, รับค่าใหม่ผ่าน AsyncPipe, หรือการ attach/detach component
```typescript
// zoneless-counter.component.ts
// Component ที่ปรับแต่งสำหรับโหมด zoneless
import {
Component,
signal,
ChangeDetectionStrategy,
inject
} from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-zoneless-counter',
standalone: true,
// แนะนำให้ใช้ OnPush สำหรับ zoneless
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
Counter: {{ count() }}
@if (loading()) {
Loading...
}
@if (data()) {
{{ data() | json }}
}
`
})
export class ZonelessCounterComponent {
private http = inject(HttpClient);
count = signal(0);
loading = signal(false);
data = signal(null);
increment() {
// การอัปเดต Signal ทำให้เกิดการตรวจจับ
this.count.update(c => c + 1);
}
async fetchData() {
this.loading.set(true);
try {
// Signals รับประกันการอัปเดตหน้าจอ
const response = await fetch('/api/data');
const json = await response.json();
this.data.set(json);
} finally {
this.loading.set(false);
}
}
}
```
> **ความเข้ากันได้กับ Zoneless**
>
> Component ที่ใช้ `ChangeDetectionStrategy.OnPush` และ Signals โดยทั่วไปเข้ากันได้กับโหมด zoneless หลีกเลี่ยงการเปลี่ยนแปลงคุณสมบัติโดยตรงที่ไม่ใช่ Signal
## การย้าย component ที่มีอยู่
การย้ายไปยัง API ที่ใช้ signal สามารถทำได้ทีละน้อย ตัวอย่างต่อไปนี้แสดงการ refactoring component แบบดั้งเดิม:
```typescript
// ก่อน: Component ที่ใช้ decorator แบบคลาสสิก
// user-profile-legacy.component.ts
import { Component, Input, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
@Component({
selector: 'app-user-profile-legacy',
template: `
{{ user.name }}
{{ user.email }}
`
})
export class UserProfileLegacyComponent implements AfterViewInit {
@Input() user!: { name: string; email: string };
@ViewChild('container') container!: ElementRef;
ngAfterViewInit() {
console.log('Container ready:', this.container.nativeElement);
}
}
```
```typescript
// หลัง: Component ที่ย้ายไป Signals แล้ว
// user-profile.component.ts
import { Component, input, viewChild, ElementRef, effect } from '@angular/core';
interface User {
name: string;
email: string;
}
@Component({
selector: 'app-user-profile',
standalone: true,
template: `
{{ user().name }}
{{ user().email }}
`
})
export class UserProfileComponent {
// input.required ใช้แทน @Input() ที่มี !
user = input.required();
// viewChild.required ใช้แทน @ViewChild ที่มี !
container = viewChild.required('container');
constructor() {
// effect ใช้แทน ngAfterViewInit สำหรับ queries
effect(() => {
console.log('Container ready:', this.container().nativeElement);
});
}
}
```
ประโยชน์ของการย้ายนี้: typing ที่เข้มงวดขึ้น, reactivity อัตโนมัติ, โค้ด boilerplate น้อยลง, และเข้ากันได้กับโหมด zoneless
## แนวปฏิบัติที่ดีกับ Signals
คำแนะนำหลักเพื่อใช้งาน Signals ใน Angular 18 ได้อย่างเต็มประสิทธิภาพ:
```typescript
// best-practices.component.ts
// ตัวอย่างแนวปฏิบัติที่ดีกับ Signals
import {
Component,
signal,
computed,
effect,
untracked,
ChangeDetectionStrategy
} from '@angular/core';
interface Product {
id: string;
name: string;
price: number;
quantity: number;
}
@Component({
selector: 'app-cart',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
Cart ({{ itemCount() }} items)
@for (item of items(); track item.id) {
{{ item.name }}
{{ item.quantity }} × \${{ item.price }}
}
Total: \${{ total() }}
`
})
export class CartComponent {
// Signal สำหรับข้อมูลที่เปลี่ยนแปลงได้
items = signal([]);
// Computed สำหรับค่าที่สืบทอดมา - หลีกเลี่ยงการคำนวณซ้ำ
itemCount = computed(() => this.items().length);
total = computed(() =>
this.items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);
constructor() {
// Effect สำหรับ side effects (analytics, การเก็บข้อมูล)
effect(() => {
const currentItems = this.items();
// untracked หลีกเลี่ยงการสร้าง dependency
untracked(() => {
localStorage.setItem('cart', JSON.stringify(currentItems));
});
});
}
addItem(product: Product) {
// update() สำหรับการเปลี่ยนแปลงจากสถานะก่อนหน้า
this.items.update(current => {
const existing = current.find(i => i.id === product.id);
if (existing) {
return current.map(i =>
i.id === product.id
? { ...i, quantity: i.quantity + 1 }
: i
);
}
return [...current, { ...product, quantity: 1 }];
});
}
removeItem(id: string) {
this.items.update(current => current.filter(i => i.id !== id));
}
}
```
ประเด็นสำคัญที่ควรจดจำ:
- ใช้ `computed()` สำหรับค่าที่สืบทอดมาแทนการคำนวณซ้ำใน template
- เลือกใช้ `update()` แทน `set()` เมื่อค่าใหม่ขึ้นกับค่าเดิม
- ใช้ `untracked()` ใน effects เพื่อหลีกเลี่ยง dependency แบบวน
- ระบุ `track` ในลูป `@for` เสมอเพื่อปรับแต่งการ rendering ให้เหมาะสม
## สรุป
Angular 18 วางรากฐานสำหรับอนาคตที่ไม่ต้องใช้ Zone.js ผ่าน Signals ประเด็นสำคัญ:
- **input()** ใช้แทน `@Input()` พร้อม typing ที่เข้มงวดขึ้นและการเข้าถึงแบบอ่านอย่างเดียวที่รับประกัน
- **model()** เปิดใช้งาน two-way binding แบบ reactive ระหว่าง parent และ child
- **viewChild()** และ **contentChild()** ไม่ต้องใช้ lifecycle hooks อีกต่อไป
- **Zoneless** ลดขนาด bundle และเพิ่มประสิทธิภาพ
- **computed()** และ **effect()** ทำให้ระบบนิเวศ reactive สมบูรณ์
- ย้ายทีละน้อยได้ทีละ component
การนำ Signals มาใช้เตรียมแอปพลิเคชัน Angular สำหรับเวอร์ชันในอนาคตที่โหมด zoneless จะกลายเป็นมาตรฐาน การเปลี่ยนแปลงนี้เป็นการลงทุนที่คุ้มค่าสำหรับการดูแลรักษาและประสิทธิภาพในระยะยาว
---
Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack.
HTML version of this page: https://sharpskill.dev/th/blog/angular/angular-18-signals-new-features