Angular

Lab 5: Signals

arrow_back Alle labs
timer 20 minutter
sell module-5-start → module-6-start
Mål: Konvertér appen til at bruge signals — Angulars reaktive primitiver. Signals er værdier der automatisk opdaterer alt der afhænger af dem. Når et signal ændrer sig, ved Angular præcist hvad der skal opdateres i DOM'en, uden at du manuelt skal kalde metoder eller holde styr på ændringer.

Udgangspunkt

Checkout tag module-5-start. Alle components bruger klassiske @Input()/@Output() decorators og EventEmitter.

git checkout module-5-start

Lige nu ser herd-card.ts sådan ud:

// herd-card.ts (udgangspunkt)
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Card } from '../../shared/card';

export class HerdCard {
  @Input({ required: true }) herd!: Card;
  @Output() countChanged = new EventEmitter<number>();
  // ...
}

Og herd-input.ts har manuelle beregninger med @Output() og EventEmitter:

// herd-input.ts (udgangspunkt)
import { Component, Output, EventEmitter } from '@angular/core';

export class HerdInput {
  @Output() totalEmissionChange = new EventEmitter<{ totalEmission: number }>();
  herdCounts: HerdCounts = { ... };
  // ...
}

Hvad er signals?

Et signal er en reaktiv værdi. Tænk på det som en variabel der "ved" hvem der bruger den. Når signalet ændrer sig, opdaterer Angular automatisk alle steder der læser det. Der findes tre typer:

Angular har også signal baserede versioner af input() og output() der erstatter de gamle @Input()/@Output() decorators.

Trin for trin

looks_one Kør signal input migration

Angular CLI har en built-in migration der konverterer alle @Input() decorators til den nye input() funktion automatisk:

ng generate @angular/core:signal-input-migration

Når CLI'en spørger, vælg ./ som sti og "Yes" til at migrere så meget som muligt. Migrationen finder 2 inputs og konverterer dem:

// herd-card.ts — FØR
@Input({ required: true }) herd!: Card;

// herd-card.ts — EFTER
readonly herd = input.required<Card>();
// result-panel.ts — FØR
@Input() totalEmission = 0;

// result-panel.ts — EFTER
readonly totalEmission = input(0);

Migrationen opdaterer også templates automatisk. Da input() returnerer et signal, skal du kalde det som en funktion for at læse værdien. Så herd.emoji bliver til herd().emoji, og totalEmission bliver til totalEmission().

lightbulb
Hvorfor readonly? Signal inputs sættes af parent via template binding. Du kan ikke overskrive selve signalet inde i componenten, kun læse det. readonly gør dette tydeligt i koden.

looks_two Kør signal output migration

Næste migration konverterer @Output() og EventEmitter til den nye output() funktion:

ng generate @angular/core:output-migration

Det ændrer begge outputs i projektet:

// herd-card.ts — FØR
@Output() countChanged = new EventEmitter<number>();

// herd-card.ts — EFTER
readonly countChanged = output<number>();
// herd-input.ts — FØR
@Output() totalEmissionChange = new EventEmitter<{ totalEmission: number }>();

// herd-input.ts — EFTER
readonly totalEmissionChange = output<{ totalEmission: number }>();

Importen ændres fra EventEmitter, Output til blot output (lille bogstav, en funktion i stedet for en dekorator). Du kalder stadig .emit() på præcis samme måde som før.

looks_3 Konvertér herdCounts til signal()

Nu tilføjer vi den virkelige magi. I herd-input.ts konverterer vi herdCounts fra en almindelig property til et signal:

// herd-input.ts
import { Component, signal, computed, effect, output } from '@angular/core';

readonly herdCounts = signal<HerdCounts>({
  dairyCows: 0,
  beefCows: 0,
  calves: 0,
  pigs: 0,
  sows: 0,
  chickens: 0,
});

signal() opretter en reaktiv værdi. Du læser den med this.herdCounts() og opdaterer den med .set() eller .update().

looks_4 Tilføj computed() til totalEmission

