iT邦幫忙

2024 iThome 鐵人賽

0
JavaScript

Signal API in Angular系列 第 37 篇

Day 37 - 在信號中更新 Map,我希望有人在我犯錯之前告訴我。

  • 分享至 

  • xImage
  •  

更新儲存在 Angular 訊號中的 Map 時可能會出現微妙的錯誤,這主要是由於 change detection 如何與物件引用 (object reference) 配合使用。將鍵 (key) 新增至 Map 時,對原始 Map 的引用 (reference 保持不變,因為該操作會就地修改現有物件,而不是建立新實例。因此,Angular 的 change detection 無法辨識訊號 (signal) 已更新,導致元件中的視圖 (view) 和計算訊號 (computed signals) 無法反映 Map 的最新狀態。

請容許我解釋一下這個問題並提供逐步的解決方案。

更新訊號中的 Map - 無效版本

export type Data = {
 name: string;
 count: number;
}
function updateAndReturnMap(dataMap: Map<string, number>, { name, count }: Data) {
 const newCount = (dataMap.get(name) || 0) + count;

 if (newCount <= 0) {
   dataMap.delete(name);
 } else {
   dataMap.set(name, newCount);
 }
 return dataMap;
}

當數值為非正數時,此函數會從 Map 中刪除鍵。否則,現有鍵將更新為新數值。此函數修改 Map 內容並返回以更新訊號。

// main.ts

const MY_MAP = new Map<string, number>();
MY_MAP.set('orange', 3);

@Component({
 selector: 'app-root',
 standalone: true,
 imports: [AppSignalObjectComponent, AppSignalMapDataComponent],
 template: `
   <button (click)="addBanana()">Add banana</button>
   <div>
     <p>aMap and champ works in the current component because the equal function always returns false</p>
     @for (entry of aMap(); track entry[0]) {
       <p>{{ entry[0] }} - {{ entry[1] }}</p>
     }
     <p>Most Popular: {{ champ()?.[0] || '' }}</p>
   </div>
 `,
})
export class App {
  aMap = signal<Map<string, number>>(new Map(MY_MAP));

  champ = computed(() => {
   let curr: [string, number] | undefined = undefined;
   for (const entry of this.aMap()) {
     if (!curr || curr[1] < entry[1]) {
       curr = entry;
     }
   }
   return curr;
 });

 addBanana() {
   const data = { name: 'banana', count: 10 };
   this.updateMaps(data);
 }

 updateMaps(data: Data) {
   this.aMap.update((prev) => updateAndReturnMap(prev, data));
 }
}

MY_MAP 是一個包含鍵(orange)且值為 3 的 Map。 我複製了 MY_MAP 並使用預設選項建立了一個名為 aMap 的訊號。 champ 計算訊號迭代 map 以尋找具有最高值的鍵。 HTML 範本有一個用於顯示地圖條目 (map entries) 的 @for 迴圈、一個用於顯示 champ 訊號值的段落 (paragraph) 元素以及一個用於向其中新增 [banana, 10] 的按鈕。 點擊按鈕後,會顯示新的地圖條目,但champ 值不正確。預設 equal 函數使用三重等於 (===) 來比較值;因此,函數會比較 Map 的引用。 然而,Map 的引用並沒有被修改;僅新增了一個新鍵。因此,應用程式不會重新計算 champ 計算訊號。

這是一個錯誤,但透過改變 equal 函數很容易解決。

改變訊號的 equal 函數

aMap = signal<Map<string, number>>(new Map(MY_MAP), { equal: () => false });

我重寫 equal 函數以在訊號函數的選項中總是返回 false。 因此,Angular 認為訊號發生了變化;此元件已髒,需要在 change detection 期間更新。 champ 計算訊號取決於重新計算的 aMap 訊號。 範本顯示 aMap 和 champ 的值;因此,視圖也會重新渲染。有比這更好的解決方案,因為它可能導致不必要的 change detection 週期,但它修復了錯誤。

假設我重構了 App 元件,將 @for 迴圈和 champ 訊號邏輯移至新元件中以供重複使用。

// map-data.component.html

<div>
 <p>{{ title }}</p>
 @for (entry of mapData(); track entry[0]) {
   <p>{{ entry[0] }} - {{ entry[1] }}</p>
 }
 <p>Most Popular: {{ mostPopular() }}</p>
</div>
// signal-map-data.component.ts

@Component({
 selector: 'app-signal-map-data',
 standalone: true,
 templateUrl: `./map-data.component.html`,
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class AppSignalMapDataComponent { 
 mapData = input.required<Map<string, number>>();
 title = inject(new HostAttributeToken('title'), { optional: true }) || 'Signal';

 champ = computed(() => {
   let curr: [string, number] | undefined = undefined;
   for (const entry of this.mapData()) {
     if (!curr || curr[1] < entry[1]) {
       curr = entry;
     }
   }
   return curr;
 });
  mostPopular = computed(() => this.champ()?.[0] || '');
}

接下來,我將 AppSignalMapDataComponent 元件匯入到 App 元件中,並使用它在 HTML 範本中顯示相同的資料。

// main.ts

function updateAndReturnMap(dataMap: Map<string, number>, { name, count }: Data) { ... same logic as before … }

const MY_MAP = new Map<string, number>();
MY_MAP.set('orange', 3);

@Component({
 selector: 'app-root',
 standalone: true,
 imports: [AppSignalMapDataComponent],
 template: `
   <button (click)="addBanana()">Add banana</button>
   <app-signal-map-data [mapData]="aMap()" title='It does not work because the map reference does not change.' /> 
 `,
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
 aMap = signal<Map<string, number>>(new Map(MY_MAP), { equal: () => false });

 addBanana() { … same logic … }

 updateMaps(data: Data) {
   this.aMap.update((prev) => updateAndReturnMap(prev, data));
 }
}

我單擊按鈕將新鍵 (banana) 添加到 map。但是,AppSignalMapDataComponent 元件未顯示正確的結果。為什麼?

這是因為 mapData 輸入的引用保持不變。因此, change detection 不會更新 AppSignalMapDataComponent 元件中的視圖和計算訊號。

我沒有將 aMap 直接傳遞到訊號輸入 (signal input),而是製作 aMap 的副本並將新實例傳遞給它。

建立一個新的地圖實例

function updateAndReturnMap(dataMap: Map<string, number>, { name, count }: Data) { … same logic as before … }

const MY_MAP = new Map<string, number>();
MY_MAP.set('orange', 3);

@Component({
 selector: 'app-root',
 standalone: true,
 imports: [AppSignalMapDataComponent],
 template: `
   <button (click)="addBanana()">Add banana</button>
   <app-signal-map-data [mapData]="aDeepCopyMap()" title='Signal with a new map' />
 `,
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
 aDeepCopyMap = signal<Map<string, number>>(new Map(MY_MAP));

 addBanana() { … same logic… }

 updateMaps(data: Data) {
   this.aDeepCopyMap.update((prev) => updateAndReturnMap(new Map(prev), data));
 }
}

aDeepCopyMap 是儲存 Map 的訊號。 updateMaps 方法呼叫 new Map(prev) 建立一個新 Map 並將其傳遞給 updateAndReturnMap 函數以新增鍵、banana 及其值。 aDeepCopyMap 的 update 方法以新的 Map 更新訊號。 訊號輸入接收新的引用並觸發 change detection 以更新元件的視圖和計算訊號。 AppSignalMapDataComponent 元件在 HTML 範本中顯示正確的結果。

這是一個合理的解決方案,因為呼叫 Map 建構函數 (constructor) 並不昂貴。最後一個解決方案是將 map 儲存在物件中,並在每次 map 操作後建立新的物件引用。

更改訊號以儲存物件

// signal-object.component.ts

@Component({
 selector: 'app-signal-object',
 standalone: true,
 templateUrl: './map-data.component.html',
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class AppSignalObjectComponent { 
 store = input.required<{ map: Map<string, number> }>();
 mapData = computed(() => this.store().map);
 champ = computed(() => {
   let curr: [string, number] | undefined = undefined;
   for (const entry of this.store().map) {
     if (!curr || curr[1] < entry[1]) {
       curr = entry;
     }
   }
   return curr;
 });
 mostPopular = computed(() => this.champ()?.[0] || '');
 title = 'Signal is an Object with a Map';
}
// main.ts

function updateAndReturnMap(dataMap: Map<string, number>, { name, count }: Data) { ... same logic as before ... }

const MY_MAP = new Map<string, number>();
MY_MAP.set('orange', 3);

@Component({
 selector: 'app-root',
 standalone: true,
 imports: [AppSignalObjectComponent],
 template: `
   <button (click)="addBanana()">Add banana</button>
   <app-signal-object [store]="this.store()" />
 `,
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
 store = signal({ map: new Map(MY_MAP) });

 addBanana() { ...same logic... }

 updateMaps(data: Data) {
   this.store.set({ map: updateAndReturnMap(this.store().map, data) });
 }
}

store 是儲存包含 Map 的物件的訊號。 updateMaps 方法呼叫 updateAndReturnMap 函數將鍵、banana 和 value 加入到同一個 Map 物件中。 此方法建立一個新物件 {map: <a Map Object> },並呼叫 store 的 set 方法來覆寫訊號。訊號接收新的引用,並發生 change detection。 AppSignalObjectComponent 元件的視圖、訊號輸入和計算訊號因此而更新。

結論:

  • 向 Map 新增鍵不會觸發 change detection,因為 Map 的引用不會改變。
  • 簡單的解決方案是改變 Signal 的 equal 函數以總是傳回 false。
  • 如果Map是元件的訊號輸入,也會出現同樣的問題。這是因為當僅向 Map 添加鍵時,對訊號輸入的引用不會改變。
  • 我呼叫 Map 建構函式 (constructor) 來建立新的 Map 並更新訊號。訊號輸入變更引用和 change detection 會發生以更新檢視和訊號。
  • 最後一個解決方案是將 Map 儲存在一個 Object 中,而訊號則儲存 Object 引用。 在 Map 上新增鍵後,我建立一個新物件並覆蓋訊號。 訊號輸入獲得新的引用,並發生 change detection。類似地,該元件更新視圖、輸入和計算訊號。

鐵人賽的第 37 天到此結束

參考:


上一篇
Day 36 - 探索利用 signals 共享資料的不同模式
下一篇
Day 38 - 在 Angular 19 中重置或設定 LinkedSignal 中的值
系列文
Signal API in Angular 共 44 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言