元件中的多次渲染問題排查過程
本篇記錄了一個實際案例:投票系統中,選項的投票百分比和背景顏色無法正確更新的排查過程。
問題描述
在一個投票系統中,用戶選擇一個選項後,該選項的投票百分比應該更新,同時背景顏色也應該根據百分比調整。但實際情況是,點擊選項後雖然選項被正確選中(邊框顯示選中狀態),但百分比數值和背景顏色沒有更新。
原始程式碼結構:
<!-- option.component.html -->
<mat-radio-button
class="radio-option"
[value]="option.id"
(click)="onSelect(option)"
>
<div class="option-content">
<span class="option-text">{{ option.text }}</span>
<span class="option-percentage">{{ displayPercentage(option) }}%</span>
</div>
</mat-radio-button>
// option.component.ts
@Component({
selector: 'app-option',
standalone: true,
imports: [MatRadioModule, UserAvatarComponent, FormsModule],
templateUrl: './option.component.html',
styleUrl: './option.component.scss',
})
export class OptionComponent {
// ...其他代碼...
// 處理選項選擇
onSelect(selectedOption: Option) {
this.selectedOptionId.set(selectedOption.id);
// 更新投票記錄...
this.localVoteRecords.set(currentRecords);
// 發出事件通知父組件
this.optionSelected.emit({
optionId: selectedOption.id,
voteRecords: currentRecords,
});
}
// 計算選項的投票百分比
calculatePercentage(option: Option): number {
if (!this.totalVotes() || this.totalVotes() === 0) {
return 0;
}
const optionVotes = this.localVoteRecords().filter(
(record) => record.option_id === option.id
).length;
return (optionVotes / this.totalVotes()) * 100;
}
// 顯示格式化的百分比
displayPercentage(option: Option): string {
const percentage = this.calculatePercentage(option);
return percentage.toFixed(1);
}
}
/* option.component.scss */
.radio-option::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: calc(var(--percentage) * 1%);
height: 100%;
background-color: rgba(97, 28, 194, 0.1);
z-index: 0;
pointer-events: none;
}
排查過程
1. 初步檢查:確認 (click) 事件觸發情況
首先發現,如果在兩個地方都放上 click 事件,可以阻止一個 bug:當用戶在點擊選項時不小心把文字反白,有時只有選項樣式會更新(邊框),但百分比和頭像不會更新。

