V 2.3 DOM Manipulation und Event Handling

Inhaltsverzeichnis

DOM

Lädt der Browser eine Datei und versucht diese als HTML-Datei zu interpretieren, baut er anhand der Daten im Speicher ein Document-Object-Modell (DOM) auf. Was schließlich im Browserfenster angezeigt wird, ist also nicht ein direktes Abbild der Datei, sondern ein Abbild dieses internen Speichermodells.

Dabei wird aus den Elementen in der HTML Datei eine große Baumstruktur aufgebaut, bei der jeder Knoten ein Objekt ist, welcher einen Teil der Struktur repräsentiert, z.B. einen Titel, einen Absatz oder ein Bild.

DOM Baum Beispiel

Erzeugen Sie eine einfache Textdatei mit der Endung “.txt” im Dateinamen und schreiben Sie einige Worte hinein, auch mit mehreren Leerzeichenfolgen, Umlauten und Tabulatoren. Laden Sie diese Datei im Browser und schauen Sie sich in den Entwicklertools die Seitenstruktur an (Tab links neben Console)

Es wird deutlich, dass ein html-Element enstanden ist und darin ein head-Element sowie ein body-Element. In letzterem ist irgendwo, wahrscheinlich in einem pre-Element, unser eigentlicher Text vergraben.

Ändern Sie die Endung in “.html” und laden Sie die Datei erneut. Was hat sich verändert?

Ein Skript kann das DOM manipulieren, darin Elemente verändern, hinzufügen oder löschen, der Browser kümmert sich automatisch um die Darstellung für den User.

DOM Manipulation

Hinweise und Antworten auf potenzielle Fragen:

Achtung: Die Begriffe Objekt, Element und Knoten können teilweise synonym verwendet werden, es ist aber Vorsicht geboten. ‘Alles’ in Javascript/TypeScript ist ein Objekt, auch etwas vom Typ number oder string. Ein Knoten ist ein Objekt mit speziellen Eigenschaften und Fähigkeiten, mit dem sich ein Graph aufbauen lässt. Ein Element wiederum ist ein spezieller Knoten, der Eigenschaften eines HTML-Elementes aufweist.

Sehen Sie hier einen Ausschnitt aus der DOM Klassenhierarchie:

Schaubild

Baumstruktur

Das DOM lässt sich als Graph mit Knoten, die mit Kanten verbunden sind, darstellen.

Suchen Sie die Klasse Node im Schaubild zur DOM-Hierarchie. Welche verwandtschaftlichen Beziehungen werden innerhalb der Klasse genutzt?

Diese Knoten enthalten die Kernfunktionalität zur Bildung des Graphen und damit des DOMs. Jeder Knoten kann auf einen anderen Knoten als parentNode verweisen und auf eine Liste von childNodes. Im DOM ist document der Wurzelknoten, der lediglich eine Referenz auf html in seiner Kinderliste hat. html referenziert über die Eigenschaft parentNode das document und hat in seiner Kinderliste Referenzen auf head und body. body wiederum referenziert html als Mutter bzw Vater und hat wieder verschiedene Kindreferenzen, je nach Inhalt der darzustellenden Seite. Damit ergibt sich eine Baumstruktur, die sich in der Tiefe immer weiter verästeln kann und mit Hilfe der Entwicklertools, wie oben bereits getan, leicht einsehen lässt.

Wählen Sie sich für ein besseres Verständnis des DOM aus Ihren eigenen vorangegangenen Arbeiten eine Seite aus und stellen Sie deren DOM grafisch dar.

DOM Elemente in TS ansprechen

Um ein HTMLElement in TS nutzen zu können, muss dieses zunächst aus dem Dokument herausgefunden werden. Das document ist ein globales Attribut des Browserfensters in dem unser Code ausgeführt wird.

Einige Elemente, welche pro Dokument nur einmal existieren, können direkt herausgezogen werden:

let head: HTMLHeadElement = document.head;
let body: HTMLElement = document.body;

Andere Elemente können durch diverse Selektoren herausgefiltert werden.

// ein (das erste) HTMLElement mit einer bestimmten id
document.getElementById("uniqueIdName");
// alle HTMLElemente als Liste mit einem Klasennamen
document.getElementsByClassName("className");
// alle HTMLElemente als Liste eines Typs (hier HTMLDivElemente)
document.getElementsByTag("div");
// das erste HTMLElement, das dem Selektor entspricht 
document.querySelector(".class > div.divClass");
// alle HTMLElemente als Liste, die dem Selektor entsprechen 
document.querySelectorAll(".class > div.divClass");

