子組件輸入屬性與 Signal 狀態管理問題解決
問題描述
當父組件從 API 獲取資料並透過 @Input
將資料傳遞給子組件時,子組件無法正確接收並處理這些資料。具體表現為:
在父組件的
ngOnInit
生命週期鉤子中呼叫 APIAPI 回傳資料後,將資料處理並傳給子組件
子組件在其
ngOnInit
中嘗試存取這些資料結果子組件無法獲取資料,因為此時父組件的 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 的錯誤,但我們發現這種方法導致了循環依賴問題:
子組件更新選擇的選項
子組件發出事件給父組件
父組件更新選項值並傳回子組件
子組件的 effect 再次觸發更新
這導致計算百分比等派生值被多次計算,最終一些派生值(如百分比)不正確
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);
}
}
原始方法與新方法比較
代碼複雜度
較高,需要處理依賴關係和循環問題
較低,直接明了
循環依賴風險
高,容易導致無限循環
低,可在 setter 中添加條件控制
資料流向
不明確,雙向流動
明確的單向數據流
變更檢測
可能過度觸發
只在需要時觸發
可維護性
較差,難以理解和調試
較好,符合 Angular 標準模式
與 Angular 理念契合度
中等
高,遵循 Angular 的數據流模型
最終選擇與原因
我們最終選擇了方案3(使用 @Input setter)作為解決方案,原因如下:
單向數據流:符合 Angular 的設計理念
避免循環依賴:不會導致無限循環問題
代碼簡潔清晰:易於理解和維護
性能更好:避免不必要的重新計算


教訓與最佳實踐
避免在 ngOnInit 中依賴 @Input 值:由於生命週期的特性,不能保證 @Input 值在 ngOnInit 時可用。
Signal 使用的一致性:
在模板中始終使用
value()
函數形式在代碼中始終使用
.set()
方法更新值確保所有依賴于 Signal 的計算都正確引用 Signal 值
避免循環依賴:
子組件應該避免直接將父組件傳入的資料作為自己的輸出
使用條件判斷或 untracked 避免不必要的依賴
優先使用標準 Angular 模式:@Input setter 是處理輸入屬性變化的成熟模式,比 effect 更適合這種場景。
Signal 與傳統 @Input 的結合使用:
// 私有 signal 存儲實際數據 private _data = signal<Data | null>(null); // 公開唯讀 signal data = this._data.asReadonly(); // 處理輸入 @Input() set inputData(value: Data) { this._data.set(value); }
參考資料
Angular 官方文檔:輸入屬性
Angular Signal 文檔:Signal API
Angular 生命週期鉤子:Lifecycle Hooks
Last updated