原本以為只是單純的文字被反白導致的數據與樣式脫鉤問題,但即便讓文字無法被反白,這個問題也偶發性地出現。
嘗試在組件和按鈕上都添加了 click 事件,確實解決這個問題:
<div class="option-wrapper" (click)="onSelect(option)">
<mat-radio-button
class="radio-option"
[value]="option.id"
(click)="onSelect(option)"
>
<!-- 內容... -->
</mat-radio-button>
</div>
但這又導致了一個新問題:每次點擊會觸發兩次 console.log(this.userSelectedOptionId)
,這讓我們感到困惑。
2. 嘗試使用 MatRadioGroup 的 change 事件
為了解決多次觸發的問題,我們嘗試使用 mat-radio-group
的 change
事件代替 click
事件:
(當時的想法是 mat-radio-group
自帶的事件處理與我定義的 click
事件重複觸發了。)
<mat-radio-group [value]="selectedOptionId()" (change)="onRadioChange($event)">
@for (option of options(); track option.id) {
<div class="option-wrapper" [class.selected]="option.id === selectedOptionId()">
<mat-radio-button [value]="option.id">
<!-- 內容... -->
</mat-radio-button>
</div>
}
</mat-radio-group>
onRadioChange(event: MatRadioChange) {
const optionId = event.value;
const selectedOption = this.options().find((opt) => opt.id === optionId);
if (selectedOption) {
// 設置選中的選項 ID
this.selectedOptionId.set(optionId);
// 更新投票記錄
// ...
// 發出事件通知父組件
this.optionSelected.emit({
optionId: optionId,
voteRecords: currentRecords,
});
}
}
這樣做減少了事件觸發次數,但仍然無法解決背景和百分比不更新的問題。
3. 添加詳細日誌追蹤數據流
為了找出問題的根源,我們在代碼中添加了更詳細的日誌,追蹤數據的變化:
displayPercentage(option: Option): string {
const percentage = this.calculatePercentage(option);
console.log('觸發了格式化百分比', option.sequence, percentage.toFixed(1));
return percentage.toFixed(1);
}
onRadioChange(event: MatRadioChange) {
console.log('===== Radio 變更事件開始 =====');
// 現有代碼...
console.log('===== Radio 變更事件結束 =====');
// 輸出每個選項的狀態
console.log('--- 選項投票情況 ---');
this.options().forEach((option) => {
const percentage = this.calculatePercentage(option);
const voters = this.getOptionVoters(option);
console.log(
`選項: ${option.text} | ` +
`ID: ${option.id} | ` +
`百分比: ${percentage.toFixed(1)}% | ` +
`投票人數: ${voters.length} | ` +
`投票者: ${voters.map((v) => v.user.username).join(', ')}`
);
});
}
4. 發現多次計算問題
日誌顯示了一個有趣的現象:計算百分比的函數被多次觸發,而且第二次計算的結果與第一次不同:
觸發了格式化百分比 1 50.0
觸發了格式化百分比 2 0.0
觸發了格式化百分比 3 0.0
觸發了格式化百分比 4 50.0
觸發了格式化百分比 1 0.0
觸發了格式化百分比 2 0.0
觸發了格式化百分比 3 0.0
觸發了格式化百分比 4 100.0
這表明,在同一個變更檢測週期內,投票數據可能被修改了,導致第二次計算時結果不同。
5. 追蹤完整的事件流程
我們添加了更多日誌,追蹤從事件觸發到父組件處理的完整流程:
===== Radio 變更事件開始 =====
添加了新投票記錄
準備本地投票記錄 [{…}]
已更新本地投票記錄 (2) [{…}, {…}]
即將發送事件到父組件
===== 父組件收到選項變更事件 =====
A. 父組件收到選項變更事件: 8cb146e1-ed48-483f-bb9f-2a4c3c7d8983
B. 原本選中的選項: null
C. 選項已變更,正在更新
D. 更新後的選項: 8cb146e1-ed48-483f-bb9f-2a4c3c7d8983
===== 父組件處理完成 =====
已發送事件到父組件
--- 選項投票情況 ---
計算百分比開始 - 選項 1
選項 1 計算結果: 50.0%
選項: Poll 2 Option 1: ... | 百分比: 50.0% | 投票人數: 1 | 投票者: test03
計算百分比開始 - 選項 2
選項 2 計算結果: 0.0%
選項: Poll 2 Option 2: ... | 百分比: 0.0% | 投票人數: 0 | 投票者:
計算百分比開始 - 選項 3
選項 3 計算結果: 0.0%
選項: Poll 2 Option 3: ... | 百分比: 0.0% | 投票人數: 0 | 投票者:
計算百分比開始 - 選項 4
選項 4 計算結果: 50.0%
選項: Poll 2 Option 4: ... | 百分比: 50.0% | 投票人數: 1 | 投票者: test01
-------------------
===== Radio 變更事件結束 =====
計算百分比開始 - 選項 1
選項 1 計算結果: 0.0%
計算百分比開始 - 選項 1
選項 1 計算結果: 0.0%
觸發了格式化百分比 1 0.0
計算百分比開始 - 選項 2
選項 2 計算結果: 0.0%
計算百分比開始 - 選項 2
選項 2 計算結果: 0.0%
觸發了格式化百分比 2 0.0
計算百分比開始 - 選項 3
選項 3 計算結果: 0.0%
計算百分比開始 - 選項 3
選項 3 計算結果: 0.0%
觸發了格式化百分比 3 0.0
計算百分比開始 - 選項 4
選項 4 計算結果: 100.0%
計算百分比開始 - 選項 4
選項 4 計算結果: 100.0%
觸發了格式化百分比 4 100.0
我們發現:
Radio 事件和父組件處理都正確執行
在
Radio 變更事件結束
後,計算函數被再次觸發第二次計算時,選項 1 的票數變為 0%,選項 4 的票數變為 100%
6. 檢查 localVoteRecords 更新情況
為了驗證 localVoteRecords
是否正確更新,我們添加了更多日誌:
準備本地投票記錄 [{…}]
已更新本地投票記錄 (2) [{…}, {…}]
確認數據確實已經更新,但模板仍然顯示舊的值。
7. 檢查模板中的函數調用
我們意識到問題可能出在模板中直接調用函數。在 Angular 中,模板綁定的表達式會在每個變更檢測週期中多次求值:
<mat-radio-button
[style.--percentage]="calculatePercentage(option)"
>
<span class="option-percentage">{{ displayPercentage(option) }}%</span>
</mat-radio-button>
這裡有兩次函數調用:
[style.--percentage]="calculatePercentage(option)"
{{ displayPercentage(option) }}
而且 displayPercentage
內部又調用了 calculatePercentage
,可能導致多次計算。
8. 嘗試使用 Signal API
為了解決模板中多次計算的問題,我們決定使用 Angular 的 Signal API:
// 計算百分比
private percentagesSignal = computed(() => {
const percentages = new Map<string, number>();
this.options().forEach((option) => {
if (!this.totalVotes() || this.totalVotes() === 0) {
percentages.set(option.id, 0);
return;
}
const optionVotes = this.localVoteRecords().filter(
(record) => record.option_id === option.id
).length;
percentages.set(option.id, (optionVotes / this.totalVotes()) * 100);
});
return percentages;
});
// 公開的方法給模板使用
getFormattedPercentage(option: Option): number {
return this.percentagesSignal().get(option.id) || 0;
}
並修改模板:
<mat-radio-button
class="radio-option"
[value]="option.id"
[style.--percentage]="getFormattedPercentage(option)"
>
<div class="option-content">
<span class="option-text">{{ option.text }}</span>
<span class="option-percentage">{{ getFormattedPercentage(option) }}%</span>
</div>
</mat-radio-button>
然而,這個改動仍然沒有解決問題,背景和百分比仍然沒有正確更新。
9. 檢查 CSS 變數的使用
我們檢查了 CSS 代碼,特別關注 --percentage
變數的使用:
.radio-option::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: calc(var(--percentage) * 1%);
height: 100%;
background-color: rgba(97, 28, 194, 0.1);
z-index: 0;
pointer-events: none;
}
這裡要求 --percentage
是一個純數值(不含單位),以便 calc(var(--percentage) * 1%)
能夠正確計算。我們懷疑可能是變數格式的問題。
10. 嘗試添加 OnPush 變更檢測策略
我們還嘗試添加 OnPush 變更檢測策略,希望能減少不必要的變更檢測:
@Component({
selector: 'app-option',
standalone: true,
imports: [MatRadioModule, UserAvatarComponent, FormsModule],
templateUrl: './option.component.html',
styleUrl: './option.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
但這也沒有解決問題。
11. 確保 getFormattedPercentage 返回正確的格式
我們確認了 getFormattedPercentage
方法返回的是純數值,而不是帶有單位的字串:
getFormattedPercentage(option: Option): number {
const value = this.percentagesSignal().get(option.id) || 0;
return Math.round(value * 10) / 10;
}
在模板中:
<span class="option-percentage">{{ getFormattedPercentage(option) }}%</span>
單位 %
在模板中直接寫死,而不是包含在方法返回值中。但問題仍然存在。
當前狀態與推測
經過多次調試和嘗試,我們確認:
數據確實正確更新(從日誌可以看出
localVoteRecords
已更新)模板使用的
getFormattedPercentage
方法能取到正確的值(日誌顯示計算結果正確)CSS 變數設置正確([style.--percentage]="getFormattedPercentage(option)")
但是,視覺上百分比和背景仍然沒有更新。
可能的原因:
CSS 變數更新後沒有觸發樣式重新計算
Angular 的變更檢測沒有捕捉到樣式變化
可能存在某些阻止 DOM 更新的因素
問題仍然沒有完全解決,但我們在排查過程中已經識別出了一些潛在問題點,並嘗試了多種解決方案。這個案例突顯了 Angular 中處理模板渲染、變更檢測和 CSS 樣式綁定時可能遇到的複雜情況。
Last updated