Damit Click-Handler nach dem Ersetzen des DOM-Inhalts einer Ansicht weiterhin funktionieren, binden Sie einen Listener an ein übergeordnetes Element, das das Update übersteht. Diese Anleitung zeigt anhand eines kleinen, synthetischen Kachelauswahlelements, wie Sie dieses Muster in Safari auf einem Mac implementieren und überprüfen.

Die Aufgabe ist konkret: Bauen Sie den Inhalt einer JavaScript-Ansicht neu auf und sorgen Sie dafür, dass ihre Schaltflächen reagieren und die Auswahl erhalten bleibt. Sie benötigen grundlegende JavaScript-Kenntnisse und einen aktuellen Safari-Browser. Das Beispiel verwendet standardmäßige Browser-APIs und erfordert weder Framework noch Paketinstallation.

Warum funktionieren Klicks nach dem Ersetzen des DOM nicht mehr?

Ein mit addEventListener() registrierter Listener gehört zum Ereignisziel, an dem er registriert wurde. Wenn Sie innerHTML eines Containers zuweisen, wird das angegebene Markup geparst und seine Nachfahren werden ersetzt. Eine neu erstellte Schaltfläche übernimmt daher keinen Listener, der an der vorherigen Schaltfläche angebracht war, selbst wenn ID und Beschriftung übereinstimmen. Siehe die Referenz zu innerHTML von MDN und das Ereignis-Listener-Modell des DOM-Standards.

Entscheidend ist der Unterschied zwischen dem Ersetzen des Containerinhalts und dem Ersetzen des Containers selbst. Wenn Ihr Code den Container beibehält und nur seine untergeordneten Elemente neu aufbaut, kann ein Listener auf diesem Container bestehen bleiben. Wenn ein äußerer Render-Vorgang auch den Container ersetzt, müssen Sie einen überlebenden Vorfahren auswählen oder während der Initialisierung des neuen Containers einen Listener anbringen.

Bei gewöhnlichen Click-Ereignissen, die nach oben weitergereicht werden, kann ein übergeordnetes Element mithilfe der Ereignisdelegation Interaktionen seiner Nachfahren verarbeiten. Statt jede ersetzte Schaltfläche einzeln zu binden, binden Sie das stabile übergeordnete Element und bestimmen die relevante Schaltfläche, wenn das Ereignis eintrifft. MDNs Anleitung zur Ereignisweitergabe nach oben beschreibt dieses Muster.

Wo sollte der Listener sitzen?

Ermitteln Sie zunächst die genaue DOM-Grenze, die Ihr Render-Vorgang ersetzt. Wählen Sie dann das am besten geeignete möglichst nahe übergeordnete Element außerhalb dieser Grenze. Die folgende Entscheidungshilfe überträgt das dokumentierte Verhalten beim Ersetzen von Elementen und bei Listenern auf gängige Aktualisierungsmuster. MDN: replaceChildren(), DOM-Standard: EventTarget.

Ihre Aktualisierung Platzierung des Listeners Anforderung an den Lebenszyklus
Die untergeordneten Elemente einer vorhandenen Ansichts-Root neu aufbauen Klicks von dieser Root delegieren Einmal beim Erstellen der Root registrieren
Auch die Ansichts-Root ersetzen Von einem überlebenden äußeren Container delegieren oder die neue Root binden Die Registrierung der vorherigen Ansicht bereinigen
Schaltflächen mit individuellen Handlern erstellen Jede neue Schaltfläche während des Aufbaus binden Das Binden abschließen, bevor die neuen Steuerelemente verfügbar sind
Das gesamte Dokument neu laden Listener im neuen Dokument initialisieren Die normale Seiteninitialisierung erneut ausführen

Für das folgende Beispiel bleibt die Struktur eine Ansichts-Root mit Überschrift, Status, geordneter Auswahl von Kacheln und Aktualisierungsschaltfläche. Bei jedem Rendern werden diese untergeordneten Elemente in derselben Reihenfolge neu aufgebaut. Nur die Ansichts-Root, der Listener und der JavaScript-Zustand bleiben bestehen.

Eine vollständige, ersetzungssichere Ansicht in Safari erstellen

Bereiten Sie zunächst einen temporären Browser-Tab vor. Öffnen Sie in Safari auf Ihrem Mac Einstellungen…, wählen Sie Erweitert und aktivieren Sie Webentwickler-Funktionen anzeigen. Öffnen Sie den Web Inspector über Entwickler → Web Inspector einblenden oder drücken Sie Option-Command-I. Diese Schritte sind in WebKits Anleitung zum Aktivieren des Web Inspectors dokumentiert.

Rufen Sie about:blank auf, öffnen Sie den Web Inspector und wählen Sie Konsole. Fügen Sie das vollständige Programm unten in die Eingabeaufforderung ein und führen Sie es aus. WebKit dokumentiert, dass die Konsoleneingabe JavaScript im ausgewählten Ausführungskontext der inspizierten Seite ausführt. Verwenden Sie den leeren Tab, damit die Demonstration von Ihrer Anwendung getrennt bleibt.

