前言
在上一篇文章中,我們介紹了 Angular Signal 的基礎概念。這篇文章將深入探討兩個重要的 Signal 功能:Computed 和 Effect。Computed 讓我們能夠基於其他 Signal 計算衍生值,而 Effect 則讓我們能夠在 Signal 變更時執行副作用。
Computed Signal 深入介紹
computed() 是一個唯讀的 Signal,它的值是根據其他 Signal 自動計算出來的。當依賴的 Signal 變更時,Computed Signal 會自動重新計算。
基本用法
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-price-calculator',
template: `
<div>
<h2>Price Calculator</h2>
<p>Price: ${{ price() }}</p>
<p>Quantity: {{ quantity() }}</p>
<p>Subtotal: ${{ subtotal() }}</p>
<p>Tax (10%): ${{ tax() }}</p>
<p><strong>Total: ${{ total() }}</strong></p>
<button (click)="increaseQuantity()">Add Item</button>
<button (click)="decreaseQuantity()">Remove Item</button>
</div>
`
})
export class PriceCalculatorComponent {
price = signal(100);
quantity = signal(1);
// Computed: 自動計算小計
subtotal = computed(() => this.price() * this.quantity());
// Computed: 基於其他 computed 計算稅金
tax = computed(() => this.subtotal() * 0.1);
// Computed: 計算總金額
total = computed(() => this.subtotal() + this.tax());
increaseQuantity(): void {
this.quantity.update(q => q + 1);
}
decreaseQuantity(): void {
this.quantity.update(q => Math.max(0, q - 1));
}
}
依賴追蹤
Computed Signal 會自動追蹤它依賴的所有 Signal。當任何一個依賴改變時,Computed 會重新計算。
export class DependencyTrackingComponent {
firstName = signal('John');
lastName = signal('Doe');
age = signal(25);
// 只依賴 firstName 和 lastName
fullName = computed(() =>
`${this.firstName()} ${this.lastName()}`
);
// 條件式依賴:根據條件決定依賴哪些 Signal
displayInfo = computed(() => {
const name = this.fullName();
if (this.age() >= 18) {
return `${name} (Adult)`;
}
return `${name} (Minor)`;
});
}
Computed 的效能優化
Computed Signal 有內建的效能優化機制:
- 惰性求值(Lazy Evaluation):只有在被讀取時才會計算
- 快取(Memoization):如果依賴沒有變更,會返回快取的值
- 自動優化:只有在依賴改變時才重新計算
export class PerformanceComponent {
items = signal([1, 2, 3, 4, 5]);
// 這個 computed 只會在 items 改變時重新計算
expensiveCalculation = computed(() => {
console.log('Computing...');
return this.items().reduce((sum, item) => {
// 模擬複雜計算
let result = item;
for (let i = 0; i < 1000000; i++) {
result += Math.random();
}
return sum + result;
}, 0);
});
// 即使多次讀取,也只會計算一次(直到依賴改變)
getResult(): number {
const result1 = this.expensiveCalculation();
const result2 = this.expensiveCalculation();
const result3 = this.expensiveCalculation();
return result3; // 只有第一次會真的執行計算
}
}
Computed 實用範例:過濾與排序
interface Todo {
id: number;
title: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
}
@Component({
selector: 'app-todo-list',
template: `
<div>
<h2>Todo List</h2>
<div>
<label>
<input type="radio"
[checked]="filter() === 'all'"
(change)="filter.set('all')">
All ({{ allTodos().length }})
</label>
<label>
<input type="radio"
[checked]="filter() === 'active'"
(change)="filter.set('active')">
Active ({{ activeTodos().length }})
</label>
<label>
<input type="radio"
[checked]="filter() === 'completed'"
(change)="filter.set('completed')">
Completed ({{ completedTodos().length }})
</label>
</div>
<div>
<button (click)="sortBy.set('title')">Sort by Title</button>
<button (click)="sortBy.set('priority')">Sort by Priority</button>
</div>
<ul>
<li *ngFor="let todo of displayedTodos()">
{{ todo.title }} - {{ todo.priority }}
<input type="checkbox"
[checked]="todo.completed"
(change)="toggleTodo(todo.id)">
</li>
</ul>
</div>
`
})
export class TodoListComponent {
todos = signal<Todo[]>([
{ id: 1, title: 'Learn Angular', completed: false, priority: 'high' },
{ id: 2, title: 'Learn Signals', completed: true, priority: 'high' },
{ id: 3, title: 'Build App', completed: false, priority: 'medium' },
{ id: 4, title: 'Write Tests', completed: false, priority: 'low' }
]);
filter = signal<'all' | 'active' | 'completed'>('all');
sortBy = signal<'title' | 'priority'>('title');
// Computed: 所有 todos
allTodos = computed(() => this.todos());
// Computed: 未完成的 todos
activeTodos = computed(() =>
this.todos().filter(todo => !todo.completed)
);
// Computed: 已完成的 todos
completedTodos = computed(() =>
this.todos().filter(todo => todo.completed)
);
// Computed: 根據 filter 顯示對應的 todos
filteredTodos = computed(() => {
const filterValue = this.filter();
switch (filterValue) {
case 'active':
return this.activeTodos();
case 'completed':
return this.completedTodos();
default:
return this.allTodos();
}
});
// Computed: 排序後的 todos
displayedTodos = computed(() => {
const todos = [...this.filteredTodos()];
const sortKey = this.sortBy();
return todos.sort((a, b) => {
if (sortKey === 'title') {
return a.title.localeCompare(b.title);
} else {
const priorityOrder = { high: 3, medium: 2, low: 1 };
return priorityOrder[b.priority] - priorityOrder[a.priority];
}
});
});
toggleTodo(id: number): void {
this.todos.update(todos =>
todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}
}
Effect 完整說明
effect() 讓你在 Signal 變更時執行副作用(side effects),例如記錄日誌、發送 HTTP 請求、更新 localStorage 等。Angular 提供了兩種使用 Effect 的方式。
什麼是 Effect?
Effect 是一個函數,當它內部讀取的任何 Signal 改變時,它會自動重新執行。
Effect 的兩種使用方式
方式 1:在 Constructor 中使用 effect()
這是最常見的用法,直接在 constructor 中調用 effect() 函數。
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<div>
<p>Count: {{ count() }}</p>
<button (click)="increment()">Increment</button>
</div>
`
})
export class CounterComponent {
count = signal(0);
constructor() {
// 方式 1:在 constructor 中使用 effect()
effect(() => {
console.log(`Count changed to: ${this.count()}`);
});
}
increment(): void {
this.count.update(c => c + 1);
}
}
方式 2:使用 Signal Effect (類別欄位)
從 Angular 17 開始,可以直接將 Effect 定義為類別欄位,不需要放在 constructor 中。
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-counter-v2',
template: `
<div>
<p>Count: {{ count() }}</p>
<button (click)="increment()">Increment</button>
</div>
`
})
export class CounterV2Component {
count = signal(0);
// 方式 2:直接定義為類別欄位
logEffect = effect(() => {
console.log(`Count changed to: ${this.count()}`);
});
increment(): void {
this.count.update(c => c + 1);
}
}
兩種 Effect 方式的比較
| 特性 | Constructor Effect | Signal Effect (欄位) |
|---|---|---|
| 定義位置 | constructor 內部 | 類別欄位 |
| 語法 | effect(() => {}) | 變數名 = effect(() => {}) |
| 可讀性 | 一般 | 較好(有命名) |
| 可控制性 | 無法直接控制 | 可以手動銷毀 |
| 執行時機 | 元件初始化時 | 元件初始化時 |
| 適用場景 | 簡單的副作用 | 需要管理的副作用 |
| Angular 版本 | 16+ | 17+ |
使用 Signal Effect 的優勢
import { Component, signal, effect, EffectRef } from '@angular/core';
@Component({
selector: 'app-advanced-counter',
template: `
<div>
<p>Count: {{ count() }}</p>
<p>Double: {{ doubleCount() }}</p>
<button (click)="increment()">Increment</button>
<button (click)="toggleLogging()">
{{ loggingEnabled() ? 'Disable' : 'Enable' }} Logging
</button>
</div>
`
})
export class AdvancedCounterComponent {
count = signal(0);
doubleCount = signal(0);
loggingEnabled = signal(true);
// Signal Effect:可以取得 EffectRef 進行控制
countLogger = effect(() => {
if (this.loggingEnabled()) {
console.log(`Count: ${this.count()}`);
}
});
// 多個 Effect 可以清晰命名
doubleLogger = effect(() => {
if (this.loggingEnabled()) {
console.log(`Double: ${this.doubleCount()}`);
}
});
// 同步 Effect:計算 double count
syncDouble = effect(() => {
this.doubleCount.set(this.count() * 2);
});
increment(): void {
this.count.update(c => c + 1);
}
toggleLogging(): void {
this.loggingEnabled.update(v => !v);
}
ngOnDestroy(): void {
// 可以手動銷毀特定的 Effect
// this.countLogger.destroy();
}
}
Effect 命名最佳實踐
使用 Signal Effect 時,建議使用描述性的命名:
export class DataSyncComponent {
user = signal<User | null>(null);
settings = signal<Settings>({});
// ✅ 好:清楚的命名
syncUserToLocalStorage = effect(() => {
const user = this.user();
if (user) {
localStorage.setItem('user', JSON.stringify(user));
}
});
syncSettingsToBackend = effect(() => {
const settings = this.settings();
this.http.post('/api/settings', settings).subscribe();
});
logUserActivity = effect(() => {
const user = this.user();
if (user) {
console.log(`User ${user.name} is active`);
}
});
}
何時使用 Effect?
Effect 適合用於:
- 日誌記錄:追蹤狀態變化
- 同步到外部系統:localStorage、sessionStorage
- 發送分析事件:Google Analytics
- DOM 操作:需要直接操作 DOM 時
- 訂閱外部資料源:WebSocket、Server-Sent Events
不適合用於:
- 更新其他 Signal(應該使用 Computed)
- 模板渲染(模板會自動追蹤)
Constructor Effect vs Signal Effect 實例比較
import { Component, signal, effect } from '@angular/core';
// 使用 Constructor Effect
@Component({
selector: 'app-user-preferences',
template: `
<div>
<label>
Theme:
<select [value]="theme()" (change)="theme.set($any($event.target).value)">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
<label>
Language:
<select [value]="language()" (change)="language.set($any($event.target).value)">
<option value="en">English</option>
<option value="zh">中文</option>
</select>
</label>
</div>
`
})
export class UserPreferencesComponent {
theme = signal<'light' | 'dark'>('light');
language = signal<'en' | 'zh'>('en');
constructor() {
// 從 localStorage 載入設定
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark';
const savedLanguage = localStorage.getItem('language') as 'en' | 'zh';
if (savedTheme) this.theme.set(savedTheme);
if (savedLanguage) this.language.set(savedLanguage);
// Constructor Effect: 同步 theme 到 localStorage
effect(() => {
localStorage.setItem('theme', this.theme());
console.log(`Theme saved: ${this.theme()}`);
});
// Constructor Effect: 同步 language 到 localStorage
effect(() => {
localStorage.setItem('language', this.language());
console.log(`Language saved: ${this.language()}`);
});
// Constructor Effect: 應用主題到 document
effect(() => {
document.body.className = this.theme();
});
}
}
// 使用 Signal Effect(推薦)
@Component({
selector: 'app-user-preferences-v2',
template: `
<div>
<label>
Theme:
<select [value]="theme()" (change)="theme.set($any($event.target).value)">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
<label>
Language:
<select [value]="language()" (change)="language.set($any($event.target).value)">
<option value="en">English</option>
<option value="zh">中文</option>
</select>
</label>
</div>
`
})
export class UserPreferencesV2Component {
theme = signal<'light' | 'dark'>('light');
language = signal<'en' | 'zh'>('en');
// Signal Effect: 清晰的命名,容易理解每個 Effect 的用途
syncThemeToStorage = effect(() => {
localStorage.setItem('theme', this.theme());
console.log(`Theme saved: ${this.theme()}`);
});
syncLanguageToStorage = effect(() => {
localStorage.setItem('language', this.language());
console.log(`Language saved: ${this.language()}`);
});
applyThemeToDocument = effect(() => {
document.body.className = this.theme();
});
constructor() {
// 從 localStorage 載入設定
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark';
const savedLanguage = localStorage.getItem('language') as 'en' | 'zh';
if (savedTheme) this.theme.set(savedTheme);
if (savedLanguage) this.language.set(savedLanguage);
}
}
Effect 的執行時機
Effect 會在以下時機執行:
- 首次執行:Effect 創建時立即執行一次
- Signal 變更:當 Effect 內讀取的 Signal 改變時
export class EffectTimingComponent {
count = signal(0);
name = signal('John');
// Signal Effect:命名清晰
logChanges = effect(() => {
console.log('Effect executed');
console.log(`Count: ${this.count()}`);
console.log(`Name: ${this.name()}`);
});
constructor() {
console.log('Constructor start');
// Effect 會在這裡執行
console.log('Constructor end');
// 輸出順序:
// Constructor start
// Effect executed
// Count: 0
// Name: John
// Constructor end
}
updateValues(): void {
this.count.set(1); // 觸發 Effect
this.name.set('Jane'); // 再次觸發 Effect
}
}
條件式 Effect
Signal Effect 讓條件式執行更清晰:
export class ConditionalEffectComponent {
count = signal(0);
enableLogging = signal(true);
enableAnalytics = signal(false);
// 只在 enableLogging 為 true 時記錄
loggingEffect = effect(() => {
if (this.enableLogging()) {
console.log(`Count: ${this.count()}`);
}
});
// 只在 enableAnalytics 為 true 時發送
analyticsEffect = effect(() => {
if (this.enableAnalytics()) {
// 發送到分析服務
analytics.track('count_changed', { count: this.count() });
}
});
// 當 count 超過特定值時觸發
alertEffect = effect(() => {
const count = this.count();
if (count > 10) {
alert(`Count is now ${count}!`);
}
});
}
### 清理函數(Cleanup)
Effect 可以返回一個清理函數,在 Effect 重新執行或元件銷毀時調用。兩種 Effect 方式都支援清理函數。
```typescript
import { Component, signal, effect } from '@angular/core';
// Constructor Effect 版本
@Component({
selector: 'app-timer',
template: `
<div>
<p>Time: {{ time() }}s</p>
<p>Running: {{ isRunning() ? 'Yes' : 'No' }}</p>
<button (click)="toggle()">{{ isRunning() ? 'Stop' : 'Start' }}</button>
<button (click)="reset()">Reset</button>
</div>
`
})
export class TimerComponent {
time = signal(0);
isRunning = signal(false);
constructor() {
effect((onCleanup) => {
if (this.isRunning()) {
console.log('Starting timer...');
const interval = setInterval(() => {
this.time.update(t => t + 1);
}, 1000);
// 清理函數:當 isRunning 變成 false 或元件銷毀時執行
onCleanup(() => {
console.log('Stopping timer...');
clearInterval(interval);
});
}
});
}
toggle(): void {
this.isRunning.update(r => !r);
}
reset(): void {
this.time.set(0);
}
}
// Signal Effect 版本(推薦)
@Component({
selector: 'app-timer-v2',
template: `
<div>
<p>Time: {{ time() }}s</p>
<p>Running: {{ isRunning() ? 'Yes' : 'No' }}</p>
<button (click)="toggle()">{{ isRunning() ? 'Stop' : 'Start' }}</button>
<button (click)="reset()">Reset</button>
</div>
`
})
export class TimerV2Component {
time = signal(0);
isRunning = signal(false);
// Signal Effect: 清晰命名,易於理解
timerEffect = effect((onCleanup) => {
if (this.isRunning()) {
console.log('Starting timer...');
const interval = setInterval(() => {
this.time.update(t => t + 1);
}, 1000);
// 清理函數:當 isRunning 變成 false 或元件銷毀時執行
onCleanup(() => {
console.log('Stopping timer...');
clearInterval(interval);
});
}
});
toggle(): void {
this.isRunning.update(r => !r);
}
reset(): void {
this.time.set(0);
}
}
複雜的清理場景
export class WebSocketComponent {
isConnected = signal(false);
messages = signal<string[]>([]);
connectionUrl = signal('ws://localhost:8080');
// Signal Effect: WebSocket 連接管理
websocketEffect = effect((onCleanup) => {
const url = this.connectionUrl();
if (this.isConnected()) {
console.log(`Connecting to ${url}...`);
const ws = new WebSocket(url);
ws.onopen = () => {
console.log('Connected!');
};
ws.onmessage = (event) => {
this.messages.update(msgs => [...msgs, event.data]);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
// 清理函數:關閉連接
onCleanup(() => {
console.log('Closing WebSocket connection...');
ws.close();
});
}
});
connect(): void {
this.isConnected.set(true);
}
disconnect(): void {
this.isConnected.set(false);
}
changeUrl(newUrl: string): void {
this.connectionUrl.set(newUrl);
// Effect 會自動執行清理並重新建立連接
}
}
Effect 進階範例:表單自動儲存
interface FormData {
title: string;
content: string;
tags: string[];
}
@Component({
selector: 'app-auto-save-form',
template: `
<div>
<h2>Auto-Save Form</h2>
<p>Last saved: {{ lastSaved() || 'Never' }}</p>
<div>
<label>
Title:
<input [value]="title()"
(input)="title.set($any($event.target).value)">
</label>
</div>
<div>
<label>
Content:
<textarea [value]="content()"
(input)="content.set($any($event.target).value)">
</textarea>
</label>
</div>
<div>
<label>
Tags (comma separated):
<input [value]="tagsString()"
(input)="updateTags($any($event.target).value)">
</label>
</div>
<p *ngIf="isSaving()">Saving...</p>
</div>
`
})
export class AutoSaveFormComponent {
title = signal('');
content = signal('');
tags = signal<string[]>([]);
lastSaved = signal<string>('');
isSaving = signal(false);
// Computed: 將 tags 陣列轉為字串顯示
tagsString = computed(() => this.tags().join(', '));
// Computed: 整合表單資料
formData = computed<FormData>(() => ({
title: this.title(),
content: this.content(),
tags: this.tags()
}));
constructor(private http: HttpClient) {
// 從 localStorage 載入草稿
this.loadDraft();
// Effect: 自動儲存到 localStorage(防抖)
effect((onCleanup) => {
const data = this.formData();
// 設定延遲儲存(模擬防抖)
const timeout = setTimeout(() => {
this.saveDraft(data);
}, 1000);
onCleanup(() => clearTimeout(timeout));
});
// Effect: 監控表單變更
effect(() => {
const data = this.formData();
console.log('Form data changed:', data);
// 可以在這裡發送分析事件
// analytics.track('form_edited', data);
});
}
private loadDraft(): void {
const draft = localStorage.getItem('formDraft');
if (draft) {
const data: FormData = JSON.parse(draft);
this.title.set(data.title);
this.content.set(data.content);
this.tags.set(data.tags);
}
}
private saveDraft(data: FormData): void {
localStorage.setItem('formDraft', JSON.stringify(data));
this.lastSaved.set(new Date().toLocaleTimeString());
console.log('Draft saved to localStorage');
}
updateTags(value: string): void {
const tagsArray = value.split(',').map(tag => tag.trim()).filter(tag => tag);
this.tags.set(tagsArray);
}
async submitForm(): Promise<void> {
this.isSaving.set(true);
try {
await this.http.post('/api/save', this.formData()).toPromise();
localStorage.removeItem('formDraft');
this.lastSaved.set(new Date().toLocaleTimeString());
alert('Form saved successfully!');
} catch (error) {
console.error('Save failed:', error);
alert('Save failed. Draft saved locally.');
} finally {
this.isSaving.set(false);
}
}
}
Effect 與 RxJS 整合
Signal Effect 讓 RxJS 整合更加清晰:
import { Component, signal, effect } from '@angular/core';
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs';
// Constructor Effect 版本
@Component({
selector: 'app-search',
template: `
<div>
<input [value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
placeholder="Search...">
<div *ngIf="isLoading()">Loading...</div>
<ul>
<li *ngFor="let result of results()">{{ result }}</li>
</ul>
</div>
`
})
export class SearchComponent {
searchTerm = signal('');
results = signal<string[]>([]);
isLoading = signal(false);
private searchSubject = new Subject<string>();
constructor(private http: HttpClient) {
// 設定 RxJS 搜尋管道
this.searchSubject.pipe(
debounceTime(300),
distinctUntilChanged()
).subscribe(term => {
this.performSearch(term);
});
// Constructor Effect: 將 Signal 變更推送到 RxJS Subject
effect(() => {
const term = this.searchTerm();
this.searchSubject.next(term);
});
}
private async performSearch(term: string): Promise<void> {
if (!term) {
this.results.set([]);
return;
}
this.isLoading.set(true);
try {
const response = await this.http
.get<string[]>(`/api/search?q=${term}`)
.toPromise();
this.results.set(response || []);
} catch (error) {
console.error('Search failed:', error);
this.results.set([]);
} finally {
this.isLoading.set(false);
}
}
ngOnDestroy(): void {
this.searchSubject.complete();
}
}
// Signal Effect 版本(推薦)
@Component({
selector: 'app-search-v2',
template: `
<div>
<input [value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
placeholder="Search...">
<div *ngIf="isLoading()">Loading...</div>
<ul>
<li *ngFor="let result of results()">{{ result }}</li>
</ul>
</div>
`
})
export class SearchV2Component {
searchTerm = signal('');
results = signal<string[]>([]);
isLoading = signal(false);
private searchSubject = new Subject<string>();
// Signal Effect: 清晰命名,易於理解其作用
syncSearchTermToRxJS = effect(() => {
const term = this.searchTerm();
this.searchSubject.next(term);
});
constructor(private http: HttpClient) {
// 設定 RxJS 搜尋管道
this.searchSubject.pipe(
debounceTime(300),
distinctUntilChanged()
).subscribe(term => {
this.performSearch(term);
});
}
private async performSearch(term: string): Promise<void> {
if (!term) {
this.results.set([]);
return;
}
this.isLoading.set(true);
try {
const response = await this.http
.get<string[]>(`/api/search?q=${term}`)
.toPromise();
this.results.set(response || []);
} catch (error) {
console.error('Search failed:', error);
this.results.set([]);
} finally {
this.isLoading.set(false);
}
}
ngOnDestroy(): void {
this.searchSubject.complete();
}
}
Computed vs Effect 比較
| 特性 | Computed | Effect |
|---|---|---|
| 目的 | 計算衍生值 | 執行副作用 |
| 返回值 | 有返回值(Signal) | 無返回值 |
| 執行時機 | 惰性求值(被讀取時) | 立即執行 |
| 適用場景 | 資料轉換、過濾、計算 | 日誌、同步、DOM 操作 |
| 可以更新其他 Signal | ❌ 不可以 | ✅ 可以(但不推薦) |
| 可以執行異步操作 | ❌ 不可以 | ✅ 可以 |
| 快取 | ✅ 自動快取 | ❌ 無快取 |
最佳實踐
1. 優先使用 Computed
// ✅ 好:使用 Computed
fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
// ❌ 壞:使用 Effect 更新其他 Signal
constructor() {
effect(() => {
this.fullName.set(`${this.firstName()} ${this.lastName()}`);
});
}
2. Effect 只用於副作用
// ✅ 好:用於副作用(Signal Effect 版本)
logCountChanges = effect(() => {
console.log('Count:', this.count());
localStorage.setItem('count', this.count().toString());
});
// ❌ 壞:用於計算值(應該用 computed)
updateDoubleCount = effect(() => {
this.doubleCount.set(this.count() * 2); // 錯誤!應該用 computed
});
// ✅ 正確:使用 computed
doubleCount = computed(() => this.count() * 2);
3. 優先使用 Signal Effect(類別欄位)
// ✅ 好:Signal Effect - 清晰命名,易於維護
export class UserComponent {
user = signal<User | null>(null);
syncToLocalStorage = effect(() => {
const user = this.user();
if (user) {
localStorage.setItem('user', JSON.stringify(user));
}
});
logUserChanges = effect(() => {
console.log('User changed:', this.user());
});
}
// ❌ 較差:Constructor Effect - 難以追蹤和管理
export class UserComponent {
user = signal<User | null>(null);
constructor() {
effect(() => {
const user = this.user();
if (user) {
localStorage.setItem('user', JSON.stringify(user));
}
});
effect(() => {
console.log('User changed:', this.user());
});
}
}
4. 使用清理函數
// ✅ 好:正確清理資源(Signal Effect)
subscribeToData = effect((onCleanup) => {
const subscription = this.dataService.getData().subscribe(data => {
this.data.set(data);
});
onCleanup(() => subscription.unsubscribe());
});
// ✅ 好:清理多個資源
setupConnections = effect((onCleanup) => {
const ws = new WebSocket('ws://localhost:8080');
const interval = setInterval(() => console.log('ping'), 1000);
onCleanup(() => {
ws.close();
clearInterval(interval);
});
});
5. 避免無限循環
// ❌ 危險:可能造成無限循環
dangerousEffect = effect(() => {
this.count.set(this.count() + 1); // 每次執行都會觸發自己
});
// ✅ 好:使用條件判斷
conditionalEffect = effect(() => {
if (this.count() < 10) {
// 有條件的更新,不會無限循環
this.processCount();
}
});
// ✅ 好:只讀取 Signal,不修改
logEffect = effect(() => {
console.log('Count is:', this.count());
});
6. 選擇正確的 Effect 方式
| 場景 | 推薦方式 | 原因 |
|---|---|---|
| 簡單的日誌記錄 | Signal Effect | 命名清晰 |
| 需要手動控制的副作用 | Signal Effect | 可以取得 EffectRef |
| 一次性的設置 | Constructor Effect | 簡單直接 |
| 複雜的副作用管理 | Signal Effect | 易於追蹤和維護 |
| 多個相關的 Effect | Signal Effect | 命名讓關係更清楚 |
// ✅ 推薦:多個 Signal Effect,清晰的職責劃分
export class DashboardComponent {
user = signal<User | null>(null);
theme = signal<'light' | 'dark'>('light');
notifications = signal<Notification[]>([]);
// 清晰命名:每個 Effect 的作用一目了然
syncUserToStorage = effect(() => {
const user = this.user();
if (user) {
localStorage.setItem('user', JSON.stringify(user));
}
});
applyTheme = effect(() => {
document.body.className = this.theme();
});
notifyUser = effect(() => {
const notifications = this.notifications();
if (notifications.length > 0) {
this.showNotificationToast(notifications[0]);
}
});
trackAnalytics = effect(() => {
analytics.track('user_active', {
userId: this.user()?.id,
theme: this.theme()
});
});
}
總結
Computed Signal 和 Effect 是 Angular Signal 中兩個強大的功能:
Computed Signal
- 用途:計算衍生值
- 特點:惰性求值、自動快取、唯讀
- 適合:資料轉換、過濾、計算
- 優勢:效能優化、自動依賴追蹤
Effect 兩種使用方式
Constructor Effect
- 定義:在 constructor 中調用
effect() - 優點:語法簡潔、適合簡單場景
- 缺點:難以追蹤、無法命名、不易管理
- 適用:一次性設置、簡單的副作用
Signal Effect(推薦)
- 定義:作為類別欄位定義
- 優點:清晰命名、易於管理、可手動控制
- 缺點:稍微多寫一點程式碼
- 適用:複雜副作用、需要追蹤的邏輯、可維護性要求高的專案
選擇建議
// 簡單場景:可以用 Constructor Effect
constructor() {
effect(() => console.log(this.count()));
}
// 複雜場景:建議用 Signal Effect
syncToBackend = effect(() => {
this.http.post('/api/sync', this.data()).subscribe();
});
最佳實踐總結
- ✅ 優先使用 Computed 而非 Effect 來計算衍生值
- ✅ 優先使用 Signal Effect 而非 Constructor Effect
- ✅ 為 Signal Effect 取有意義的名稱
- ✅ Effect 只用於副作用,不要更新其他 Signal
- ✅ 使用清理函數釋放資源
- ✅ 使用條件判斷避免無限循環
掌握這兩個功能以及它們的最佳用法,可以讓你的 Angular 應用更加響應式、高效且易於維護!