iT邦幫忙

2024 iThome 鐵人賽

0
JavaScript

Signal API in Angular系列 第 36

Day 36 - 探索利用 signals 共享資料的不同模式

  • 分享至 

  • xImage
  •  

元件通訊 (component communication) 是元件架構裡面重要的一環,其中父元件向子元件提供輸入,子元件將結果傳回給其父元件。

我將示範 Angular 元件之間資料共享的四種模式。他們是:

  • signal inputs 和 RxJS-interop output emitters。其中父元件透過 inputs 將資料傳遞給子元件,子元件透過 output emitters 通知父元件。
  • Signas in a service。 服務封裝 Writable signals,公開 read-only 訊號 (signal)和一些更新方法來更新 Writable signals。然後,元件可以注入 service 來存取和修改訊號。
  • 提供/注入狀態物件 (state object)。當開發人員不想使用服務時,元件提供封裝訊號 (signal) 屬性的物件。 應用程式為元件提供可以讀取和寫入訊號屬性的物件。
  • Signal Store 或 Signal State。 當應用程式成長時,我建議團隊使用 state management library(例如 NgRx Signal Store/ Signal State)來共享全域資料並幫助應用程式擴充。

應用程式路由

export const routes: Route[] = [
 {
   path: 'input-output',
   loadComponent: () => import('./communication/components/app-input-output.component'),
   data: {
     secretValue: 'my-secret',
   }
 },
 {
   path: 'signal-in-service',
   loadComponent: () => import('./communication/components/app-signal-in-service.component'),
 },
 {
   path: 'provide-inject',
   loadComponent: () => import('./communication/components/app-provide-inject.component'),
 },
 {
   path: 'signal-state',
   loadComponent: () => import('./communication/components/app-signal-state.component'),
 },
];

該應用程式有四個元件來演示資料共享模式。input-output 路由具有將被解析並成為 Angular 元件的signal input 的路由資料 (route data)。在 provideRouter 函數中啟用 withComponentInputBinding 功能後就可以實作。

引導應用程式

import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
 providers: [
   provideExperimentalZonelessChangeDetection(),
   provideRouter(routes, withComponentInputBinding())
 ]
}
bootstrapApplication(App, appConfig);

appConfig 提供到 provideRouter 函數的路由,withComponentInputBinding 功能將secretValue 資料綁定到 signal input。 然後,bootstrapApplication 使用 App 元件和設定引導應用程式。

Signal Input 和 output emitter