Der Queryselektor nutzt dabei die selbe Struktur wie CSS Definitionen.

Sobald die Elemente dann in einer Variablen gespeichert sind, können über deren Attribute und Methoden die Elemente verändert werden.

Elemente erschaffen

Um neue Elemente zu erschaffen, gibt es zwei Möglichkeiten.

Die einfache aber gefährliche Variante

Man kann über das .innerHTML Attribut die Textuelle Darstellung des HTMLs innerhalb des ausgewählten Elementes einfach mit einem String befüllen, welcher dann vom Browser analysiert und umgewandelt wird.

element.innerHTML = `<p>Ein neuer Paragraph an dieser Stelle.</p>`;
element.innerHTML += `<p>Und noch einer dahinter.</p>`;

Gefährlich ist diese Methode, weil man so schnell vorherige Inhalte überschrieben hat. Außerdem werden bei jeder Änderung des innerHTMLs immer alle Elemente aus dem DOM entfernt und das gesamte innerHTML neu interpretiert und aufgebaut. Dies ist nicht nur langsam, sondern löscht auch sämtliche Eventlistener welche an diesen Elementen angebracht waren. Zusätzlich ist das Anbringen solcher Listener umständlich.

Die aufwändigere aber sichere Variante

Über document.createElement("typ") lassen sich HTMLElemente auch programmatisch erzeugen. Diese können dann über ihre Attribute befüllt werden, über Methoden wie .appendChild() an andere Knoten angehängt werden, usw. Diese Vorgehensweise ist weitaus verboser als die oben gezeigte, umgeht dafür aber all ihre Probleme.

let p1: HTMLParagraphElement = document.createElement("p");
p1.innerText = "Ein neuer Paragraph an dieser Stelle.";
element.appendChild(p1);

Die Nutzung von innerText hat ähnliche Probleme wie innerHTML. Darum wäre es besser an dieser Stelle eine neue TextNode zu erschaffen.

let p1: HTMLParagraphElement = document.createElement("p");
p1.appendChild(document.createTextNode("Ein neuer Paragraph an dieser Stelle."));
element.appendChild(p1);
Typassertion

Oder auch “Lieber Typescript Compiler, ich bin mir sicher bei dem was ich hier tue”.

Wie in der letzten Woche unter dem Stichwort Polymorphie bereits erklärt, können Instanzen von Subklassen auch in Variablencontainer ihrer Superklasse gespeichert werden.

So können z.B. auch HTMLInputElemente in HTMLElementen oder sogar EventTargets gespeichert werden. Versucht man dann allerdings, auf die Subklassenspezifischen Attribute zuzugreifen, wird Typescript sich beschweren.

<input type="email" name="email" id="emailInput">
// Direkt als HTMLInputElement deklarieren 
let input: HTMLInputElement = document.getElementById("emailInput");
// ERROR: Type HTMLElement is not assignable to HTMLInputElement

// als HTMLElement deklarieren aber auf eine Input spezifisches Attribut zugreifen wollen
let input: HTMLElement = document.getElementById("emailInput"); //Kein Error
console.log(input.type);  // ERROR: Property "type" does not exist on type "HTMLElement"

Dises Problem kann umgangen werden, indem man dem Compiler mitteilt, dass man sich sicher ist, um welche Art von Subklasse es sich handelt, indem man diese in spitze Klammern <> vor das zu assertierende Objekt schreibt.

let input: HTMLInputElement = <HTMLInputElement> document.getElementById("textinput");
console.log(input.type); // "email"

So kann nicht nur der Compiler ruhig gestellt werden, sondern es kann auch die eigene Arbeit erleichtern, da mit der richtigen Typisierung auch mehr/bessere/korrekte Vervollständigungsoptionen in VSCode angezeigt werden.

Dies sollte nur genutzt werden, wenn Sie sich sicher sind, welchen Typen Sie zurück bekommen. Alternativ (und sicherer für die Produktion außerhalb dieser Veranstaltung) wäre eine Prüfung mit instanceof.

let input: HTMLElement = document.getElementById("textinput");
if(input instanceof HTMLInputElement){
  console.log(input.type);
}

// oder auch so
function a() {
  let input: HTMLElement = document.getElementById("textinput");
  if (!(input instanceof HTMLInputElement)) return;

  console.log(input.type);
}

DOM Untersuchen

Sie können das DOM untersuchen und sich dessen Eigenschaften ausgeben lassen:

DOM in Chrome untersuchen

Dom in Chrome untersuchen

DOM in Firefox untersuchen

