Sicherer Zugriff auf DOM mit Angular SSR

Gerald Monaco
Gerald Monaco

Im letzten Jahr wurden viele neue Funktionen in Angular hinzugefügt, z. B. Hydration und deferrable views, mit denen Entwickler ihre Core Web Vitals verbessern und die Nutzerfreundlichkeit für ihre Endnutzer erhöhen können. Wir arbeiten auch an weiteren serverseitigen Rendering-Funktionen, die auf dieser Funktion aufbauen, z. B. Streaming und teilweise Hydratisierung.

Leider gibt es ein Muster, das verhindern kann, dass Ihre Anwendung oder Bibliothek alle diese neuen und kommenden Funktionen voll ausschöpft: die manuelle Manipulation der zugrunde liegenden DOM-Struktur. In Angular muss die Struktur des DOM von dem Zeitpunkt an, zu dem eine Komponente vom Server serialisiert wird, bis zu dem Zeitpunkt, zu dem sie im Browser wiederhergestellt wird, unverändert bleiben. Wenn Sie ElementRef, Renderer2 oder DOM-APIs verwenden, um Knoten vor der Hydratisierung manuell zum DOM hinzuzufügen, zu verschieben oder daraus zu entfernen, kann es zu Inkonsistenzen kommen, die die Funktion dieser Funktionen verhindern.

Allerdings ist nicht jede manuelle DOM-Manipulation und jeder manuelle Zugriff problematisch. Manchmal ist es sogar erforderlich. Der Schlüssel zur sicheren Verwendung des DOM besteht darin, die Notwendigkeit so weit wie möglich zu minimieren und dann die Verwendung so lange wie möglich hinauszuschieben. In den folgenden Richtlinien wird erläutert, wie Sie dies erreichen und wirklich universelle und zukunftssichere Angular-Komponenten erstellen können, die alle neuen und kommenden Funktionen von Angular optimal nutzen.

Manuelle DOM-Manipulation vermeiden

Die Probleme, die durch die manuelle DOM-Manipulation verursacht werden, lassen sich am besten vermeiden, indem sie nach Möglichkeit ganz vermieden wird. Angular bietet integrierte APIs und Muster, mit denen die meisten Aspekte des DOM manipuliert werden können. Sie sollten diese verwenden, anstatt direkt auf das DOM zuzugreifen.

DOM-Element einer Komponente mutieren

Wenn Sie eine Komponente oder Direktive schreiben, müssen Sie möglicherweise das Hostelement (d. h. das DOM-Element, das mit dem Sellektor der Komponente oder Direktive übereinstimmt) ändern, um beispielsweise eine Klasse, einen Stil oder ein Attribut hinzuzufügen, anstatt ein Wrapper-Element zu verwenden oder einzuführen. Es ist verlockend, einfach ElementRef zu verwenden, um das zugrunde liegende DOM-Element zu verändern. Verwenden Sie stattdessen Hostbindungen, um die Werte deklarativ an einen Ausdruck zu binden:

@Component({
  selector: 'my-component',
  template: `...`,
  host: {
    '[class.foo]': 'true'
  },
})
export class MyComponent {
  /* ... */
}

Ähnlich wie bei der Datenbindung in HTML können Sie beispielsweise auch Attribute und Stile binden und 'true' in einen anderen Ausdruck ändern, mit dem Angular den Wert bei Bedarf automatisch hinzufügt oder entfernt.

In einigen Fällen muss der Schlüssel dynamisch berechnet werden. Sie können auch ein Signal oder eine Funktion binden, die einen Satz oder eine Zuordnung von Werten zurückgibt:

@Component({
  selector: 'my-component',
  template: `...`,
  host: {
    '[class.foo]': 'true',
    '[class]': 'classes()'
  },
})
export class MyComponent {
  size = signal('large');
  classes = computed(() => {
    return [`size-${this.size()}`];
  });
}

Bei komplexeren Anwendungen kann es verlockend sein, eine manuelle DOM-Manipulation zu verwenden, um eine ExpressionChangedAfterItHasBeenCheckedError zu vermeiden. Stattdessen können Sie den Wert wie im vorherigen Beispiel an ein Signal binden. Das ist nach Bedarf möglich und erfordert nicht, dass Signale in Ihrer gesamten Codebasis übernommen werden.

DOM-Elemente außerhalb einer Vorlage mutieren

Es ist verlockend, über das DOM auf Elemente zuzugreifen, auf die normalerweise nicht zugegriffen werden kann, z. B. auf Elemente, die zu anderen über- oder untergeordneten Komponenten gehören. Dies ist jedoch fehleranfällig, verstößt gegen die Kapselung und erschwert es, diese Komponenten in Zukunft zu ändern oder zu aktualisieren.