.enabled {
   border: 1px solid black;
   border-radius: 0.25rem;
}
// app-input-output.component.ts
@Component({
 selector: 'app-input-output',
 standalone: true,
 imports: [AppInputOutputGrandchildComponent],
 template: `
   <h3>Input/Output Component</h3>
   <div [class.enabled]="isEnabled()">
     <app-input-output-grandchild [secretValue]="secretValue()" (featureFlag)="handleClicked($event)" />
   </div>
 `,
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class AppInputOutputComponent {
 secretValue = input.required<string>();
 isEnabled = signal(false);
 featureFlag = output<Feature>();

 handleClicked(value: Feature) {
   this.isEnabled.set(value.isShown);
   this.featureFlag.emit(value);
 }
}

AppInputOutputComponent 元件是一個父元件,它將資料綁定到 AppInputOutputGrandchildComponent 元件的 input,並將結果傳送到 App 元件。 AppInputOutputComponent 有一個 secretVaue input,並傳遞給其子元件的 input。 它也聲明了一個發出功能名稱和狀態的 featureFlag output 函數。 當 AppInputOutputGrandchildComponent 的自訂 featureFlag 事件發出新結果時,handleClicked 方法會更新 featureFlag output 函數以將其傳播到 App 元件。此外,isEnabled 訊號 會切換 enabled 類別以在子元件周圍繪製邊框。

// grand-child.component.html

<h3>{{ title }}</h3>
 <div>
   <p>Secret Value: {{ secretValue() }}</p>
   <p>{{ toggleText() }}</p>
   <button (click)="handleClicked()">Click Me!!!!</button>
</div>
@Component({
 selector: 'app-input-output-grandchild',
 standalone: true,
 templateUrl: './grand-child.component.html',
})
export default class AppInputOutputGrandchildComponent {
 secretValue = input.required<string>();
 toggleFeature = signal(false);

 toggleText = computed(() => {
   const click = this.toggleFeature() ? 'disable' : 'enable';
   return `Click the button to ${click} the input/output feature`;
 });

 featureFlag = output<Feature>();

 handleClicked() {
   this.toggleFeature.set(!this.toggleFeature());
   this.featureFlag.emit({
     name: 'Input/Output feature',
     isShown: this.toggleFeature()
   });
 }
}

AppInputOutputGrandchildComponent 是一個表現元件,它接收來自父元件的 input,並使用 RxJs-interop output 函數通知父元件 featureFlag 的狀態已變更。

signal inputoutput emitter 模式很簡單,但對於深度嵌套的元件樹來說並不是最佳的。這些元件容易出現 "input/output" 複製,其中每一層都必須複製相同的 input 和 output emitter。

因此,在 Signals in a Service,元件可以注入服務來存取或更新 訊號。

Signals 在服務中 (Signals in a Service)

import { computed, Injectable, signal } from '@angular/core';
import { FeatureFacade } from '../feature.facade';
import { Feature } from '../types/feature.type';

@Injectable({
 providedIn: 'root'
})
export class CommunicationService implements FeatureFacade {
 #secretValue = signal('');
 secretValue = this.#secretValue.asReadonly();

 #feature = signal<Feature | null>(null);
 feature = this.#feature.asReadonly();
 featureName = computed(() => {
   const feature = this.feature() || { name: '', isShown: false };
   return feature.isShown ? feature.name : '';
 });

 setSecretValue(value: string): void {
   this.#secretValue.set(value);
 }
  setFeature(feature: Feature | null): void {
   this.#feature.set(feature);
 }
}

CommunicationService 服務定義了 setScretValuesetFeature 方法來更新 Writable signals。 它也公開只讀訊號、secretValue、feature 和 featureName。

App 元件中,secretValuengOnInit 生命週期方法中設定。

ngOnInit(): void {
   this.communicationService.setSecretValue('signal-in-a-service-secret');
   this.signalStateService.setSecretValue('signal-state-secret');
}
// app-signal-in-service.component.ts 

@Component({
 selector: 'app-signal-in-service',
 standalone: true,
 imports: [AppSignalInServiceGrandchildComponent],
 template: `
   <h3>Signal in a Service Component</h3>
   <div [class.enabled]="isEnabled()">
     <app-signal-in-service-grandchild />
   </div>
 `,
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class AppSignalInServiceComponent { 
 isEnabled = computed(() => {
   const feature = this.communicationService.feature();
   return feature?.isShown || false
 });

 communicationService = inject(CommunicationService);
}

父元件存取服務的唯讀訊號以建立 isEnabled 計算訊號 (computed signal)。isEnabled 何時應用 enabled 類別在子元件周圍繪製邊框。

// app-signal-in-service-grandchild.component.ts

@Component({
 selector: 'app-signal-in-service-grandchild',
 standalone: true,
 templateUrl: './grand-child.component.html',
})
export default class AppSignalInServiceGrandchildComponent {
 communicationService = inject(CommunicationService);
 toggleFeature = signal(false);
 secretValue = this.communicationService.secretValue;

 toggleText = computed(() => /* same logic */);

 handleClicked() {
   this.toggleFeature.set(!this.toggleFeature());
   this.communicationService.setFeature({
     name: 'Signal in a Service feature',
     isShown: this.toggleFeature()
   });
 }
}

AppSignalInServiceGrandchildComponent 元件呼叫服務的 setFeature 方法來更新功能標誌。它還從服務獲取 secretValue 訊號並在模板中顯示該值。

當元件狀態包含很少的資訊時,開發人員可能會認為 Signals in a Service 模式過於複雜。然後,他們可以將狀態封裝在一個物件中並將其註入到元件中,從而完全刪除服務。元件不注入服務,而是注入一個 injection token。

提供和注入一個狀態物件 (state object)

// provider-inject.type.ts

import { WritableSignal } from '@angular/core';
import { Feature } from './feature.type';

export type ProvideInjectToken = {
 secretValue: WritableSignal<string>;
 feature: WritableSignal<Feature | null>; 
}
// provide-inject.constant.ts

import { InjectionToken } from '@angular/core';
import { ProvideInjectToken } from './types/provide-inject.type';

export const PROVIDE_INJECT_TOKEN = new InjectionToken<ProvideInjectToken>('PROVIDE_INJECT_TOKEN');

PROVIDE_INJECT_TOKEN 是注入 ProvideInjectToken 實例的 injection token。 ProvideInjectToken 類型由兩個訊號屬性組成:secretValuefeature。然後元件可以讀取或更新註入物件的訊號屬性。

@Component({
 selector: 'app-root',
 providers: [
   {
     provide: PROVIDE_INJECT_TOKEN,
     useValue: {
       secretValue: signal(''),
       feature: signal(null)
     }
   }
 ],
})
export class App implements OnInit {}

App 元件將 PROVIDE_INJECT_TOKEN token 注入 'providers' array 中。因此,AppProvideInjectComponentAppProvideInjectGrandchildComponent 元件可以注入 token來取得 ProvideInjectToken 的實例。這些元件可以讀取訊號 、顯示值或為其指派新值。

// app-provide-inject.component.ts

@Component({
 selector: 'app-provide-inject-service',
 standalone: true,
 imports: [AppProvideInjectGrandchildComponent],
 template: `
   <h3>Provide/Inject Component</h3>
   <div [class.enabled]="isEnabled()">
     <app-provide-inject-grandchild />
   </div>
 `,
})
export default class AppProvideInjectComponent { 
 token = inject(PROVIDE_INJECT_TOKEN);
 isEnabled = computed(() => this.token.feature()?.isShown || false);
}

父元件注入 PROVIDE_INJECT_TOKEN 並使用 feature訊號來匯出 isEnabled 計算訊號 (computed signal)。當值為 true 時,enabled 類別會在子元件周圍新增邊框。否則,子組件周圍沒有邊框。

// app-provide-injector-grandchild.component.ts

@Component({
 selector: 'app-provide-inject-grandchild',
 standalone: true,
 templateUrl: './grand-child.component.html',
})
export default class AppProvideInjectGrandchildComponent {
 toggleFeature = signal(false);
 token = inject(PROVIDE_INJECT_TOKEN);
 secretValue = computed(() => this.token.secretValue());

 toggleText = computed(() => { …same logic… });

 handleClicked() {
   this.toggleFeature.set(!this.toggleFeature());
   this.token.feature.set({
     name: 'Provide/Inject feature',
     isShown: this.toggleFeature()
   });
 }
}

子元件宣告一個 secretValue 計算訊號以傳回 token 的 secretValue 訊號。在 handleClicked方法中,token 的 feature 訊號被指派一個新值。

最後,我們嘗試了上述模式,應用程式已經發展到企業規模。現在是時候使用 state management library 來解決資料共享問題,而不是使用自訂的解決方案。

NgRx Signal State

npm install @ngrx/signals

將 NgRx Signal State 安裝到專案中。

// signal-state.service.ts

@Injectable({
 providedIn: 'root'
})
export class SignalStateService implements FeatureFacade {
 #state = signalState<{ secretValue: string, feature: Feature | null }>({
   secretValue: '',
   feature: null,
 });

 secretValue = computed(() => this.#state.secretValue()); 
 feature = computed(() => this.#state.feature());
 featureName = computed(() => {
   const feature = this.feature() || { name: '', isShown: false };
   return feature.isShown ? feature.name : '';
 });

 setSecretValue(secretValue: string): void {
   patchState(this.#state, () => ({ secretValue }));
 }
  setFeature(feature: Feature | null): void {
   patchState(this.#state, () => ({ feature }))
 }
}

#state 是由 secretValuefeature 屬性組成的訊號狀態 (signal state)。 setSecretValuesetFeature 方法分別修補狀態中的 secretValuefeature 的值。 secretValuefeaturefeatureName 是從狀態中提取屬性的計算訊號 (computed signals)。

// app-signal-state.component.ts

@Component({
 selector: 'app-signal-state',
 standalone: true,
 imports: [AppSignalStateGrandchildComponent],
 template: `
   <h3>NgRx Signal State Component</h3>
   <div [class.enabled]="isEnabled()">
     <app-signal-state-grandchild />
   </div>
 `,
})
export default class AppSignalStateComponent { 
 isEnabled = computed(() => {
   const feature = this.service.feature();
   return feature?.isShown || false
 });

 service = inject(SignalStateService);
}

類似地,父元件注入 SignalStateService 服務並使用 feature訊號來衍生 isEnabled 計算訊號 (computed signal)。

// app-signal-state-grandchild.component.ts
 
@Component({
 selector: 'app-signal-state-grandchild',
 standalone: true,
 templateUrl: './grand-child.component.html',
})
export default class AppSignalStateGrandchildComponent {
 service = inject(SignalStateService);
 toggleFeature = signal(false);
 secretValue = this.service.secretValue;

 toggleText = computed(() => { …same logic… });

 handleClicked() {
   this.toggleFeature.set(!this.toggleFeature());
   this.service.setFeature({
     name: 'Signal State feature',
     isShown: this.toggleFeature()
   });
 }
}

子元件注入 SignalStateService 以顯示 secretValue訊號的值。類似地,handleClicked 方法呼叫服務的 setFeature 方法來更新 feature訊號。

RouterOutlet 事件

// main.ts

@Component({
 selector: 'app-root',
 standalone: true,
 imports: [NavbarComponent, RouterOutlet],
 template: `
   <p>Selected feature: {{ unifiedFeature() }}</p>
   <router-outlet (activate)="onActivate($event)" (deactivate)="onDeactivate()" />
 `,
 providers: [...omitted for brevity reason…],
})
export class App implements OnInit {
 feature = signal<string>('');
 communicationService = inject(CommunicationService);
 signalStateService = inject(SignalStateService);
 token = inject(PROVIDE_INJECT_TOKEN);

 unifiedFeature = computed(() => {
   const tokenFeature = this.token.feature();
   const tokenFeatureName = tokenFeature?.isShown ? tokenFeature.name : ''; 

   return this.feature() || this.communicationService.featureName() || tokenFeatureName || this.signalStateService.featureName();
 })

  onActivate(component: any) {
   if (component.featureFlag) {
     component.featureFlag.subscribe((v: Feature) => {
       this.feature.set(v.isShown ? v.name : '');
     });
   }
 }

 onDeactivate() {
   this.token.feature.set(null);
   this.communicationService.setFeature(null);
   this.feature.set('');
   this.signalStateService.setFeature(null);
 }
}

RouterOutletactivated 事件通知被路由的元件。 當元件為 AppInputOutputComponent 時,更新 feature訊號。當不再對某個元件進行路由時,所有 feature 訊號都會重設為空。

unifiedFeature 是一個計算訊號 (computed signal),用於衍生功能的名稱。最多有一個 feature 訊號的 isShown 屬性等於 true。 此功能的名稱是 unifiedFeature 訊號的結果。

結論:

  • 元件之間的資料共享可以逐步修改。
  • 最直接的機制是 signal inputs 和 RxJS-interop output 在父元件和子元件之間共享資料。
  • 當元件樹 (component tree) 很深時,元件很容易出現 input/output 重複。因此,我們使用 "signals in a service",其中任何層的元件都可以注入服務來讀取和寫入訊號。
  • 如果共享資料是輕量級的,開發人員可以完全避免服務。開發人員可以注入 injection token 來提供由訊號屬性組成的物件。然後,元件可以注入該物件來存取或更新訊號屬性。
  • 為了幫助擴展應用程序,應用程式可以將資料儲存在 signal store或 signal state 中。然後,資料可供所有元件使用。

鐵人賽的第 36 天到此結束

參考:


上一篇
Day 35 - 使用rejectErrors選項更改toSignal的錯誤處理行為
系列文
Signal API in Angular36
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言