DOM in Firefox untersuchen DOM in Firefox untersuchen DOM in Firefox untersuchen DOM in Firefox untersuchen

Daten in DOM Elementen speichern

DOM Elemente sind durch die DOM Klassenhierarchie klar definiert, und während JS zwar jegliche Modifikation von allen JS Objekten erlaubt, so ist das weder guter Stil noch in TS erlaubt.
Man könnte nun überlegen, da manche Eigenschaften wie die Attribute nicht geprüft werden, eigene Attribute zu setzen. Und während das funktioniert, so ist es doch wieder nur ein Hack. Der offizelle Weg ist die Nutzung von dataset.

HTMLElement.dataset ist ein Assoziatives Array und erlaubt es so, beliebige Key-Value Paare (strings) auf einem Element zu speichern.

let el: HTMLElement = document.querySelector("#myElement");
el.dataset.name = "Max Mustermann";

console.log(el.dataset.name) // "Max Mustermann"
console.log(el.dataset["name"]) // "Max Mustermann"

Die so hinzugefügten Daten werden im Inspektor als data-<key>=<value> angezeigt.

<div id="myElement" data-name="Max Mustermann"></div>

Ereignisse

Das DOM bietet zudem ein System für die Interaktion mit dem Nutzer: das Eventsystem. Es stellt äußerst bequem Informationen zu Ereignissen innerhalb der Anwendung zur Verfügung, ohne dass Kenntnisse der Hardware erforderlich sind. Das Betriebssystem und der Browser werten diese Ereignisse bereits aus und bringen die Informationen darüber in eine allgemeine Form.

Event-Objekt

Events sind spezielle Objekte, die Informationen über ein Ereignis tragen. Ein solches Ereignis kann ein Mausklick sein, ein Tastendruck, eine Berührung des Bildschirms, das Laden einer Datei oder die Beendigung einer Datenübertragung und vieles mehr.

Im DOM-Klassendiagram sind einige Ereignisklassen aufgeführt. Finden Sie sie und heraus, welche Informationen diese tragen.

Kurze Zusammenfassung von Events:

Target

In der Regel bezieht sich ein Ereignis auf ein bestimmtes Objekt. Zum Beispiel auf den Button, der angeklickt wurde, den Link, der berührt wurde, das Fenster, das den Ladevorgang abgeschlossen hat oder das Textfeld, das verändert wurde. Die Eigenschaft target des Event-Objektes stellt eine Referenz auf dieses Ziel-Objekt zur Verfügung.

Von welchem Typ ist target? schauen Sie im Klassendiagramm.
Objekte welcher Klassen / welches Typs können also targets sein?

Type

type ist eine simple Zeichenkette und gibt an, was für ein Ereignis beschrieben wird. Hier sind beispielsweise die Werte click, load, change, dragstart und viele weitere vordefiniert. Es ist aber auch möglich eigene, neue Ereignisse zu definieren.

Event-Handler

Handler sind Funktionen, die ein Ereignis auswerten. Der Umgang damit ist denkbar simpel.

Handler-Implementation

Um ein Ereignis auszuwerten, implementieren Sie einfach eine Funktion, deren Signatur diesem Muster entspricht:

function handlerName(_event: Event): void {
    ...
}

Die Funktion nimmt also einen Parameter vom Typ Event entgegen, im Beispiel trägt dieser Parameter den Namen _event. Auch der Name der Funktion ist frei wählbar, es ist aber zu empfehlen den Prefix “handle” oder abgekürzt “hnd” zu verwenden, z.B. “handleClick”, denn eine solche Funktion, die ein Event verarbeitet, nennt man Handler.

Listener-Installation

Damit das System weiß, bei welchem Ereignis welcher Handler aufgerufen werden soll, muss der Handler registriert werden. Dies erfolgt mit der Anweisung addEventListener(...), zum Beispiel so:

document.addEventListener("click", handleClick);

Der erste Parameter ist lediglich die Zeichenkette, die den Typ des Ereignisses beschreibt, der zweite eine Referenz zum Handler. Erhält das document-Objekt nun ein Event-Objekt vom Typ “click”, wird dieses an die Handler-Funktion handleClick weitergeschickt. Das document-Objekt horcht also jetzt in das System hinein, es wurde ihm hierfür ein “Ohr” installiert, ein sogenannter Listener.

Achtung: Ein häufiger Fehler in Javascript ist, statt der Referenz einen Funktionsaufruf zu implementieren, z.B. mit addEventListener("click", handleClick()). Die zusätzliche Klammer bewirkt, dass die Funktion bereits bei der Installation aufgerufen wird und deren zurückgeliefertes Ergebnis als Handler-Referenz installiert wird.