Stattdessen sollte Ihre Komponente jede andere Komponente als Black Box betrachten. Überlegen Sie, wann und wo andere Komponenten (auch innerhalb derselben Anwendung oder Bibliothek) mit der Komponente interagieren oder ihr Verhalten oder Aussehen anpassen müssen, und stellen Sie dann eine sichere und dokumentierte Möglichkeit dafür bereit. Verwenden Sie Funktionen wie die hierarchische Abhängigkeitsinjektion, um eine API für einen untergeordneten Knoten verfügbar zu machen, wenn einfache @Input- und @Output-Eigenschaften nicht ausreichen.

Bisher war es üblich, Funktionen wie modale Dialogfelder oder Kurzinfos zu implementieren, indem am Ende des <body> oder eines anderen Hostelements ein Element hinzugefügt und dann Inhalte dorthin verschoben oder projiziert wurden. Heutzutage können Sie jedoch wahrscheinlich stattdessen ein einfaches <dialog>-Element in Ihrer Vorlage rendern:

@Component({
  selector: 'my-component',
  template: `<dialog #dialog>Hello World</dialog>`,
})
export class MyComponent {
  @ViewChild('dialog') dialogRef!: ElementRef;

  constructor() {
    afterNextRender(() => {
      this.dialogRef.nativeElement.showModal();
    });
  }
}

Manuelle DOM-Manipulation verschieben

Wenn Sie die vorherigen Richtlinien befolgt haben, um die direkte DOM-Manipulation und den Zugriff so weit wie möglich zu minimieren, bleiben möglicherweise einige davon übrig, die unvermeidlich sind. In solchen Fällen ist es wichtig, die Aktualisierung so lange wie möglich hinauszuschieben. afterRender- und afterNextRender-Callbacks eignen sich hervorragend dazu, da sie nur im Browser ausgeführt werden, nachdem Angular nach Änderungen gesucht und sie im DOM verbindlich gemacht hat.

Nur Browser-JavaScript ausführen

In einigen Fällen haben Sie eine Bibliothek oder API, die nur im Browser funktioniert (z. B. eine Diagrammbibliothek oder die Verwendung von IntersectionObserver). Anstatt bedingt zu prüfen, ob die Ausführung im Browser erfolgt, oder das Verhalten auf dem Server zu stubben, kannst du einfach afterNextRender verwenden:

@Component({
  /* ... */
})
export class MyComponent {
  @ViewChild('chart') chartRef: ElementRef;
  myChart: MyChart|null = null;
  
  constructor() {
    afterNextRender(() => {
      this.myChart = new MyChart(this.chartRef.nativeElement);
    });
  }
}

Benutzerdefiniertes Layout ausführen

Manchmal müssen Sie das DOM lesen oder darauf schreiben, um ein Layout auszuführen, das von Ihren Zielbrowsern noch nicht unterstützt wird, z. B. das Positionieren eines Kurzinfofelds. afterRender eignet sich hervorragend dafür, da Sie sicher sein können, dass sich das DOM in einem konsistenten Zustand befindet. Für afterRender und afterNextRender ist ein phase-Wert von EarlyRead, Read oder Write zulässig. Wenn das DOM-Layout nach dem Schreiben gelesen wird, muss der Browser das Layout synchron neu berechnen. Das kann sich erheblich auf die Leistung auswirken (siehe Layout-Trashing). Daher ist es wichtig, die Logik sorgfältig in die richtigen Phasen aufzuteilen.

Für eine Kurzinfo-Komponente, die eine Kurzinfo relativ zu einem anderen Element auf der Seite anzeigen soll, werden beispielsweise wahrscheinlich zwei Phasen verwendet. In der EarlyRead-Phase werden zuerst die Größe und Position der Elemente erfasst:

afterRender(() => {
    targetRect = targetEl.getBoundingClientRect();
    tooltipRect = tooltipEl.getBoundingClientRect();
  }, { phase: AfterRenderPhase.EarlyRead },
);

In der Write-Phase wird dann der zuvor gelesene Wert verwendet, um die Kurzinfo neu zu positionieren:

afterRender(() => {
    tooltipEl.style.setProperty('left', `${targetRect.left + targetRect.width / 2 - tooltipRect.width / 2}px`);
    tooltipEl.style.setProperty('top', `${targetRect.bottom - 4}px`);
  }, { phase: AfterRenderPhase.Write },
);

Durch die Aufteilung der Logik in die richtigen Phasen kann Angular die DOM-Manipulation für alle anderen Komponenten in der Anwendung effektiv in einem Batch ausführen und so für eine minimale Leistungsbelastung sorgen.

Fazit

Es gibt viele neue und spannende Verbesserungen am serverseitigen Rendering von Angular, mit dem Ziel, Ihnen die Bereitstellung einer hervorragenden Nutzererfahrung zu erleichtern. Wir hoffen, dass Ihnen die vorherigen Tipps dabei helfen, die Vorteile von Firebase in Ihren Anwendungen und Bibliotheken voll auszuschöpfen.