Zum Inhalt springen

DOM und Events

Das DOM ist die Seite, wie der Browser sie im Speicher hält, ein Baum aus Elementen. JavaScript kann diesen Baum lesen und ändern. In Teil 2 baust du so eine erste SPA von Hand, bevor Vue dir diese Arbeit abnimmt. Die Beispiele dieser Einheit sind kleine HTML-Seiten. Mit «Ausführen» erscheinen sie unter dem Code, und die Ausgabe der Konsole steht darunter.

Lernziele

Nach dieser Einheit kannst du

  • Elemente mit querySelector finden und neue Elemente erzeugen,
  • Text sicher mit textContent setzen und erklären, warum innerHTML gefährlich sein kann,
  • mit addEventListener auf Klicks und Eingaben reagieren,
  • mit einem einzigen Listener viele Elemente bedienen und ein Formular ohne Neuladen verarbeiten.

document.querySelector nimmt einen CSS-Selektor und liefert das erste passende Element oder null. querySelectorAll liefert alle passenden Elemente. Mit document.createElement erzeugst du ein neues Element, das erst sichtbar wird, wenn du es mit append in den Baum einfügst.

list.html
<!doctype html>
<h1>Katalog</h1>
<ul id="catalog"></ul>
<script>
const names = ['Bohrmaschine', 'Zelt', 'Leiter']
const list = document.querySelector('#catalog')
for (const name of names) {
const entry = document.createElement('li')
entry.textContent = name
list.append(entry)
}
console.log(list.children.length)
</script>

Die Schleife erzeugt für jeden Namen ein <li>, setzt seinen Text und hängt es an die Liste. list.children enthält danach drei Elemente.

textContent setzt reinen Text. Mit innerHTML könntest du auch HTML einsetzen, doch das ist gefährlich, sobald der Text von Mitgliedern stammt. Wer in eine Bemerkung HTML mit Script schreibt, bringt sonst fremden Code in die Seite. Diese Lücke heisst Cross-Site Scripting, kurz XSS. Die Aufgabe «Einstieg» zeigt den Unterschied.

Klicks, Eingaben und andere Ereignisse heissen Events. Mit addEventListener hängst du eine Funktion an ein Element, und der Browser ruft sie bei jedem passenden Event auf.

events.html
<!doctype html>
<button id="reserve">Reservieren</button>
<p id="count">0</p>
<ul id="catalog">
<li data-id="1">Bohrmaschine</li>
<li data-id="2">Zelt</li>
</ul>
<script>
let reservations = 0
document.querySelector('#reserve').addEventListener('click', () => {
reservations += 1
document.querySelector('#count').textContent = String(reservations)
})
// One listener on the list handles clicks on every entry, also on entries added later
document.querySelector('#catalog').addEventListener('click', (event) => {
const entry = event.target.closest('li')
if (entry) console.log('Gewählt', entry.dataset.id, entry.textContent)
})
document.querySelector('#reserve').click()
document.querySelector('li[data-id="2"]').click()
</script>

Der erste Listener zählt die Klicks auf den Knopf. Der zweite hängt an der ganzen Liste. Ein Event steigt vom angeklickten Element im Baum nach oben, darum bekommt die Liste auch die Klicks auf ihre Einträge. event.target ist das Element, auf das wirklich geklickt wurde, und closest('li') sucht von dort aus den nächsten Eintrag nach oben. Dieses Muster heisst Event-Delegation. Ein Listener genügt für alle Einträge, auch für solche, die erst später dazukommen.

data-id ist ein eigenes Attribut, das du im Script über dataset.id liest. Werte aus dataset sind immer Text. Am Schluss löst das Script zwei Klicks selbst aus, damit in der Ausgabe etwas steht. Klick danach selbst auf den Knopf und auf die Einträge.

Ein Formular schickt seine Daten normalerweise an einen Server und lädt dann eine neue Seite. Eine SPA verhindert das mit event.preventDefault() und verarbeitet die Daten selbst.

form.html
<!doctype html>
<form id="request">
<label>Name <input name="name" required></label>
<label>Tage <input name="days" type="number" min="1" max="7" value="2"></label>
<button>Anfragen</button>
</form>
<p id="message"></p>
<script>
const form = document.querySelector('#request')
form.addEventListener('submit', (event) => {
event.preventDefault()
const data = new FormData(form)
document.querySelector('#message').textContent = `${data.get('name')}, ${data.get('days')} Tage`
console.log(document.querySelector('#message').textContent)
})
form.elements.name.value = 'Martina'
form.requestSubmit()
</script>

