Sicherer Zugriff auf DOM mit Angular SSR

Gerald Monaco
Gerald Monaco

Im letzten Jahr hat Angular viele neue Funktionen hinzugewonnen, wie Flüssigkeitszufuhr und aufnehmbare Ansichten, mit denen Entwickler ihre Core Web Vitals verbessern und ihren Endnutzern ein optimales Erlebnis bieten können. Außerdem arbeiten wir daran, weitere Funktionen im Zusammenhang mit dem serverseitigen Rendering zu untersuchen, die auf dieser Funktionalität aufbauen, z. B. Streaming und teilweise Flüssigkeitszufuhr.

Leider gibt es ein Muster, das Ihre Anwendung oder Bibliothek möglicherweise daran hindert, all diese neuen und kommenden Funktionen vollständig zu nutzen: die manuelle Änderung der zugrunde liegenden DOM-Struktur. Angular erfordert, dass die DOM-Struktur von dem Zeitpunkt an, an dem eine Komponente vom Server serialisiert wird, konsistent bleibt, bis sie im Browser hydriert ist. Wenn Sie ElementRef, Renderer2 oder DOM APIs verwenden, um Knoten manuell im DOM hinzuzufügen, zu verschieben oder zu entfernen, bevor die Hydrierung zu Inkonsistenzen führen kann, die verhindern, dass diese Funktionen funktionieren.

Nicht jede manuelle DOM-Manipulation und -Zugriff sind jedoch problematisch und manchmal notwendig. Der Schlüssel zu einer sicheren Verwendung des DOMs besteht darin, den Bedarf so weit wie möglich zu minimieren und die Nutzung dann so lange wie möglich aufzuschieben. 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 zukünftigen Funktionen von Angular voll nutzen können.

Manuelle DOM-Manipulation vermeiden

Der beste Weg, die Probleme zu vermeiden, die durch eine manuelle DOM-Manipulation verursacht werden, besteht natürlich darin, diese nach Möglichkeit vollständig zu vermeiden. Angular verfügt über integrierte APIs und Muster, mit denen die meisten Aspekte des DOMs bearbeitet werden können. Sie sollten diese verwenden, anstatt direkt auf das DOM zuzugreifen.

Eigenes DOM-Element einer Komponente ändern

Wenn Sie eine Komponente oder eine Anweisung schreiben, müssen Sie möglicherweise das Hostelement (das DOM-Element, das dem Selektor der Komponente oder Anweisung entspricht) ändern, um beispielsweise eine Klasse, einen Stil oder ein Attribut hinzuzufügen, anstatt ein Targeting oder ein Wrapper-Element einzuführen. Es ist verlockend, einfach nach ElementRef zu greifen, um das zugrunde liegende DOM-Element zu ändern. Stattdessen sollten Sie Hostbindungen verwenden, um die Werte deklarativ an einen Ausdruck zu binden:

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

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

In einigen Fällen muss der Schlüssel dynamisch berechnet werden. Sie können auch an 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, zur manuellen DOM-Bearbeitung zu greifen, um ein ExpressionChangedAfterItHasBeenCheckedError zu vermeiden. Stattdessen können Sie den Wert wie im vorherigen Beispiel an ein Signal binden. Dies kann nach Bedarf erfolgen. Es sind keine Signale in der gesamten Codebasis erforderlich.

DOM-Elemente außerhalb einer Vorlage ändern

Es ist verlockend, das DOM für den Zugriff auf Elemente zu verwenden, die normalerweise nicht zugänglich sind, beispielsweise auf solche, die zu anderen übergeordneten oder untergeordneten Komponenten gehören. Dies ist jedoch fehleranfällig, verstößt gegen die Datenkapselung und erschwert die zukünftige Änderung oder Aktualisierung dieser Komponenten.

Stattdessen sollten alle anderen Komponenten als Blackbox gewertet werden. Überlegen Sie, wann und wo andere Komponenten (selbst innerhalb derselben Anwendung oder Bibliothek) möglicherweise mit dem Verhalten oder dem Erscheinungsbild Ihrer Komponente interagieren oder diese anpassen müssen, und legen Sie eine sichere und dokumentierte Möglichkeit offen, dies zu tun. Verwenden Sie Funktionen wie das Einschleusen von hierarchischen Abhängigkeiten, um eine API für eine Unterstruktur verfügbar zu machen, wenn einfache @Input- und @Output-Attribute nicht ausreichen.

In der Vergangenheit wurden Funktionen wie modale Dialogfelder oder Kurzinfos häufig implementiert, indem ein Element am Ende des <body> oder eines anderen Hostelements hinzugefügt und dann Inhalte dorthin verschoben oder projiziert wurden. Stattdessen kannst du heutzutage aber wahrscheinlich stattdessen ein einfaches <dialog>-Element in deiner 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-Bearbeitung aufschieben

Nachdem Sie die vorherigen Richtlinien verwendet haben, um Ihre direkten DOM-Manipulationen und den Zugriff so weit wie möglich zu minimieren, haben Sie möglicherweise einige übrig, die unvermeidlich sind. In solchen Fällen ist es wichtig, den Vorgang so lange wie möglich zu verschieben. afterRender- und afterNextRender-Callbacks sind dafür sehr gut geeignet, da sie erst im Browser ausgeführt werden, nachdem Angular nach Änderungen gesucht und sie per Commit an das DOM übergeben 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 bestimmte IntersectionObserver-Nutzung. Anstatt bedingt zu prüfen, ob die Ausführung im Browser erfolgt, oder das Verhalten auf dem Server auszuschleusen, können Sie 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 in das DOM schreiben, um ein Layout auszuführen, das von Ihren Zielbrowsern noch nicht unterstützt wird, z. B. zum Positionieren einer Kurzinfo. afterRender ist dafür eine gute Wahl, da Sie sicher sein können, dass das DOM einen einheitlichen Zustand hat. afterRender und afterNextRender akzeptieren einen phase-Wert von EarlyRead, Read oder Write. Durch das Lesen des DOM-Layouts nach dem Schreiben wird der Browser gezwungen, das Layout synchron neu zu berechnen, was sich negativ auf die Leistung auswirken kann (siehe Layout-Thrashing). Daher ist es wichtig, Ihre Logik in die richtigen Phasen zu unterteilen.

Beispielsweise verwendet eine Kurzinfo-Komponente, die eine Kurzinfo relativ zu einem anderen Element auf der Seite anzeigen soll, wahrscheinlich zwei Phasen. Die Phase EarlyRead würde zuerst verwendet werden, um die Größe und Position der Elemente zu ermitteln:

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

Dann verwendet die Phase Write den zuvor gelesenen Wert, um die Kurzinfo tatsächlich 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 unserer Logik in die richtigen Phasen ist Angular in der Lage, DOM-Manipulationen über alle anderen Komponenten der Anwendung im Batch zu stapeln und so eine minimale Leistungseinbußen zu gewährleisten.

Fazit

Wir arbeiten derzeit an vielen neuen und spannenden Verbesserungen für das serverseitige Angular-Rendering, mit dem wir es Ihnen einfacher machen möchten, Ihren Nutzern eine optimale Erfahrung zu bieten. Wir hoffen, dass sich die bisherigen Tipps als hilfreich erweisen können, damit ihr sie in euren Anwendungen und Bibliotheken optimal nutzen könnt.