子組件輸入屬性與 Signal 狀態管理問題解決

問題描述

當父組件從 API 獲取資料並透過 @Input 將資料傳遞給子組件時,子組件無法正確接收並處理這些資料。具體表現為:

  1. 在父組件的 ngOnInit 生命週期鉤子中呼叫 API

  2. API 回傳資料後,將資料處理並傳給子組件

  3. 子組件在其 ngOnInit 中嘗試存取這些資料

  4. 結果子組件無法獲取資料,因為此時父組件的 API 尚未回傳

這個問題涉及到 Angular 生命週期、非同步操作、父子組件通訊、以及 Signal API 的使用。

排查過程

1. 初步診斷:時序問題

首先確認了這是一個典型的時序問題:

Angular is running in development mode.
父組件的 init start
子組件的 voteRecords undefined
子組件的 localVoteRecords []
子組件的 initialId null
子組件的 selectedOptionId null
父組件的 userSelectedOptionId null
父組件的 voteRecords (7) [{…}, {…}, {…}, {…}, {…}, {…}, {…}]
父組件的 userSelectedOptionId 賦值後 484634dc-384b-41e8-92ad-8ad81816a218
父組件的 init end
子組件的 voteRecords (7) [{…}, {…}, {…}, {…}, {…}, {…}, {…}]
子組件的 localVoteRecords (7) [{…}, {…}, {…}, {…}, {…}, {…}, {…}]
子組件的 initialId 484634dc-384b-41e8-92ad-8ad81816a218
子組件的 selectedOptionId 484634dc-384b-41e8-92ad-8ad81816a218

從日誌可以看出,子組件的 ngOnInit 先於父組件的 API 響應執行,導致子組件初始化時拿不到數據。

2. 嘗試使用 effect 解決問題

我們首先嘗試使用 Angular 的 effect() 函數來解決這個問題:

// 初始化
private dataEffect = effect(() => {
  // 投票紀錄
  console.log('子組件的 voteRecords', this.voteRecords());

  const records = this.voteRecords();
  if (records) {
    // this.localVoteRecords = [...records];
    this.localVoteRecords.set([...records]);
  }
  console.log('子組件的 localVoteRecords', this.localVoteRecords);

  // 使用者選中的選項 ID
  const initialId = this.initialSelectedOptionId();
  console.log('子組件的 initialId', initialId);
  if (initialId !== '') {
    this.selectedOptionId = initialId;
    console.log('子組件的 selectedOptionId', this.selectedOptionId);
  }
});

這個方法確實監聽到了輸入變化,但遇到了新的錯誤:

ERROR RuntimeError: NG0600: Writing to signals is not allowed in a computed or an effect by default. Use allowSignalWrites in the CreateEffectOptions to enable this inside effects.

3. 嘗試在 effect 中添加 allowSignalWrites 選項

接著我們嘗試添加 allowSignalWrites 選項:

private dataEffect = effect(() => {
  const records = this.voteRecords();
  const initialId = this.initialSelectedOptionId();

  if (records) {
    this.updateLocalVoteRecords(records);
  }

  if (initialId !== null) {
    this.updateSelectedOptionId(initialId);
  }
}, { allowSignalWrites: true });

雖然解決了寫入 Signal 的錯誤,但我們發現這種方法導致了循環依賴問題:

  1. 子組件更新選擇的選項

  2. 子組件發出事件給父組件

  3. 父組件更新選項值並傳回子組件

  4. 子組件的 effect 再次觸發更新

  5. 這導致計算百分比等派生值被多次計算,最終一些派生值(如百分比)不正確

4. 嘗試更改為普通變數而非 Signal

嘗試將狀態改為普通變數:

// 暫存狀態
selectedOptionId: string | null = null;
localVoteRecords: VoteRecord[] = [];

但這導致另一個問題:當父層的值變更時,UI 不會自動更新。普通變數不會觸發 Angular 的變更檢測。所以改回 Signal 並找尋別的解決方法。

5. 解決循環依賴問題

最後,我們確認了問題的根本原因是循環依賴:子組件更新 → 通知父組件 → 父組件更新輸入 → 子組件的 effect 再次觸發 → 循環開始。

第一次進入頁面
選擇其他選項

解決方案

經過多種嘗試,我們確定了三種可能的解決方案:

方案1:在 effect 中使用條件判斷

effect(() => {
  const currentName = this.name();
  const initialId = this.initialSelectedOptionId();

  // 只有當目前選中ID不同於初始ID時才更新
  if (initialId !== null && initialId !== this.selectedOptionId()) {
    this.updateSelectedOptionId(initialId);
  }
}, { allowSignalWrites: true });

方案2:使用 untracked 避免創建某些依賴

effect(() => {
  const currentSelectedId = untracked(() => this.selectedOptionId());
  const initialId = this.initialSelectedOptionId();

  // 只有當值不同時才更新
  if (initialId !== null && initialId !== currentSelectedId) {
    this.updateSelectedOptionId(initialId);
  }
}, { allowSignalWrites: true });

方案3 (推薦):使用 @Input setter 替代 effect

// 私有的 signal 以存儲狀態
private _localVoteRecords = signal<VoteRecord[]>([]);
private _selectedOptionId = signal<string | null>(null);

// 公開的唯讀 signal 供模板使用
localVoteRecords = this._localVoteRecords.asReadonly();
selectedOptionId = this._selectedOptionId.asReadonly();

// 使用 @Input() set 處理輸入屬性
@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);
  }
}

原始方法與新方法比較

特性
原始方法 (使用 effect)
新方法 (使用 @Input setter)

代碼複雜度

較高,需要處理依賴關係和循環問題

較低,直接明了

循環依賴風險

高,容易導致無限循環

低,可在 setter 中添加條件控制

資料流向

不明確,雙向流動

明確的單向數據流

變更檢測

可能過度觸發

只在需要時觸發

可維護性

較差,難以理解和調試

較好,符合 Angular 標準模式

與 Angular 理念契合度

中等

高,遵循 Angular 的數據流模型

最終選擇與原因

我們最終選擇了方案3(使用 @Input setter)作為解決方案,原因如下:

  1. 單向數據流:符合 Angular 的設計理念

  2. 避免循環依賴:不會導致無限循環問題

  3. 代碼簡潔清晰:易於理解和維護

  4. 性能更好:避免不必要的重新計算

第一次進入頁面
選擇其他選項

教訓與最佳實踐

  1. 避免在 ngOnInit 中依賴 @Input 值:由於生命週期的特性,不能保證 @Input 值在 ngOnInit 時可用。

  2. Signal 使用的一致性

    • 在模板中始終使用 value() 函數形式

    • 在代碼中始終使用 .set() 方法更新值

    • 確保所有依賴于 Signal 的計算都正確引用 Signal 值

  3. 避免循環依賴

    • 子組件應該避免直接將父組件傳入的資料作為自己的輸出

    • 使用條件判斷或 untracked 避免不必要的依賴

  4. 優先使用標準 Angular 模式:@Input setter 是處理輸入屬性變化的成熟模式,比 effect 更適合這種場景。

  5. Signal 與傳統 @Input 的結合使用

    // 私有 signal 存儲實際數據
    private _data = signal<Data | null>(null);
    
    // 公開唯讀 signal
    data = this._data.asReadonly();
    
    // 處理輸入
    @Input()
    set inputData(value: Data) {
      this._data.set(value);
    }

參考資料

  1. Angular 官方文檔:輸入屬性

  2. Angular Signal 文檔:Signal API

  3. Angular 生命週期鉤子:Lifecycle Hooks

Last updated