元件中的多次渲染問題排查過程

本篇記錄了一個實際案例:投票系統中,選項的投票百分比和背景顏色無法正確更新的排查過程。

問題描述

在一個投票系統中,用戶選擇一個選項後,該選項的投票百分比應該更新,同時背景顏色也應該根據百分比調整。但實際情況是,點擊選項後雖然選項被正確選中(邊框顯示選中狀態),但百分比數值和背景顏色沒有更新。

原始程式碼結構:

<!-- 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-groupchange 事件代替 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>

單位 % 在模板中直接寫死,而不是包含在方法返回值中。但問題仍然存在。

當前狀態與推測

經過多次調試和嘗試,我們確認:

  1. 數據確實正確更新(從日誌可以看出 localVoteRecords 已更新)

  2. 模板使用的 getFormattedPercentage 方法能取到正確的值(日誌顯示計算結果正確)

  3. CSS 變數設置正確([style.--percentage]="getFormattedPercentage(option)")

但是,視覺上百分比和背景仍然沒有更新。

可能的原因:

  1. CSS 變數更新後沒有觸發樣式重新計算

  2. Angular 的變更檢測沒有捕捉到樣式變化

  3. 可能存在某些阻止 DOM 更新的因素

問題仍然沒有完全解決,但我們在排查過程中已經識別出了一些潛在問題點,並嘗試了多種解決方案。這個案例突顯了 Angular 中處理模板渲染、變更檢測和 CSS 樣式綁定時可能遇到的複雜情況。

Last updated