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 訊號。 範本顯示 aMapchamp 的值;因此,視圖也會重新渲染。有比這更好的解決方案,因為它可能導致不必要的 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 及其值。 aDeepCopyMapupdate 方法以新的 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> },並呼叫 storeset 方法來覆寫訊號。訊號接收新的引用,並發生 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 共享資料的不同模式
系列文
Signal API in Angular37
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言