mitmario.dev

Event-Delegation

JavaScript Im Browser 3 Min Lesezeit 3 BeispieleLektion 6 von 9

Das ist die wichtigste Lektion dieses Abschnitts, und sie löst ein Problem, das nach Lektion 11.7 zwangsläufig auftaucht.

Das Problem

Der Kurs zeichnet Listen aus Daten. Jeder Eintrag hat einen Knopf, und jeder Knopf soll etwas tun.

Der naheliegende Weg wäre, nach dem Zeichnen über alle Knöpfe zu laufen und jedem einen Listener zu geben. Das funktioniert genau einmal. Sobald die Liste neu gezeichnet wird, und das passiert nach jedem Filtern, Sortieren und Hinzufügen, sind die alten Elemente weg und die neuen haben keine Listener. Also muss man sie erneut setzen. Und daran denken. Jedes Mal.

Genau an dieser Stelle verlieren Anfänger den Überblick, und zwar nicht, weil es schwer wäre, sondern weil es eine Buchführung ist, die nie fertig wird.

Die Lösung

Ein Listener statt vieler
const liste = document.getElementById("liste");
const ausgabe = document.getElementById("ausgabe");

// EIN Listener, am gemeinsamen Elternteil.
liste.addEventListener("click", (event) => {
  // Wo genau war der Klick? Vielleicht daneben.
  const knopf = event.target.closest(".knopf");
  if (!knopf) return;

  const eintrag = knopf.closest("li");
  ausgabe.textContent = `gewählt: ${eintrag.dataset.id}`;
  console.log("Klick auf", eintrag.dataset.id);
});

// Die Alternative wären drei Listener gewesen, und bei
// dreihundert Einträgen dreihundert.
console.log("Klick auf die Knöpfe und daneben in die Liste.");

Ein Listener am gemeinsamen Elternteil. Der Eltern-Container wird nicht neu gezeichnet, nur sein Inhalt. Der Listener überlebt also alles.

Möglich ist das, weil Ereignisse aufsteigen: Ein Klick auf einen Knopf tief in der Liste kommt am Container an, mit event.target als Hinweis darauf, wo er herkam.

Im Handler steht dann immer dasselbe Muster:

Erst herausfinden, was gemeint war. event.target.closest(".knopf") sucht von der Klickstelle aus nach oben den nächsten passenden Vorfahren, wobei das Element selbst mitzählt.

Dann der frühe Ausstieg. Passt nichts, war der Klick daneben, und der Handler hört auf. Das ist die Zeile if (!knopf) return;, und ohne sie bricht der nächste Zugriff mit einem TypeError ab.

Dann die Arbeit. Über closest noch einmal nach oben zum Eintrag, und aus dessen data-id weißt du, welcher Datensatz gemeint war. Das ist der Grund, warum Lektion 11.7 jedem erzeugten Element seine Kennung mitgibt.

Warum closest und nicht target

Warum closest und nicht target
const liste = document.getElementById("liste");

liste.addEventListener("click", (event) => {
  // So nicht: target ist das, was genau unter dem Zeiger lag.
  // Triffst du das Wort Stern, ist das der span und nicht der Knopf.
  const naiv = event.target.classList.contains("knopf");
  document.getElementById("naiv").textContent = `naiv: Knopf getroffen? ${naiv}`;

  // So: von target aus nach oben suchen, das Element selbst
  // zählt dabei mit.
  const knopf = event.target.closest(".knopf");
  document.getElementById("richtig").textContent =
    `mit closest: Knopf getroffen? ${Boolean(knopf)}`;

  console.log("target war:", event.target.tagName, event.target.className);
});

console.log("Klick einmal auf das Wort Stern und einmal auf JavaScript.");

event.target ist das Element, das genau unter dem Zeiger lag. Bei einem Knopf mit einem Symbol darin ist das oft das Symbol und nicht der Knopf.

Ein event.target.classList.contains("knopf") ist deshalb ein Fehler, der zufällig manchmal funktioniert: Er trifft, solange man auf den Rand des Knopfes klickt, und verfehlt, sobald man das Symbol in der Mitte trifft. Das sind die unangenehmsten Fehler, weil sie in der eigenen Vorführung nie auftreten.

closest beantwortet stattdessen die Frage, die man wirklich hat: Liegt die Klickstelle irgendwo innerhalb eines Knopfes?

Der Fall, um den es geht

Was ein Listener je Eintrag nicht kann
const liste = document.getElementById("liste");
const einzeln = document.getElementById("einzeln");
const delegiert = document.getElementById("delegiert");
let nummer = 1;

// Zwei getrennte Ausgaben, damit du beide nebeneinander siehst.
// In eine gemeinsame geschrieben würde der delegierte Listener
// den anderen jedes Mal überschreiben, denn er läuft später.

// Der einzelne Listener: wird jetzt gesetzt, für das eine
// Element, das es jetzt gibt.
document.querySelector(".knopf").addEventListener("click", (event) => {
  einzeln.textContent = event.currentTarget.textContent;
});

// Der delegierte Listener: kennt gar keine Knöpfe, nur die Liste.
liste.addEventListener("click", (event) => {
  const knopf = event.target.closest(".knopf");
  if (!knopf) return;
  delegiert.textContent = knopf.textContent;
});

document.getElementById("mehr").addEventListener("click", () => {
  nummer = nummer + 1;
  const eintrag = document.createElement("li");
  const knopf = document.createElement("button");
  knopf.type = "button";
  knopf.classList.add("knopf");
  knopf.textContent = `Nummer ${nummer}`;
  eintrag.append(knopf);
  liste.append(eintrag);
});

console.log("Füg Einträge hinzu und klick sie an. Beim ersten Knopf ändern sich beide Zeilen, bei jedem neuen nur die untere.");

Im dritten Beispiel hängen beide Fassungen nebeneinander an derselben Liste.

Der einzelne Listener wurde beim Laden gesetzt, für den einen Knopf, den es damals gab. Er kennt jeden später hinzugefügten Knopf nicht und wird ihn nie kennenlernen. Klick erst „Erster”, dann füg einen Eintrag hinzu und klick den an: Die obere Zeile bleibt ab da stehen, die untere geht mit.

Der delegierte Listener kennt gar keine Knöpfe. Er kennt die Liste, und die ändert sich nicht. Ein Eintrag, der zehn Minuten später erzeugt wird, funktioniert genauso wie der erste.

Drei Dinge kommen damit auf einmal:

  1. Ein Listener statt vieler. Bei dreihundert Einträgen ein spürbarer Unterschied.
  2. Es gilt für alles, was noch kommt. Kein erneutes Verdrahten nach dem Neuzeichnen.
  3. Kein Aufräumen. Wird ein Eintrag entfernt, ist auch nichts zu lösen. Warum das zählt, ist Lektion 12.7.

Wo die Grenze liegt

Delegation setzt voraus, dass das Ereignis aufsteigt. Bei focus, blur, mouseenter und mouseleave tut es das nicht.

Für die ersten beiden nimmst du focusin und focusout, die steigen auf. Für mouseenter und mouseleave gibt es mouseover und mouseout, die dafür bei jedem Wechsel zwischen Kindelementen feuern, was man dann selbst abfangen muss.

Zum Mitnehmen

Ein Listener am Elternteil, darin event.target.closest(...). Das funktioniert auch für Elemente, die es beim Setzen des Listeners noch gar nicht gab.

Jetzt du

Basis Konto, kostenlos

Zu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.

Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.

Was in dieser Lektion steckt

  • Artikel mit 3 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe im Editor, direkt im Browser geprüft

    Öffnet sich mit dem Basis Konto.