Der Listener hängt am submit-Event des Formulars, nicht am Klick auf den Knopf. So funktioniert das Absenden auch mit der Eingabetaste. FormData liest alle Felder mit einem name auf einmal, data.get('days') liefert den Wert als Text. Die Attribute required, min und max prüft der Browser selbst, bevor er das Event auslöst. requestSubmit sendet das Formular aus dem Script ab, wie es ein Klick auf den Knopf täte.

FehlerWas hilft
querySelector liefert null, weil das Script vor dem HTML läuft.Das Script ans Ende der Seite setzen oder type="module" verwenden, denn Module laufen erst, wenn das HTML gelesen ist.
Text von Mitgliedern wird mit innerHTML eingesetzt.textContent verwenden.
Das Formular lädt die Seite neu, und die Ausgabe verschwindet sofort.Im submit-Listener event.preventDefault() aufrufen.
Jeder Eintrag einer Liste bekommt einen eigenen Listener, und neue Einträge reagieren nicht.Einen Listener auf die Liste setzen und mit event.target.closest(...) den Eintrag finden.
addEventListener('click', handle()) ruft die Funktion sofort auf.Die Funktion selbst übergeben, ohne Klammern.

Aufgabe Einstieg

Ein Mitglied gibt als Bemerkung <img src=x onerror=alert(1)> Zelt ein. Zeig die Bemerkung in einem Absatz an, ohne dass daraus ein Element wird. Wie viele Kindelemente hat der Absatz danach?

Lösung
solution-1.html
<!doctype html>
<p id="note"></p>
<script>
const input = '<img src=x onerror=alert(1)> Zelt'
// textContent shows the text as it is, innerHTML would turn it into an element
document.querySelector('#note').textContent = input
console.log(document.querySelector('#note').children.length)
</script>

Die Ausgabe ist 0. textContent zeigt die Zeichen genau so, wie sie eingegeben wurden, auch die spitzen Klammern. Mit innerHTML entstünde ein <img>-Element, dessen Bild nicht lädt, und onerror würde den Code des Mitglieds ausführen.

Aufgabe Vertiefung

Ein Klick auf einen Eintrag der Liste markiert ihn als Favorit, ein zweiter Klick hebt die Markierung wieder auf. Verwende einen einzigen Listener auf der Liste und die Klasse favorite. Gib nach jedem Klick den Namen aus und ob der Eintrag gerade Favorit ist.

Lösung
solution-2.html
<!doctype html>
<ul id="catalog">
<li>Bohrmaschine</li>
<li>Zelt</li>
</ul>
<script>
const list = document.querySelector('#catalog')
list.addEventListener('click', (event) => {
const entry = event.target.closest('li')
if (!entry) return
entry.classList.toggle('favorite')
console.log(entry.textContent, entry.classList.contains('favorite'))
})
list.querySelector('li').click()
list.querySelector('li').click()
</script>

classList.toggle setzt die Klasse, wenn sie fehlt, und entfernt sie, wenn sie da ist. classList.contains fragt den Stand ab. Das return am Anfang des Listeners fängt Klicks ab, die zwar in der Liste, aber auf keinem Eintrag landen.

Aufgabe Knobelei

Baue eine Suche. Bei jeder Eingabe im Suchfeld zeigt die Liste nur die Namen, die den Suchtext enthalten, ohne Rücksicht auf Gross- und Kleinschreibung. Schreib dafür eine Funktion render(query), die die Liste jedes Mal neu aufbaut.

Lösung
solution-3.html
<!doctype html>
<input id="search" placeholder="Suchen">
<ul id="catalog"></ul>
<script>
const names = ['Bohrmaschine', 'Stichsäge', 'Zelt']
const list = document.querySelector('#catalog')
function render(query) {
list.replaceChildren()
for (const name of names) {
if (!name.toLowerCase().includes(query.toLowerCase())) continue
const entry = document.createElement('li')
entry.textContent = name
list.append(entry)
}
console.log(list.children.length)
}
const search = document.querySelector('#search')
search.addEventListener('input', () => render(search.value))
render('')
search.value = 'säge'
search.dispatchEvent(new Event('input'))
</script>

replaceChildren() ohne Argumente leert die Liste, danach fügt die Schleife die passenden Einträge ein. Das input-Event kommt bei jeder Änderung im Feld, nicht erst beim Verlassen. Am Schluss setzt das Script den Suchtext «säge» und löst das Event selbst aus, darum zeigt die Ausgabe zuerst 3 und dann 1. Zustand ändern und die Liste neu aufbauen, diese Arbeit nimmt dir Vue in Teil 3 ab.

Texte und Abbildungen stehen unter CC BY-SA 4.0, der Code unter der MIT-Lizenz und die Daten des Kurses unter CC0 1.0. Die Schrift der Abbildungen ist Kalam unter der SIL Open Font License 1.1. Die Quellen liegen auf GitHub.