computed() opretter et readonly signal der automatisk genberegnes når de signals det afhænger af ændrer sig. Erstat den manuelle calculateTotal() metode med:

readonly totalEmission = computed(() => {
  const counts = this.herdCounts();
  return this.herdCards.reduce(
    (sum, card) => sum + (counts[card.key] * card.factor), 0
  ) || 0;
});

Når herdCounts signalet ændrer sig, genberegner Angular automatisk totalEmission. Du behøver aldrig kalde en beregningsmetode manuelt igen.

looks_5 Tilføj effect() til debugging

effect() kører en side effect hver gang et signal den læser ændrer sig. Tilføj den i constructoren:

constructor() {
  effect(() => console.log('Total emission:', this.totalEmission()));
}

Angular registrerer automatisk at denne effect afhænger af totalEmission (som afhænger af herdCounts). Åbn browser console og se loggen opdatere sig hver gang du ændrer et antal.

looks_6 Opdatér onCountChanged

Da herdCounts nu er et signal, kan du ikke bare tildele direkte med =. Brug .update() der tager den nuværende værdi og returnerer en ny:

onCountChanged(key: keyof HerdCounts, count: number): void {
  this.herdCounts.update(prev => ({ ...prev, [key]: count }));
  this.totalEmissionChange.emit({ totalEmission: this.totalEmission() / 1000 });
}

.update() modtager en funktion der får den gamle værdi (prev) og returnerer en ny. Spread operatoren (...prev) kopierer alle eksisterende værdier og overskriver kun den ændrede nøgle.

lightbulb
Hvad forsvandt? Den manuelle calculateTotal() metode er helt væk. computed() klarer alt automatisk. Når herdCounts opdateres, ved totalEmission at den skal genberegnes, og effect() ved at den skal logge igen. Det er hele pointen med signals: du beskriver hvad der afhænger af hvad, og Angular klarer hvornår.
Hint: Komplet herd-card.ts
import { Component, input, output } from '@angular/core';
import { Card } from '../../shared/card';
import { DecimalPipe } from '@angular/common';

@Component({
  selector: 'app-herd-card',
  imports: [DecimalPipe],
  templateUrl: './herd-card.html',
  styleUrl: './herd-card.css',
})
export class HerdCard {
  readonly herd = input.required<Card>();
  readonly countChanged = output<number>();
  isActive = false;

  onCountChanged(count: number) {
    this.isActive = count > 0;
    this.countChanged.emit(count);
  }
}
Hint: Komplet herd-input.ts
import { Component, signal, computed, effect, output } from '@angular/core';
import { Card, HerdCounts, herdCards } from '../../shared/herd-data';
import { HerdCard } from '../herd-card/herd-card';

@Component({
  selector: 'app-herd-input',
  imports: [HerdCard],
  templateUrl: './herd-input.html',
  styleUrl: './herd-input.css',
})
export class HerdInput {
  readonly herdCards: Card[] = herdCards;

  readonly totalEmissionChange = output<{ totalEmission: number }>();

  readonly herdCounts = signal<HerdCounts>({
    dairyCows: 0,
    beefCows: 0,
    calves: 0,
    pigs: 0,
    sows: 0,
    chickens: 0,
  });

  readonly totalEmission = computed(() => {
    const counts = this.herdCounts();
    return this.herdCards.reduce(
      (sum, card) => sum + (counts[card.key] * card.factor), 0
    ) || 0;
  });

  constructor() {
    effect(() => console.log('Total emission:', this.totalEmission()));
  }

  onCountChanged(key: keyof HerdCounts, count: number): void {
    this.herdCounts.update(prev => ({ ...prev, [key]: count }));
    this.totalEmissionChange.emit({ totalEmission: this.totalEmission() / 1000 });
  }
}
Hint: Komplet result-panel.ts
import { Component, input } from '@angular/core';
import { DecimalPipe } from '@angular/common';

@Component({
  selector: 'app-result-panel',
  imports: [DecimalPipe],
  templateUrl: './result-panel.html',
  styleUrl: './result-panel.css',
})
export class ResultPanel {
  readonly totalEmission = input(0);
}

Dokumentation