@Input setter vs effect:解決循環依賴問題

在 Angular 應用中,父子組件間的數據傳遞是常見需求,但如果處理不當,容易造成循環依賴問題。

本篇筆記記錄了使用 Signal API 和傳統 @Input 時解決循環依賴的最佳實踐。

問題情境

在我們的投票系統專案中,遇到了一個典型的循環依賴問題:

  1. 父組件從 API 獲取數據,傳給子組件

  2. 子組件處理數據,更新 UI 顯示(包括計算百分比)

  3. 用戶在子組件中選擇選項,子組件通知父組件

  4. 父組件更新數據,再傳回子組件

  5. 子組件處理更新的數據,但導致 UI 顯示錯誤(百分比被重置)

問題分析:不正確的實現(使用 effect)

// 子組件代碼
@Component({
  selector: 'app-option',
  //...
})
export class OptionComponent {
  // 輸入
  options = input.required<Option[]>();
  voteRecords = input.required<VoteRecord[]>();
  initialSelectedOptionId = input.required<string | null>();
  
  // 本地狀態
  selectedOptionId = signal<string | null>(null);
  localVoteRecords = signal<VoteRecord[]>([]);
  
  // 問題源頭:使用 effect 處理輸入變化
  constructor() {
    effect(() => {
      const records = this.voteRecords();
      const initialId = this.initialSelectedOptionId();
      
      if (records) {
        this.localVoteRecords.set(records);
      }
      
      if (initialId !== null) {
        this.selectedOptionId.set(initialId);
      }
    }, { allowSignalWrites: true });
  }
  
  // 計算百分比
  totalVotes = computed(() => {
    return this.localVoteRecords().length;
  });
  
  private percentagesSignal = computed(() => {
    // 百分比計算邏輯
    // ...
  });
}

這種實現方式的問題是:effect 會在每次 Signal 值變化時執行,即使實際值沒有發生變化

循環依賴的具體流程

  1. 初始化階段

    • 父組件傳入初始 initialSelectedOptionId

    • 子組件的 effect 被觸發

    • 子組件設置 selectedOptionId 並計算初始百分比

  2. 使用者交互階段

    • 使用者選擇選項 1(ID: 8cb146e1-ed48-483f-bb9f...)

    • 子組件更新 selectedOptionId

    • 子組件重新計算百分比(顯示正確值:14.285%)

    • 子組件通知父組件選項已變更

  3. 父組件更新階段

    • 父組件接收通知,更新 userSelectedOptionId

    • 父組件將相同的 ID 再次傳回子組件作為 initialSelectedOptionId

  4. 子組件再次更新階段

    • 子組件的 effect 再次被觸發(即使收到的是相同的 ID)

    • 子組件重新設置 selectedOptionId

    • 百分比計算再次執行,但結果錯誤(0% 而非 14.285%)

    • UI 顯示不正確的百分比

解決方案:使用 @Input setter

使用 @Input setter 可以精確控制何時更新本地狀態,有效阻止循環依賴:

@Component({
  selector: 'app-option',
  //...
})
export class OptionComponent {
  // 私有 signal 存儲實際數據
  private _localVoteRecords = signal<VoteRecord[]>([]);
  private _selectedOptionId = signal<string | null>(null);
  
  // 公開唯讀 signal 供模板使用
  localVoteRecords = this._localVoteRecords.asReadonly();
  selectedOptionId = this._selectedOptionId.asReadonly();
  
  // 使用 @Input setter 處理輸入屬性
  @Input()
  set voteRecords(records: VoteRecord[]) {
    if (records) {
      this._localVoteRecords.set([...records]);
    }
  }
  
  @Input()
  set initialSelectedOptionId(id: string | null) {
    // 關鍵:只在值真正不同時才更新,避免循環
    if (id !== null && id !== this._selectedOptionId()) {
      this._selectedOptionId.set(id);
    }
  }
  
  // 計算邏輯保持不變
  totalVotes = computed(() => {
    return this._localVoteRecords().length;
  });
  
  // ...
}

@Input setter vs effect 優勢對比

特性
@Input setter
effect

條件性處理

✅ 可根據條件判斷是否更新

❌ 每次變化都會執行

循環依賴風險

✅ 低,可通過條件判斷防止

❌ 高,易造成無限循環

自動追蹤依賴

❌ 需手動指定對應關係

✅ 自動追蹤所有使用的 Signal

代碼可讀性

✅ 直觀明了

❌ 依賴關係不夠明確

與 Angular 標準契合

✅ 遵循 Angular 資料流模型

⚠️ 較新的 API,使用模式尚在發展

@Input setter 如何阻止循環依賴

@Input setter 通過以下機制阻止循環依賴:

  1. 門衛機制:setter 中的條件判斷只允許真正需要的更新通過

  2. 精確控制:只在值真正變化時執行更新邏輯

  3. 單向數據流:維持清晰的父到子的數據流向

例如,在上面的實現中,當父組件將用戶選擇的相同 ID 傳回子組件時:

@Input()
set initialSelectedOptionId(id: string | null) {
  // 如果值相同,這個條件不會通過,因此不會執行更新
  // 這有效地打破了循環
  if (id !== null && id !== this._selectedOptionId()) {
    this._selectedOptionId.set(id);
  }
}

條件判斷確保只有當 ID 真正變化時,才會執行 _selectedOptionId.set(id),從而避免了不必要的重新計算。

結論與最佳實踐

  1. 優先使用 @Input setter:在處理可能導致循環依賴的輸入屬性時

  2. 添加條件判斷:在 setter 中加入條件,只在值真正變化時更新

  3. 區分私有狀態與公開 API:使用私有變數存儲實際狀態,公開唯讀 API

  4. 保持單向數據流:避免子組件直接修改從父組件接收的數據

遵循這些最佳實踐,可以構建出更加健壯、可維護的 Angular 應用,特別是在使用 Signal API 等新特性時。

Last updated