Sicherer Zugriff auf DOM mit Angular SSR

Gerald Monaco
Gerald Monaco

Im letzten Jahr hat Angular viele neue Funktionen eingeführt, darunter Flüssigkeitszufuhr und verzögerbare Aufrufe, mit denen Entwickler ihre Core Web Vitals verbessern und die Nutzerfreundlichkeit verbessern können. Wir arbeiten außerdem an weiteren Funktionen im Zusammenhang mit dem serverseitigen Rendering, die auf dieser Funktionalität aufbauen, z. B. Streaming und teilweise Hydratisierung.

Leider gibt es ein Muster, das möglicherweise verhindert, dass Ihre Anwendung oder Bibliothek all diese neuen und zukünftigen Funktionen optimal nutzen kann: die manuelle Bearbeitung der zugrunde liegenden DOM-Struktur. Bei Angular muss die DOM-Struktur vom Zeitpunkt der Serialisierung einer Komponente durch den Server bis zur Bereitstellung im Browser einheitlich bleiben. Wenn Sie ElementRef, Renderer2 oder DOM APIs verwenden, um Knoten manuell zum DOM hinzuzufügen, zu verschieben oder zu entfernen, bevor die Hydratisierung zu Inkonsistenzen führen kann, die verhindern, dass diese Funktionen funktionieren.

Allerdings ist nicht jede manuelle DOM-Manipulation und -Zugriff problematisch und manchmal auch notwendig. Der Schlüssel zur sicheren Verwendung des DOMs besteht darin, den Bedarf an DOM 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, mit denen Sie alle neuen und zukünftigen Funktionen von Angular optimal nutzen können.

Manuelle DOM-Manipulation vermeiden

Die beste Methode zur Vermeidung von Problemen, die durch manuelle DOM-Manipulationen entstehen, besteht darin, möglichst wenig zu übersehen. 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 Host-Element (d. h. das DOM-Element, das dem Selektor der Komponente oder der Anweisung entspricht) ändern, um beispielsweise eine Klasse, einen Stil oder ein Attribut hinzuzufügen, anstatt ein Wrapper-Element einzuführen oder ein Targeting vorzunehmen. 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 auch eine Bindung an Attribute und Stile herstellen und 'true' in einen anderen Ausdruck ändern, den Angular verwendet, um den Wert automatisch nach Bedarf hinzuzufügen oder zu entfernen.

In einigen Fällen muss der key dynamisch berechnet werden. Sie können auch an ein Signal oder eine Funktion binden, die eine Gruppe oder 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, manuelle DOM-Manipulationen vorzunehmen, um ein ExpressionChangedAfterItHasBeenCheckedError zu vermeiden. Stattdessen können Sie den Wert wie im vorherigen Beispiel an ein Signal binden. Das kann bei Bedarf erfolgen. Es müssen keine Signale aus Ihrer gesamten Codebasis übernommen werden.

DOM-Elemente außerhalb einer Vorlage ändern

Es ist verlockend, über das DOM auf Elemente zuzugreifen, auf die normalerweise nicht zugegriffen werden kann, z. B. Elemente, die zu anderen übergeordneten oder untergeordneten Komponenten gehören. Dies ist jedoch fehleranfällig, verstößt gegen die Kapselung und erschwert zukünftige Änderungen oder Upgrades dieser Komponenten.

Stattdessen sollte Ihre Komponente jede andere Komponente als Blackbox betrachten. Überlegen Sie gut, wann und wo andere Komponenten – selbst innerhalb derselben Anwendung oder Bibliothek – möglicherweise mit der Komponente interagieren oder deren Verhalten oder Darstellung anpassen müssen, und stellen Sie dann eine sichere und dokumentierte Möglichkeit zur Verfügung. Mit Funktionen wie der hierarchischen Abhängigkeitsinjektion können Sie einer Unterstruktur eine API zur Verfügung stellen, wenn einfache @Input- und @Output-Eigenschaften nicht ausreichen.

In der Vergangenheit wurden Funktionen wie modale Dialogfelder oder Kurzinfos häufig implementiert, indem ein Element am Ende von <body> oder einem anderen Hostelement 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 aussetzen

Nachdem Sie die vorherigen Richtlinien verwendet haben, um die direkte DOM-Manipulation und den direkten Zugriff so weit wie möglich zu minimieren, bleibt möglicherweise noch ein Teil übrig, der unvermeidbar ist. In solchen Fällen ist es wichtig, sie so lange wie möglich aufzuschieben. afterRender- und afterNextRender-Callbacks eignen sich hervorragend dafür, da sie nur im Browser ausgeführt werden, nachdem Angular auf Änderungen geprüft und sie im DOM festgeschrieben hat.

JavaScript nur im Browser ausführen

In einigen Fällen haben Sie eine Bibliothek oder API, die nur im Browser funktioniert (z. B. eine Diagrammbibliothek, bestimmte IntersectionObserver-Nutzung usw.). Anstatt bedingt zu prüfen, ob Sie im Browser ausgeführt werden, oder das Verhalten auf dem Server zu unterdrücken, 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 möglicherweise etwas in das DOM lesen oder schreiben, um ein Layout auszuführen, das von Ihrem Zielbrowser noch nicht unterstützt wird, z. B. das Positionieren einer Kurzinfo. afterRender ist hierfür eine gute Wahl, da Sie sicher sein können, dass sich das DOM in einem konsistenten Zustand befindet. afterRender und afterNextRender akzeptieren den phase-Wert EarlyRead, Read oder Write. Wenn das DOM-Layout nach dem Schreiben gelesen wird, wird der Browser gezwungen, das Layout synchron neu zu berechnen, was die Leistung stark beeinträchtigen kann (siehe überladenes Layout). Daher ist es wichtig, die Logik sorgfältig in die richtigen Phasen aufzuteilen.

Beispielsweise würde eine Kurzinfo-Komponente, die eine Kurzinfo relativ zu einem anderen Element auf der Seite anzeigen soll, wahrscheinlich zwei Phasen verwenden. In der EarlyRead-Phase wird zuerst die Größe und Position der Elemente ermittelt:

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

Dann würde die Phase Write den zuvor gelesenen Wert verwenden, 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 unserer Logik in die richtigen Phasen ist Angular in der Lage, die DOM-Manipulation für alle anderen Komponenten der Anwendung effektiv im Batch zu verarbeiten und so eine minimale Auswirkung auf die Leistung zu erzielen.

Fazit

Es gibt viele neue und spannende Verbesserungen am serverseitigen Angular-Rendering mit dem Ziel, die Nutzerfreundlichkeit zu verbessern. Wir hoffen, dass die oben genannten Tipps Ihnen dabei helfen, diese in Ihren Anwendungen und Bibliotheken optimal zu nutzen.