Dies ist ein vollständiges Browserprogramm: Es erstellt sein eigenes DOM, definiert seine Beispieldaten, bindet die Ansicht ein und stellt eine Bereinigungsmethode bereit. Es ersetzt die untergeordneten Elemente der Ansicht mithilfe von replaceChildren(), das Knotenobjekte entgegennimmt und die vorhandene Menge untergeordneter Elemente ersetzt. MDNs Methodenreferenz beschreibt diesen Vorgang.

(() => {
  window.domClickDemo?.destroy();

  const root = document.createElement("section");
  root.id = "dom-click-demo";
  const controller = new AbortController();
  const items = [
    { id: "a", label: "Tile A" },
    { id: "b", label: "Tile B" },
    { id: "c", label: "Tile C" }
  ];
  const state = { selected: "None", refreshes: 0 };

  function makeButton(action, label, id) {
    const button = document.createElement("button");
    button.type = "button";
    button.dataset.action = action;
    if (id !== undefined) button.dataset.id = id;
    const caption = document.createElement("span");
    caption.textContent = label;
    button.append(caption);
    return button;
  }

  function render() {
    const heading = document.createElement("h2");
    heading.textContent = "Tile picker";
    const status = document.createElement("p");
    status.textContent =
      `Selected: ${state.selected}; refreshes: ${state.refreshes}`;
    const list = document.createElement("ul");
    for (const item of items) {
      const row = document.createElement("li");
      row.append(makeButton("select", item.label, item.id));
      list.append(row);
    }
    const refresh = makeButton("refresh", "Replace view content");
    root.replaceChildren(heading, status, list, refresh);
  }

  function handleClick(event) {
    if (!(event.target instanceof Element)) return;
    const button = event.target.closest("button[data-action]");
    if (!button || !root.contains(button) || button.disabled) return;

    if (button.dataset.action === "select") {
      const item = items.find(item => item.id === button.dataset.id);
      if (!item) return;
      state.selected = item.label;
    } else if (button.dataset.action === "refresh") {
      state.refreshes += 1;
    } else {
      return;
    }
    render();
  }

  root.addEventListener("click", handleClick, {
    signal: controller.signal
  });
  render();
  document.body.append(root);

  window.domClickDemo = {
    root,
    destroy() {
      controller.abort();
      root.remove();
    }
  };
})();

Der Listener wird außerhalb von render() registriert. Bei jeder Auswahl und Aktualisierung werden neue Schaltflächen erstellt, aber alle bleiben innerhalb der ursprünglichen Root. Auch das Zustandsobjekt befindet sich außerhalb des gerenderten Teilbaums. Daher setzt das Ersetzen des Statusabsatzes weder die ausgewählte Kachel noch die Anzahl der Aktualisierungen zurück.

Die verschachtelte Beschriftung ist beabsichtigt. Bei einem Klick auf den Text einer Schaltfläche kann ihr untergeordnetes Element das Ereignisziel sein. closest("button[data-action]") durchsucht das Ziel und seine Vorfahren nach dem passenden Steuerelement. So wird vermieden, dass ein Handler nur funktioniert, wenn die Schaltfläche selbst das Ereignisziel ist. Die Referenz zu closest() von MDN beschreibt die Suche und das Ergebnis null, wenn nichts gefunden wird.

Die Prüfung auf Zugehörigkeit zum Teilbaum sorgt dafür, dass das passende Steuerelement innerhalb der Grenze dieser Ansicht liegt. Der DOM-Standard definiert contains() darüber, ob der angegebene Knoten selbst oder einer seiner Nachfahren der Knoten ist. DOM-Standard: Node.contains().

Initialisierung, Rendering und Bereinigung getrennt halten

Dieses Beispiel hat drei klar abgegrenzte Aufgaben. Bei der Initialisierung werden die Root erstellt und ihr Listener registriert. Beim Rendering werden die untergeordneten Elemente der Root anhand des Zustands aktualisiert. Bei der Bereinigung werden der Listener und die Root entfernt. Diese Aufteilung ist der Lebenszyklusvertrag der Implementierung. Bewahren Sie bei der Anpassung an Ihre Anwendung die entsprechenden Aufgaben bei.

Der Listener erhält ein AbortSignal, und destroy() bricht seinen Controller ab. Dadurch wird die zugehörige Registrierung entfernt, wie in der Referenz zu addEventListener() von MDN dokumentiert. Wird das vollständige Programm erneut ausgeführt, wird zuerst die vorherige Demonstration bereinigt, sodass sich nicht mehrere Demo-Ansichten ansammeln.

Erstellen Sie nicht bei jedem Rendern einen neuen anonymen Listener. Wiederholte Ausdrücke für anonyme Funktionen erzeugen unterschiedliche Funktionsobjekte, sodass sich ihre Registrierungen auf einer überlebenden Root ansammeln können. Dieselbe Callback-Funktion, denselben Ereignistyp und dieselbe Einstellung für die Ereigniserfassung wiederzuverwenden verhindert doppelte Registrierungen. Die Bindung bei der Initialisierung zu belassen, macht die Zuständigkeit jedoch klarer. MDN: addEventListener(), DOM-Standard: Listener hinzufügen.

