前言

在上一篇文章中,我們介紹了 Angular Signal 的基礎概念。這篇文章將深入探討兩個重要的 Signal 功能:ComputedEffect。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 有內建的效能優化機制:

  1. 惰性求值(Lazy Evaluation):只有在被讀取時才會計算
  2. 快取(Memoization):如果依賴沒有變更,會返回快取的值
  3. 自動優化:只有在依賴改變時才重新計算
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 EffectSignal 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 適合用於:

  1. 日誌記錄:追蹤狀態變化
  2. 同步到外部系統:localStorage、sessionStorage
  3. 發送分析事件:Google Analytics
  4. DOM 操作:需要直接操作 DOM 時
  5. 訂閱外部資料源: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 會在以下時機執行:

  1. 首次執行:Effect 創建時立即執行一次
  2. 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 比較

特性ComputedEffect
目的計算衍生值執行副作用
返回值有返回值(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易於追蹤和維護
多個相關的 EffectSignal 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 SignalEffect 是 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();
});

最佳實踐總結

  1. ✅ 優先使用 Computed 而非 Effect 來計算衍生值
  2. ✅ 優先使用 Signal Effect 而非 Constructor Effect
  3. ✅ 為 Signal Effect 取有意義的名稱
  4. ✅ Effect 只用於副作用,不要更新其他 Signal
  5. ✅ 使用清理函數釋放資源
  6. ✅ 使用條件判斷避免無限循環

掌握這兩個功能以及它們的最佳用法,可以讓你的 Angular 應用更加響應式、高效且易於維護!

參考資料