Oftmals findet man auch die folgende Schreibweise, gerade wenn man auf ältere Lösungen stößt:

window.onload = initPage;
document.onclick = handleClick;
element.onclick = handleClick;

Diese Vorgehensweise ist effektiv das Gleiche wie das Attribut im HTML zu setzen.

<h1 onclick="myFunction()">Lorem Ipsum</h1>

Diese Vorgehensweise ist aber veraltet und sollte darum nicht mehr verwendet werden! Sie hat gegenüber der neuen Methodik unter anderem den besonders wichtigen, klaren Nachteil, dass immer nur ein Listener jedem Objekt angeheftet werden kann und wenn ein anderer angehängt wird, wird der alte automatisch weggeworfen.

Wenn Sie in der Situation sind, dass Sie elementabhängige Übergabeparameter an die Funktion übergeben wollen, gibt es zwei schöne Möglichkeiten, dies mit der neuen Syntax zu lösen:

  1. Die Daten über dataset statt in den Funktionsaufruf direkt aufs HTML Element speichern und dann in der Funktion auslesen, siehe Daten in DOM Elementen speichern.
  2. Sich die Geltungsbereiche bzw den gespeicherten Kontext von JS zunutze machen, indem man die aufzurufende Funktion innerhalb der Kontextes (z.B. innerhalb der for-Schleife welche die Elemente generiert o.ä.) definiert. Komplizierter zu verstehen aber in vielerlei Hinsicht interessanter, auch weil es allgemein sehr mächtig ist. Siehe Scopes und Geltungsbereiche, Abschnitt “Weiterführende Informationen”.
Beispiel

Das Folgende dürfte das wohl primitivste Beispiel sein, dass wir mit dem Eventsystem darstellen können. Eventuell müssen Sie zum Testen dieses Codes das defer Attribut des script tags weglassen.

namespace L2_3_Load {
    window.addEventListener("load", handleLoad);

    function handleLoad(_event: Event): void {
        console.log(_event);
    }
}

Hiermit wird das window-Objekt, welches dem Browsertab entspricht in dem die Applikation läuft, angewiesen, die Funktion handleLoad aufzurufen, wenn ein “load”-Event ankommt, und ihr das zugehörige event-Objekt zu übergeben. handleLoad sorgt dann lediglich für die Darstellung des Objektes in der Konsole.

Event-Phasen

Nicht alle Ereignisse werden allen Objekten im System mitgeteilt. Es ist also nur sinnvoll dort Listener zu installieren, wo sie auch wirken können. Besonders interessant wird das Ganze bei Nutzerinteraktionen, die auf DOM-Objekten ausgeführt werden, wie beispielsweise der Klick auf einen Button. Solche Ereignisse werden nämlich in drei Phasen durch den DOM-Graphen durchgereicht.

Phase 1: Capture

Das Event-Objekt wird zunächst an das window übergeben. Von dort wandert es zum document, zum html, zum body und weiter in den Baum in Richtung des target.

Phase 2: Target

Wenn es vom Elternobjekt zum target gereicht wird, befindet sich das Event-Objekt in der Target-Phase.

Phase 3: Bubble

Schließlich steigt das Event-Objekt im Baum wieder auf, bis es erneut das window erreicht. Es steigt also wie eine Luftblase unter Wasser an die Oberfläche.

Listener-Options

Bei der Installation des Listeners können mit einem dritten Parameter noch Informationen zur Funktionsweise mitgegeben werden. Wird hier schlicht ein true mitgegeben, reagiert der Listener auf die Capture-Phase. Ansonsten, was üblicher ist, auf die Bubble-Phase. In jedem Fall reagiert er auf die Target-Phase.

CurrentTarget

Neben dem target trägt das Event-Objekt auch noch eine Referenz auf das Objekt, dessen Listener das Ereignis als letztes gehört hat. Mit currentTarget kann also ausgewertet werden, wo sich das Ereignis gerade im DOM befindet und bearbeitet wird.

Path

Den kompletten Pfad, den das Event durch das DOM nimmt, kann man im Attribut path einsehen oder per Skript durch die Methode composedPath() ermitteln.

Beispiel

Takeaways

Sie haben gelernt:

Typescript Dokumentation

https://www.typescriptlang.org/


?! Fragen und Antworten

(die Publikation der Zusammenfassung erfolgt nach dem Q&A-Termin)

Zusammenfassung von: <Nutzername>