Wiederholtes Ersetzen, Auswahl und Bereinigung überprüfen

Testen Sie das Beispiel mit dieser Abfolge. Die erwarteten Ergebnisse ergeben sich aus den ausdrücklichen Zustandsaktualisierungen des Programms; sie sind keine Aussage darüber, dass ein Safari-Test durchgeführt wurde.

  1. Klicken Sie auf Tile B. Der Status sollte Selected: Tile B; refreshes: 0 lauten.
  2. Klicken Sie dreimal auf Replace view content. Der Status sollte Selected: Tile B; refreshes: 3 lauten.
  3. Klicken Sie auf den Text von Tile C. Der Status sollte sich in Selected: Tile C; refreshes: 3 ändern.
  4. Klicken Sie auf die Überschrift oder den Statusabsatz. Keiner der Werte sollte sich ändern.
  5. Führen Sie window.domClickDemo.destroy() in der Konsole aus. Die Demonstration sollte verschwinden.
  6. Führen Sie das vollständige Programm erneut aus. Eine einzelne neue Ansicht sollte mit Selected: None; refreshes: 0 erscheinen.

Die ersten drei Prüfungen decken das Ersetzen von Inhalten, den beibehaltenen Zustand und verschachtelte Klickziele ab. Die vierte prüft die Filterung von Aktionen. Die letzten beiden prüfen den Pfad für Bereinigung und Initialisierung. Zusammen testen sie den tatsächlichen Lebenszyklus, statt nur zu bestätigen, dass die anfänglichen Schaltflächen funktionieren.

Die JavaScript-Syntax des Programms wurde mit Node überprüft. Eine separate deterministische EventTarget-Prüfung bestätigte die Deduplizierung von Callbacks und deren Entfernung über ein Abbruchsignal. Das Rendern des Browser-DOM und die Interaktion in Safari wurden dabei nicht ausgeführt. Verwenden Sie die obige Abfolge, um sie auf Ihrem Mac zu überprüfen.

Was tun, wenn der Handler in Ihrer Anwendung weiterhin ausfällt?

Untersuchen Sie den ersten Punkt, an dem das Verhalten abweicht. Halten Sie das Beispiel weiterhin überschaubar, während Sie die Ersetzungsgrenze, den Ereignispfad und die Zustandsaktualisierungen Ihrer Anwendung überprüfen.

Symptom Zuerst prüfen Geeignete Korrektur
Klicks funktionieren bis zur Aktualisierung einer äußeren Ansicht Wurde auch die Root des Listeners ersetzt? Binden Sie die neue Root während der Initialisierung oder wählen Sie ein überlebendes übergeordnetes Element
Ein Klick trifft ein, aber es wird keine Aktion ausgeführt Entspricht das neue Steuerelement weiterhin dem Selektor und dem Aktionswert? Behalten Sie den Selektor des Steuerelements und den Aktionsvertrag bei
Klicks auf den Schaltflächentext verhalten sich anders Prüft der Handler nur das genaue Ziel? Ermitteln Sie das Steuerelement mit closest()
Ein Klick aktualisiert den Zustand mehrmals Sind mehrere Registrierungen oder direkte und delegierte Handler gleichzeitig aktiv? Übertragen Sie einem einzigen Initialisierungspfad die Zuständigkeit für die Aktion
Der Handler läuft, aber die Anzeige wird zurückgesetzt Wird der Zustand beim Rendern neu erstellt? Behalten Sie den erforderlichen Zustand außerhalb des ersetzten Teilbaums

Prüfen Sie außerdem die Ereignisweitergabe. Ein Handler eines Nachfahren, der stopPropagation() aufruft, kann verhindern, dass ein nach oben weitergegebenes Ereignis Ihren delegierten Listener erreicht. MDN erläutert sowohl das Unterbrechen der Ereignisweitergabe als auch die Ereignisdelegation. Prüfen Sie, warum die Weitergabe unterbrochen wird, bevor Sie die Phasen der Listener ändern, da sich dadurch auch die Ausführungsreihenfolge ändert.

Wenn Sie direkte Listener für Schaltflächen verwenden müssen, bringen Sie sie beim Erstellen der ersetzten Schaltflächen an. Eine einmalige Abfrage mit anschließendem Binden kann keine Knoten initialisieren, die erst bei späteren Render-Vorgängen erstellt werden: Registrierungen gehören zu ihren Ereigniszielen. Ebenso kann die Ereignisdelegation keinen Listener auf einer Root erhalten, die Ihr Renderer verwirft. Diese Grenzen ergeben sich aus dem Listener-Modell des DOM-Standards.

Für diese Aufgabe besteht die dauerhafte Lösung aus einer stabilen Ansichts-Root, einem delegierten Listener, einem außerhalb der gerenderten untergeordneten Elemente gehaltenen Zustand und einem expliziten Bereinigungspfad. Ermitteln Sie, welche Knoten jedes Update überstehen, platzieren Sie den Listener entsprechend und überprüfen Sie mehrere aufeinanderfolgende Ersetzungen, bevor Sie die Änderung in die größere Ansicht integrieren.

Quellen