Aber warum eigentlich ein Framework verwenden? Wozu das Projekt mit Dependencies vollstopfen, wenn ein paar elegante Zeilen Code für unsere Zwecke reichen?
]]>Aber warum eigentlich ein Framework verwenden? Wozu das Projekt mit Dependencies vollstopfen, wenn ein paar elegante Zeilen Code für unsere Zwecke reichen?
In seiner einfachsten Form ist Unit Testing nichts anderes als das Vergleichen von Annahmen mit der Ausgabe von tatsächlichen Code. In JavaScript gibt es dafür eine eingebaute Lösung: console.assert()
.
const starDestroyer = new StarDestroyer();
console.assert(starDestroyer !== null, 'Star destroyer exists');
console.assert(starDestroyer.speed >= 0, 'Star destroyer has speed property with positive Number');
console.assert(starDestroyer.faction === 'imperial', 'Star destroyer is always imperial');
Im Browser wie auch in Node.js erzeugt diese Methode eine Konsolen-Ausgabe, wenn die Annahme nicht zutrifft.
Wenn in ein und derselben Datei mehrere Testfälle geprüft werden sollen, ist eine klares Scoping von Variablen notwendig. So wird sichergestellt, das Ergebnisse aus einem Test nicht irrtümlicherweise im nächsten Test verwendet werden. In JavaScript ist die Erzeugung eines neuen Scopes jederzeit mittels {…}
möglich:
{
const starDestroyer = new StarDestroyer();
console.assert(starDestroyer !== null, 'Star destroyer exists');
console.assert(starDestroyer.speed >= 0, 'Star destroyer has speed property with positive Number');
console.assert(starDestroyer.faction === 'imperial', 'Star destroyer is always imperial');
}
// No starDestroyer here
Mehrere Tests können so in einer Datei abgehandelt werden.
Für automatisiertes Testing ist die Verwendung von console.assert
natürlich zu kurz gesprungen, denn hier kann nur durch Sichtkontrolle überprüft werden, ob alle Tests sauber durchgelaufen sind. Wichtig wäre es also, auf der Kommandozeile einen Exit-Code zu bekommen, der auf einen Fehler hinweist.
Assert
in Node.jsIn Node.js existiert dafür Assert
:
const assert = require('node:assert');
{
const starDestroyer = new StarDestroyer();
assert.ok(starDestroyer !== null, 'Star destroyer exists');
assert.ok(starDestroyer.speed >= 0, 'Star destroyer has speed property with positive Number');
assert.strictEqual(starDestroyer.faction, 'imperial', 'Star destroyer is always imperial');
}
Im Fehlerfall wird assert.AssertionError
geworfen, der auf der CLI einen auch für Automatisierungen wahrnehmbaren Exit-Code erzeugt.
Der Nachteil dieser Lösung ist gut erkennbar: Diese Art von Testing funktioniert nur in Node.js, aber nicht im Browser.
Gott sei Dank ist die Idee mit dem Werfen von Errors aber gar nicht so komplex. Und damit können wir uns eine kleine Funktion konstruieren, mit der wir ganz grundsätzliches Testing durchführen können:
const assertOk = (assertion, message = 'Assertion') => {
console.log((assertion ? "✅" : "💥") + " " + message);
if (!assertion) {
throw new Error(`"${message}" failed`);
}
};
Damit schlagen wir mehrere Fliegen mit einer Klappe:
Etwas aussagekräftiger könnte die Funktion sogar so aussehen:
const assertStrictEqual = (a, b = true, message = '') => {
message === '' || (message += " | ");
message += message = `'${a}' equals '${b}'`;
console.log((a === b ? "✅" : "💥") + " " + message);
if (a !== b) {
throw new Error(`"${message}" failed`);
}
};
Damit können nicht nur direkt Vergleiche durchgeführt werden, sondern auch gleich eine aussagekräftige Nachricht über den tatsächlichen Inhalt des Tests mit ausgegeben werden.
Wenn ihr ein Drop-In-Replacement für assert
von Node.js haben wollt, könnt ihr die beiden Funktionen in statische Methoden verwandeln:
const assert = {
/**
* Check if `assertion` is `true`. Throw Error on error.
* @param {Boolean} assertion
* @param {String} message
*/
ok(assertion, message = 'Assertion') {
console.log((assertion ? "✅" : "💥") + " " + message);
if (!assertion) {
throw new Error(`"${message}" failed`);
}
},
/**
* Check if `actual` strictly equals `b`. Throw Error on error.
* @param {any} actual
* @param {any} expected
* @param {String} message
*/
strictEqual(actual, expected, message = '') {
message === '' || (message += " | ");
message += message = `'${actual}' equals '${expected}'`;
assert.ok(actual == expected, message)
}
};
Oh, und wenn ihr eure Tests noch gruppieren wollt:
console.group('Testing StarDestroyer');
{
const starDestroyer = new StarDestroyer();
assert.ok(starDestroyer !== null, 'Star destroyer exists');
assert.ok(starDestroyer.speed >= 0, 'Star destroyer has speed property with positive Number');
assert.strictEqual(starDestroyer.faction, 'imperial', 'Star destroyer is always imperial');
}
console.groupEnd();
…erzeugt eine schön gruppierte Ausgabe eurer Tests.
Voilà! Eine Beispiel-Ausgabe:
GeoJson.Feature
✅ Type matches | 'Feature' equals 'Feature'
✅ Feature.properties.title | 'Test' equals 'Test'
✅ Feature.geometry.type | 'Point' equals 'Point'
✅ No id
GeoJson.FeatureCollection
✅ 'FeatureCollection' equals 'FeatureCollection'
✅ Bounding Box West | '6.5664576' equals '6.5664576'
✅ Bounding Box South | '51.04571' equals '51.04571'
✅ Bounding Box East | '1.53946' equals '1.53946'
✅ Bounding Box North | '58.109285' equals '58.109285'
✅ FeatureCollection | 'FeatureCollection' equals 'FeatureCollection'
✅ Two Features exist | '2' equals '2'
✅ Feature.type | 'Feature' equals 'Feature'
✅ Feature.properties.title | 'Sailing boat' equals 'Sailing boat'
Natürlich ersetzt diese sehr simple Herangehensweise nicht wirklich echte Unit-Test-Frameworks. So ist zum Beispiel der Vergleich von Objekt-Strukturen nicht möglich, das Testen von Exceptions oder auch die Verwendung von Mocking und ähnlichen Features. Aber zumindest senkt es die Hürde, überhaupt mit Testing zu beginnen. 😉
]]>24h-Uhren sind analoge Uhren, die einen feinen Unterschied zu herkömmlichen Uhren aufweisen. An Stelle von 12 Stunden pro Umdrehung des Stundenzeigers zeigen sie 24 Stunden an. Damit braucht also der Stundenzeiger einen kompletten Tag für einen Vollkreis.
Damit ist es möglich, die vierundzwanzig Stunden auf dem Ziffernblatt grafisch in Tag, Dämmerung und Nacht einzuteilen. Da diese sich in Bezug auf das Datum und die geografische Position verändern, brauchte die Uhr also auch diese Informationen. In meinem Fall habe ich eine Skala auf dem Stundenring angebracht, die sich für die Nacht dunkelblau, für die Dämmung mittelblau und für den Tag hellblau verfärbt.
Der Bau als Web Component erlaubt es nun, die Uhr mit relativ wenig HTML in eine beliebige Seite einzubauen. Unter der Github-Seite zur 24h-Uhr findet sich eine knappe Anleitung, der Einbau einer Web Component muss aber nicht komplizierter sein als:
<twentyfour-hours-clock></twentyfour-hours-clock>
Weitere Attribute erlauben es, die Component vorher mit Werten zu bestücken – die sich auch im Nachhinein mit JavaScript ändern lassen:
<twentyfour-hours-clock width="128" height="128" datetime="2011-10-10T14:48:00" longitude="auto" latitude="auto"></twentyfour-hours-clock>
Auch das Einfärben, Vergrößern und Verkleinern von Einzelteilen der Web Component sind mit CSS Custom Properties möglich:
<style>
twentyfour-hours-clock {
--color-watchhand: pink;
--color-night: #111111;
}
</style>
<twentyfour-hours-clock></twentyfour-hours-clock>
Die Web Component selber wurde mit TypeScript gebaut. Tatsächlich wäre auch der Bau direkt in Vanilla JavaScript möglich gewesen. Meine letzten Abenteuer in TypeScript sind aber so angenehme Erfahrungen gewesen, dass ich die Unterstützung bei der Typisierung in JavaScript nicht mehr missen möchte, und die Web Component in TypeScript gebaut habe. Inzwischen halte ich bei JavaScript die Verwendung von TypeScript für so hilfreich, dass ich auf ESLint zur Überprüfung meiner Programmierung bei Privatprojekten gerne verzichte.
Ein funktionierendes Beispiel für die fertige Uhr findet sich auf der Demonstrationsseite für die 24h-Uhr.
]]>addEventListener
genau durchlesen.]]>addEventListener
genau durchlesen.
Wie schon in dem Artikel „Event-Handling mit JavaScript – und ohne jQuery“ angemerkt, ist ein gutes Verständnis von addEventListener
sehr hilfreich, um sich in der eigenen JavaScript-Applikation keine Performance-Ärger einzubrocken – und gleichzeitig die Übersicht zu behalten, wenn die Anzahl der Listener schnell ansteigt.
Gehen wir davon aus, dass ihr klassen- bzw. objektorientiert arbeitet. Schnell werdet ihr auf die Herausforderung treffen, dass this
innerhalb des Listeners eine andere Bedeutung hat als außerhalb.
Natürlich könntet ihr mit .bind()
den Kontext neu setzen. Oder ihr könntet mit anonymen Arrow-Funktionen arbeiten, die aber den üblichen Nachteil von anonymen Funktionen bei Listenern mitbringen.
MDN's Anleitung für addEventListener
verrät uns, dass als Listener drei verschiedene Dinge eingetragen werden können:
null
handleEvent
-Methode verfügt.Die letzte Option erlaubt es uns, this
mit wenig Aufwand vorhersehbar einzusetzen.
Tatsächlich ist die Übergabe des gesamten Objekts als Listener und die Definition einer handleEvent
-Methode überraschend simpel:
// 1st example: Multiple event listeners attached,
// one handler
class App {
constructor() {
document.querySelectorAll('a, button').forEach(function(element) {
element.addEventListener('click', this);
});
}
handleEvent(event) {
// do something
}
}
Innerhalb von handleEvent
kann nun this
mit dem zu erwartenden Bezug auf das aktuelle Klassen-Objekt verwendet werden, während die für das Event notwendigen Eigenschaften (ebenfalls regulär) in dem Parameter event
stehen.
Ärgerlicherweise werden nun aber alle so registrierten Events mit ein und dem selben Listener bedient. Zum Glück verrät uns aber jedes Event, für welches Element es ausgelöst wurde – die sogenannte Event-Delegation. Eine Fallunterscheidung z.B. nach ID kann uns also helfen, die einzelnen Aktionen auseinander zu halten:
// 2nd example: Multiple event listeners attached,
// one handler with delegation per id
class App {
constructor() {
document.querySelectorAll('a, button').forEach(function(element) {
element.addEventListener('click', this);
});
}
handleEvent(event) {
switch (event.target.id) {
case 'example-1':
// do something
break;
case 'example-2':
// do something other
break;
}
}
}
…und aus dem Nachteil ist ein Vorteil geworden, da jetzt die einzelnen Event-Listenern nichts anderes als Fälle in einer switch
-Anweisung geworden sind.
Jetzt können wir die Vielzahl der im DOM verteilten Event-Listener eigentlich auch gleich auf einen einzigen reduzieren, da der Listener sowieso nochmals überprüft, welches Element genau getroffen wurde. Dabei machen wir uns Event-Bubbling zu Nutze, und montieren an zentraler Stelle einen Event-Listener, der alle Interaktionen abfängt, und dann mittels Event-Delegation den korrekten Ansprechpartner auswählt.
Die zentralste Stelle ist natürlich… der <body>
. Damit erschlagen wir übrigens gleich den Fall, dass nachträglich dynamisch DOM-Elemente auf der Seite hinzugefügt werden. Denn der auf dem <body>
montierte Listener fängt natürlich auch erst nachträglich im DOM montierte Elemente ab.
// 3rd example: Single event listeners attached,
// one handler with delegation per id
class App {
constructor() {
document.body.addEventListener("click", this);
}
handleEvent(event) {
switch (event.target.id) {
case 'example-1':
// do something
break;
case 'example-2':
// do something
break;
}
}
}
Noch weiter abstrahieren lässt sich das Delegations-Beispiel, wenn wir vorher die unterschiedlichen Event-Typen auseinandersortieren:
// 4th example: Single event listeners attached,
// one handler triggers subhandlers per event type
class App {
constructor() {
document.body.addEventListener("input", this, true);
document.body.addEventListener("click", this, true);
document.body.addEventListener("change", this, true);
}
handleEvent(event) {
// @see https://developer.mozilla.org/en-US/docs/Web/API/Event
switch (event.type) {
case 'click' : this.handleEventClick(event); break;
case 'input' : this.handleEventInput(event); break;
case 'change': this.handleEventChange(event); break;
}
}
handleEventClick(event) {
switch (event.target.id) {
case 'example-1':
// do something
break;
case 'example-2':
// do something
break;
}
}
handleEventInput(event) {
switch (event.target.id) {
case 'example-3':
// do something
break;
case 'example-4':
// do something
break;
}
}
handleEventChange(event) {
switch (event.target.id) {
case 'example-5':
// do something
break;
case 'example-6':
// do something
break;
}
}
}
Dieses Pattern lässt sich natürlich auch ohne Objekte einsetzen, und mit regulären Funktionsaufrufen durchführen. Dabei verliert es aber natürlich die Möglichkeit, this
ohne Gehirn- oder Code-Akrobatik zu verwenden.
Unser schönes Beispiel geht davon aus, dass wir die Auswahl der eigentlich durchzuführenden Aktion an der id
des Elements festmachen, dass das Event ausgelöst hat. Tatsächlich können wir das aber an jedes Attribut koppeln. Damit sind auch class
-Attribute mögliche Ziele.
<dialog>
<button class="modal-close">Close<button>
<label for="date">Input</label>
<input id="date" type="date" class="change-date" />
<button class="set-to-current">Set to current time</button>
</dialog>
Das erlaubt uns zum Beispiel, die erste Klasse eines Elements darüber entscheiden zu lassen, was getan werden soll:
// 5th example: Single event listeners attached,
// one handler triggers subhandlers per `class` attribute
class App {
constructor() {
document.body.addEventListener("input", this, true);
document.body.addEventListener("click", this, true);
document.body.addEventListener("change", this, true);
}
handleEvent(event) {
// Get first `class` of element
const handler = e.target.classList.item(0);
switch (handler) {
case 'modal-close' : this.handleModalClose(event); break;
case 'change-data' : this.handleChangeDate(event); break;
case 'set-to-current': this.handleSetToCurrent(event); break;
}
}
handleModalClose(event) {
// do something
}
handleChangeDate(event) {
// do something
}
handleSetToCurrent(event) {
// do something
}
}
Aber wie wäre es mit einem eigenen data
-Attribut, zum Beispiel in Form eines data-handler
?
<dialog>
<button data-handler="modal-close">Close<button>
<label for="date">Input</label>
<input id="date" type=="date" data-handler="change-date" />
<button data-handler="set-to-current">Set to current time</button>
</dialog>
In diesem Fall hängen wir an alle HTML-Elemente ein data-handle
, mit dem wir zum Beispiel auch direkt benennen können, mit welchem Handler gearbeitet werden soll:
// 5th example: Single event listeners attached,
// one handler triggers subhandlers per `data-handler` attribute
class App {
constructor() {
document.body.addEventListener("input", this, true);
document.body.addEventListener("click", this, true);
document.body.addEventListener("change", this, true);
}
handleEvent(event) {
// Bubble up to closest DOM element with `data-handler`
const handler = e.target.closest('[data-handler]')?.dataset.handler;
switch (handler) {
case 'modal-close' : this.handleModalClose(event); break;
case 'change-data' : this.handleChangeDate(event); break;
case 'set-to-current': this.handleSetToCurrent(event); break;
}
}
handleModalClose(event) {
// do something
}
handleChangeDate(event) {
// do something
}
handleSetToCurrent(event) {
// do something
}
}
Als angenehmer Nebeneffekt können wir uns auf die Elemente beschränken, die auch wirklich ein data-handler
-Attribut haben – oder im DOM nach oben wandern, bis wir ein Element gefunden haben, dass über ein solches Attribut verfügt.
.closest()
ist hier eine wirkliche Hilfe.
Gamepad
API auslesen – und nebenbei eure Gamepads, Joysticks, Schubkontrollen, Fußpedale und sonstige Controller testen.]]>Gamepad
API auslesen – und nebenbei eure Gamepads, Joysticks, Schubkontrollen, Fußpedale und sonstige Controller testen.]]>Gamepad
-API genau zu begutachten.]]>Gamepad
-API genau zu begutachten.
Die Gamepad
-API ist eine der Zutaten, die moderne Browser für die Spiele-Entwicklung bereit stellen. Neben Grafik (inklusive Virtual Reality), Sound und Socket-Verbindungen gibt es damit alles, was das Spiele-Entwickler-Herz begehrt.
Anleitungen für der Gamepad
-API gibt es viele: Allen voran die Dokumentation zur Gamepad API auf MDN lässt wenig Fragen offen. Ich habe mich aber in einen etwas speziellen Bereich vorgewagt. Inspiriert von einem Retro Flug-Simulator im Browser
habe ich mir die Frage gestellt: Wie sieht es aus, wenn ich einen Flugsimulator für den Browser baue, und dort Unterstützung für Joysticks, Schubkontrollen oder Fußpedale integrieren möchte?
Kurz gefasst: Gar nicht so übel.
Die Dokumentation zur Gamepad API auf MDN bespricht in den meisten Beispielen nur Gamepads. Der schöne Artikel „Using The Gamepad API In Web Games“ aus dem Smashing Magazine erklärt deutlich mehr die Verwendung von Joysticks & Co – aber auch hier bleiben ein paar Fragen offen.
Tatsächlich funktioniert die Gamepad API mit so ziemlich jedem USB/Bluetooth-Eingabegerät, das wie ein Gamepad angesprochen werden kann. Darunter fallen Joysticks, Schubkontrollen, Ruder-Pedale und so ziemlich jeder andere HOTAS-Controller, den Flugsimulator-Fans kennen.
Der Vorgang ist in JavaScript verblüffend einfach: Mit der JavaScript-Methode Navigator.getGamepads()
erhaltet ihr ein Array aller an eurem PC/Smartphone/sonstigen Endgerät angeschlossenen Controller.
Die Controller verraten folgendes über sich:
gamepad.id
: Das ist die ID des Eingabegeräts, die die meisten Browser in einen verständlichen Namen übersetzen – wenn auch jeder Browser ein etwas anderes Ergebnis ausgibt. 😖gamepad.buttons
: Dieses Array verrät euch, wie viele Buttons das Eingabegerät hat.gamepad.axes
: Dieses Array verrät euch, wie viele Achsen das Eingabegerät hat.gamepad.mapping
: Hier verrät der Browser mit dem Schlüsselwort „standard“
, ob der Controller auf ein Standard-Gamepad umbelegt werden konnte.gamepad.timestamp
: Der Zeitpunkt, an dem das letzte Mal eine Messung auf dem Gamepad durchgeführt wurde. Das wird später wichtig, um die Zeit zwischen den Messungen zu verfolgen, und damit z.B. die Dauer eines Tastendrucks zu erkennen.
Das Standard-Mapping ist eine große Hilfe für die Implementation von Gamepads in den Browser: Dabei wird die Achsen- und Button-Belegung des Gamepads auf ein erwartbares Layout eines Standard-Gamepads umgebogen. Die Belegung beinhaltet:
Der Haken ist, das ein und dasselbe Gamepad nicht von jedem Browser diesem Layout zugeordnet wird. So wird ein Xbox-Controller unter Firefox/Windows, Chrome/Windows und Chrome/Ubuntu als Standard-Controller erkannt, unter Firefox/Ubuntu dagegen nicht. 😖
Das kann fatale Auswirkungen haben: Ein und dasselbe Gamepad kann zum Beispiel im Standard-Mapping die analogen Trigger als Buttons mit Analogwerten melden, während der nächste Browser ohne Standard-Mapping die Buttons streicht und stattdessen zwei zusätzliche Achsen sendet (die dann auch noch die Reihenfolge durcheinander bringen). Oder das D-Pad wird nicht als Sammlung von vier Knöpfen sondern als zwei Achsen gemeldet.
Höchste Zeit also, einen Blick auf den lokalen Fuhrpark an HOTAS-Controller zu werfen. Mit dem selbstgebauten Gamepad API Test kann jedes beliebige Eingabegerät so dargestellt werden, wie der Browser dieses Gerät auswertet.
Ziemlich schnell zeichnet sich dabei ein Muster ab. Jeder Joystick bietet verlässlich folgendes an:
Bereits dahinter scheiden sich die Geister. In den meisten Fällen könnt ihr euch noch knapp auf die folgende Konvention verlassen:
Interessanterweise lassen es sich Joystick-Hersteller nicht nehmen, einige Achsen zu überspringen. Ein vermeintlicher 5-Achsen-Joysticks kann beim Auslesen der API trotzdem neun Achsen senden, wenn der Hersteller des Joysticks das für eine gute Idee hält.
Weitere Eingabegeräte wie Schubkontrollen versuchen sich, auch an diese Konvention zu halten. Hier ist aber deutlich mehr Glück gefragt.
Ganz verrückt sieht es dann mit Fußpedalen aus, die über gar keine Buttons, aber dafür eine Achse für die Verschiebung der Füße und zwei Achsen für die jeweiligen Pedale haben.
Den Zustand des Gamepads und all seiner Achsen und Buttons zu kennen ist für euer Spiel oder eure Simulation nicht immer hilfreich, denn ihr bekommt die absoluten Roh-Daten des Controllers. Diese solltet ihr in der Regel in ein besser handhabbares Format umwandeln.
Die Programmier-Beispiele in diesem Artikel sind natürlich sehr simpel gehalten; in einer echten Umgebung kann deutlich mehr Abstraktion hilfreich sein.
Analoge Eingabeinstrumente haben in der Regel kleine Ungenauigkeiten. Manchmal zentrieren sie nicht richtig, manchmal flattern die Ausgabewerte etwas. Um diese kleinen Fehler zu korrigieren werden sogenannte dead zones eingerichtet.
Dead zones oder dead bands ignorieren bestimmte Werte einer Achse, und schneiden diese ab. Somit kann zum Beispiel eine leicht verkalibrierte Mitte eures Sticks trotzdem als 0
ausgelesen werden, oder ein Stick saubere -1
und +1
übermitteln, obwohl er aus Altersschwäche nur +0.9995
erreicht.
Ein fantastischer Artikel zu diesem Thema ist „Joystick input and using deadbands“ von Mimir Games. Einfach gesprochen brauchen wir zwei Arten von Todzonen:
innerThreshold
: Bei selbst zentrierenden Achsen sollte im einer kleinen Zone in der Mitte keine Eingabe genommen werden.outerThreshold
: Generell sollte das obere und untere Ende des Wertebereichs vorzeitig einen Vollausschlag erzeugen.Eine sehr einfache Funktion für diesen Zweck sieht wie folgt aus:
const axisDeadzone = function(axis, outerThreshold, innerThreshold = 0) {
if (innerThreshold > 0) {
const multiplier = (axis > 0 ? 1 : -1);
axis = Math.max(0, (Math.abs(axis) - innerThreshold) / (1 - innerThreshold)) * multiplier;
}
if (outerThreshold > 0) {
axis = Math.max(-1, Math.min(1, axis / (1 - outerThreshold)));
}
return axis;
}
Die Gamepad
-API liefert euch immer eine Momentaufnahme des Gamepads, die ihr so oft wie möglich abfragt. Der zeitliche Abstand zwischen diesen Abfragen ist nicht direkt vorhersehbar, sondern wird ebenfalls gemessen. Dafür übermittelt das Gamepad den gamepad.timestamp
.
Den Effekt einer Achs-Eingabe können wir einfach mit der Zeitspanne multiplizieren, der zwischen den letzten beiden Messungen vergangen ist; selbst wenn unser Gamepad etwas länger für die Messung braucht, ist der Effekt im Spiel trotzdem der selbe.
Zuerst sollten wir uns eine Variable anlegen, in der wir uns den letzten Timestamp merken können. In der Abfrageschleife (dem Game-Loop) können wir nun die Zeit zwischen den letzten beiden Updates ermitteln:
let lastTimestamp = 0;
let posX = 0;
let posY = 0;
function gameLoop () {
const gamepad = navigator.getGamepads()[0]; // this is just a stub ;)
// Find time elapsed since last gamepad update
const elapsedTime = lastTimestamp === 0
? 0
: timestamp - lastTimestamp;
// Store current timestamp for next time
lastTimestamp = timestamp;
// Move Pac Man / Thunderblade / Turrican / whatever relative to elapsed time
posX += gamepad.axes[0] * elapsedTime;
posY += gamepad.axes[1] * elapsedTime;
window.requestAnimationFrame(gameLoop); // Restart loop
}
Buttons klingen trivial, sind es aber nicht. Die Gamepad
-API übermittelt, ob ein Button gerade jetzt gedrückt wird. Ob er vorher schon gedrückt wurde und für wie lange, oder ob er in der aktuellen Messung das erste Mal gedrückt wurde – diese Information müssen wir uns selber erarbeiten.
Folgende Zustände sind für uns hilfreich:
pressed
: Der Button wird gerade gedrückt gehaltentriggered
: Der Button wird gerade zum ersten Mal gedrücktreleased
: Der Button wird gerade wieder losgelassenIn einem Skript könnte das wie folgt aussehen:
let lastButtonStates = [];
function gameLoop () {
const gamepad = navigator.getGamepads()[0]; // this is just a stub ;)
// Get button states
const buttonStates = gamepad.buttons.map((button, index) => {
return {
pressed: button.pressed,
triggered: lastButtonStates[index]
? (button.pressed && lastButtonStates[index].pressed !== button.pressed)
: button.pressed,
released: lastButtonStates[index]
? (!button.pressed && lastButtonStates[index].pressed !== button.pressed)
: false
}
});
// Have Pac Man / Thunderblade / Turrican / whatever do stuff
buttonStates[0].triggered && turrican.jump();
buttonStates[1].pressed && turrican.fire();
buttonStates[2].pressed && turrican.chargeSuperpower();
buttonStates[2].released && turrican.releaseSuperpower();
window.requestAnimationFrame(gameLoop); // Restart loop
}
Mit den Informationen aus triggered
und released
könnt ihr noch die Zeitdauer des Gedrückthaltens ableiten, und zum Beispiel auch in einem bestimmten Intervall bei gedrückter Taste Aktionen ausführen.
Einige unscheinbar Kontrollen an eurem Joystick sind in der Auswertung initial etwas kompliziert zu verstehen. Mit einer kleinen Trickkiste könnt ihr euch aber schnell Übersicht verschaffen.
Schubkontrollen sind eine ganz normale Achse wie zum Beispiel ein Joystick, mit dem Unterschied, dass sie sich nicht selber zentrieren. Sie haben den selben Wertebereich wie jede andere Achse auch. Der value
kann zwischen -1
..+1
liegen.
Dieser Wertebereich ist für eine Schubkontrolle in der Regel nicht so hilfreich, aber mittels einer einfachen Funktion auf den Bereich 0
..1
transformierbar:
const axisToThrottle = function(axis) {
return (axis + 1) / 2;
}
Einige Buttons (auch gerne Trigger genannt) haben nicht nur einen festen Auslösepunkt, sondern auch einen analoge Achse verbaut. So sind bei den meisten Gamepads die unteren Schultertasten als analoge Trigger ausgeführt. Dies erlaubt euch als Spieleentwickler genau zu messen, wie weit der Button gedrückt wurde.
Auf einem Standard-Gamepad wird dies am GampepadButton
als value
gemeldet:
gamepad.button[4] = {
pressed: true,
value: 0.4321
};
Der value
liegt dann zwischen 0
..1
.
Verflixt wird es, wenn das Eingabegerät nicht als Standard-Gamepad erkannt wird; denn tatsächlich ist der Trigger gar kein Button, sondern eine Achse:
gamepad.axes[3] = -0.1358;
…wobei der Wert zwischen -1
..1
liegt, womit er einen anderen Wertebereich umfasst als in seiner GampepadButton
-Form.
Eine einfache Transformation macht uns die Achse aber wieder zum Button:
const axisToButton = function(axis) {
return {
pressed: (axis > -1),
value: (axis + 1) / 2,
touched: (axis > -1)
};
}
Jedes Vier-Wege-Kreuz auf eurem Gamepad, Joystick oder Schubkontrolle kann eine kleine Überraschung für euch bereit halten. Tatsächlich gibt es mehrere Wege, wie sie in der Gamepad
-API auftreten können:
Wenn ein Vier- bzw. Acht-Wege-Kreuz als eine einzige Achse auftritt, hat der Hersteller sich etwas besonderes überlegt: Jeder Wert zwischen -1..+1
steht für Ausrichtung des Coolie hats zwischen 0°..315°. In Ruheposition sendet er dagegen einen unmöglich hohen Wert, den ihr verwerfen solltet.
Der value
auf der Achse beginnen mit -1
auf der 0°-Position. Pro 90°-Drehung erhöht sich der value
um 0.5715
, bis er auf 315° bei +1
endet.
Um aus diesem analogen Wert einen von acht Zuständen zu machen hilft eine kleine Funktion:
// 7 0 1
// 6 ∗ 2
// 5 4 3
const axisToEightWay = function(axis) {
return (axis > 1)
? undefined // centered state
: Math.round((axis + 1) * 7/2); // 0 means "top", 4 means "bottom"
}
Diese acht Zustände können auch in zwei Achsen umgerechnet werden:
const eightWaytoAxes = function(value) {
if (value === undefined) {
return [0,0];
}
let x = value % 4 ? 1 : 0;
x *= value > 4 ? -1 : 1;
let y = ((value +2) % 4) ? 1 : 0;
y *= value < 2 || value > 6 ? -1 : 1;
return [x,y];
}
Wenn euch vier Zustände reichen, braucht es eine etwas andere Formel:
// 0
// 3 + 1
// 2
const axisToFourWay = function(axis) {
return (axis > 1)
? undefined // centered state
: Math.round((axis + 1) * 7/4) % 4; // 0 means "top", 2 means "bottom"
}
Das Auslesen eines Joysticks hat jede Menge Fallstricke. Aus gutem Grund spekulieren Spielekonsolen und viele Spiele auf das Standard-Gamepad, um sich Ärger beim Mapping zu ersparen.
Andersherum stellt sich auch im Browser die Herausforderung, dass wir nicht bei jedem angeschlossenen Controller das genaue Mapping vorher kennen können. So oder so werdet ihr nicht darum herum kommen, dem Spieler einen Konfigurationsdialog anzubieten, mit dem die Zuordnung von Spielfunktion zu Controller-Button/Achse festgelegt werden kann. Bei der Speicherung des Mappings kann die Web Storage API weiterhelfen, damit der Aufwand nur einmalig anfällt.
Ein Mapping könnte jedenfalls wie folgt aussehen:
const mapping = {
buttons: {
radio: 2,
flapsUp: 4,
flapsDown: 6,
trimLeft: 7,
trimRight: 5
},
axes: {
roll: 0,
pitch: 1,
throttle: 3,
yaw: 4,
freeLook: 9
}
}
Ein Zugriff auf die konfigurierten Achsen ist damit auch deutlich übersichtlicher:
let roll = 0;
let pitch = 0;
function gameLoop () {
const gamepad = navigator.getGamepads()[0]; // this is just a stub ;)
// ...
roll += gamepad.axes[mapping.axes.roll] * elapsedTime;
pitch += gamepad.axes[mapping.axes.pitch] * elapsedTime;
window.requestAnimationFrame(gameLoop); // Restart loop
}
Wichtig für die Konfiguration von Achsen ist es, die genaue Art der Achse zu kennen:
In den meisten Fällen werdet ihr nur wenige absolute Achsen in einem Spiel benötigen. Beim Mapping ist es aber sinnvoll, für einen über eine absolute Achse einstellbaren Wert auch ein Mapping für relative Achsen anzubieten. Als Beispiel kann die Schubkontrolle dienen (die in einer Simulation ihren Wert 1:1 auf die simulierte Schubkontrolle überträgt), die auch durch einen kleinen Stick gesteuert werden könnte (was in der Simulation die simulierte Schubkontrolle nach oben oder unten schiebt).
Nicht zuletzt müsst ihr beim Mapping berücksichtigen, dass die Anzahl der vorhandenen Achsen und Knöpfe mit dem Eingabegerät des Nutzers nicht abdeckbar ist. Hier bieten sich sogenannte Modifier an: Ähnlich wie die Shift-, Alt- und Strg-Taste die Bedeutung einer Taste auf der Tastatur verändern kann, kann ein gedrückt gehaltener Button die Bedeutung der Achsen und Buttons eines Controllers verändern.
Das Mapping-Objekt für Modifier sowie der Konfigurationsdialog sind natürlich deutlich komplexer, können aber selbst Retro-Controller mit wenigen Achsen mit einer erstaunlichen Vielzahl von Funktionen belegen.
Game Controller im Browser mittels der Gamepad
-API anzusprechen ist nicht kompliziert – die Interpretation der Werte und Berücksichtigung der verschiedenen Controller hingegen schon. Ein robustes Handling im JavaScript erspart euch und euren Nutzern unschöne Erlebnisse.
Die einzelnen Skripte aus diesem Artikel gibt es übrigens als kompletten GamepadHelper
zum Download. Diese Bibliothek kann eine gute Grundlage für ein eigenes Mapping sein.
Markdown hat eine oft gar nicht benötigte Eigenschaft: Inline-HTML. In Markdown auftretendes HTML wird nach dem Umwandeln von Markdown in HTML vollkommen unverändert wieder ausgegeben. Also wird folgendes Markdown…
This **Markdown example** will <i>output HTML tags</i> as well.
…zu folgendem HTML:
<p>This <strong>Markdown example</strong> will <i>output HTML tags</i> as well.</p>
Wir erinnern uns: Web Components sind durch JavaScript-Bibliotheken definierte HTML-Tags, die mit ihrem eigenen Verhalten und Layout versehen sind. So kann man Web Components wie HTML im Markdown verwenden, z.B. word-count
:
<word-count>
Lorem ipsum _dolor_ sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et _dolore magna_ aliquyam erat, sed diam voluptua.
</word-count>
…wird damit zu…
<word-count>
<p>Lorem ipsum <em>dolor</em> sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et <em>dolore magna</em> aliquyam erat, sed diam voluptua.</p>
</word-count>
…was wiederum im Browser dann den obigen Absatz mit einem kleinen Wort-Zähler dahinter anzeigt.
Dieser Weg funktioniert unabhängig von der Art des eingesetzten Content Management Systems, solange das CMS redaktionelles Markdown verwendet. Für den Redakteur ist diese Lösung so einfach oder so kompliziert wie einen Wordpress-Shortcode einzusetzen.
Dazu muss die zu verwendende Web-Component natürlich vorher geladen sein. Dies kann entweder im Template mit einem simplen <script>
-Aufruf geschehen – oder (je nach Sicherheitseinstellung des CMS') auch direkt im Markdown des Artikels:
<script type="module" src="https://unpkg.com/@lrnwebcomponents/word-count@2.6.5/word-count.js?module"></script>
So oder so steht Redakteuren nun eine hinreichend bedienbare Methode zur Verfügung, in ihren Artikel Web Components zu verwenden. Alleine für diesen Zweck hatte ich schon ein paar Ideen, welche Web Components Redakteuren helfen könnten:
<twitter-tweet>
zum Einbetten von Tweets<video-embed>
zum Einbetten von Videos, zum Beispiel YouTube<map-embed>
zum Einbetten von Karten, wie z.B. Google Maps<pull-quote>
zum Anzeigen von aus dem Text herausgezogenen Zitaten<linkbox-category>
oder <linkbox-tag>
zum Einbetten einer Linkbox, die verwandte Artikel anzeigt<chess-board>
oder <bar-chart>
zum Darstellung von Spezialgrafiken wie einem Schachbrett oder Säulengrafiken (wir erinnern uns: Web Components können auch SVG-Grafiken anzeigen)Zu berücksichtigen wäre nur, dass einige ältere Browser Web Components nicht darstellen können. Das betrifft zum Beispiel auch einige RSS-Reader und E-Mail-Clients, so dass Artikel in diesem Umfeld nur eingeschränkt dargestellt werden. Das kann aber sogar erwünscht sein – denn dann wird das innerhalb des Web-Component-Tags verwendete Markdown bzw. HTML ausgegeben.
]]>Web Components eignen sich ganz hervorragend, um von der Darstellung und/oder Funktionalität her komplexe Aufgaben gekapselt und wiederverwendbar zu lösen. Zudem können Web Components (ähnlich wie <iframe>
, <object>
und viele andere neue Tags) in ihrem Inneren einen Fallback enthalten, so dass Browser ohne die Fähigkeit zur Darstellung von Web Components trotzdem etwas anzuzeigen haben.
Außerdem können Web Components dieses Fallback gleichzeitig via slot
-Mechanismus auswerten, um die Fallback-Inhalte entweder wieder anzuzeigen, oder aber aus dem HTML noch weitere Informationen zu ziehen.
Im Sinne des progressive enhancement könnte man also mit Web Components viele alltägliche Probleme lösen:
<twitter-status status="https://twitter.com/Interior/status/463440424141459456">
<blockquote>Sunsets don't get much better than this one over @GrandTetonNPS. #nature #sunset</blockquote>
<cite>US Department of the Interior</cite>
</twitter-status>
Diese Komponente zieht datenschutzzkonform Tweets von Twitter – und als Fallback braucht sie gar keine weiteren Requests, da der Content bereits auf der Seite zu sehen ist. 😉
<video-embed url="https://www.youtube.com/embed/G2dGWH90aew?autoplay=1">
<a href="https://www.youtube.com/embed/G2dGWH90aew?autoplay=1">Youtube-Video</a>
</video-embed>
Diese Komponente verwandelt mittels einfacher Magie den Link zu einem Video in eine Video-Einbettung. Redakteure werden es lieben, nicht mehr mit Embed-Codes herumzuhantieren, sondern einfach den Link zum Video aus der URL-Leiste ihres Browsers kopieren zu können – und trotzdem im fertigen Artikel ein eingebettetes Video zu sehen. (Ein Trick, den das Blogophon auch beherrscht.)
Mit lite-youtube
schon ähnlich umgesetzt: Mit dieser Komponente bekommt man eine deutlich datenschutzkonforme Darstellung eines eingebetteten Videos von YouTube, Viemo oder ähnlichem – ähnlich wie in diesem Blog YouTube-Videos in die Seite eingebettet werden.
<code-highlighted lang="javascript">
<pre>
<code>
…
</code>
</pre>
</code>
Mit dieser Komponente kann ich Code-Beispiel mit Syntax-Highlighting versehen. In dem lang
-Attribut wird dabei die verwendete Programmiersprache vermerkt.
<map-embed provider="google-maps" coordinates="53.246, 10.412">
<a href="https://www.google.de/maps/place/53°14'45.6"N+10°24'43.2"E">Die wunderschöne Stadt Lüneburg</a>
</map-embed>
Karten in redaktionellen Kontext sind immer wieder hilfreich, um Orte und ihre Umgebung darzustellen. Als Fallback kann immer noch ein Link zu einer Karten-Plattform angeboten werden.
<input-coordinates provider="google-maps">
<input name="coordinates" type="coordinates" step="0.001" value="53.246, 10.412" />
</input-coordinates>
Getreu meiner Idee, ein Eingabefeld für Geo-Koordinaten in HTML anzubieten, könnte diese Komponente eine Geo-Lokalisierung und Kartendarstellung beinhalten, um den Nutzer die Auswahl eines Standorts zu erlauben.
<textarea-special type="text/html">
<textarea name="html">
…
</textarea>
</textarea-special>
Wie lange haben wir schon auf einen einfach im Browser funktionierenden Rich-Text-Editor gewartet. Die Komponente ist sogar so schlau, das generierte HTML wieder in das über ihm liegende Formular zurückzugeben.
Theoretisch könnte sie ja aber auch ganz andere Dinge zur Bearbeitung anbieten. text/markdown
? text/csv
? text/yaml
? Warum nicht? Das könnte man alles über das type
-Attribut lösen.
Mit einigen kleinen Kniffen kann die Entwicklung solcher Komponenten noch schneller von der Hand gehen.
]]>Mit einigen kleinen Kniffen kann die Entwicklung solcher Komponenten noch schneller von der Hand gehen.
Für den Einstieg in das Thema Web Components empfiehlt sich die Lektüre der ausgezeichneten Einführung von Google und CSS-Tricks. Andere Anleitungen basieren teilweise auf älteren Ideen, die getrost ignoriert werden können.
Update 2020–09: Die Vielzahl der Wege, wie man eine Web Component bauen kann, hat webcomponents.dev veranlasst, eine Auflistung aller möglichen Wege zum Erstellen einer Web Component zusammenzustellen.
Außerdem lohnt es sich immer, moderne Beispiele anzuschauen. Ich für meinen Teil habe entsprechend versucht, einen Horizontal Situation Indicator als Vanilla-JavaScript Web Component mustergültig zu bauen. Der daraus resultierende Quellcode der „Horizontal Situation Indicator“ Web Component wird im Rahmen dieses Artikels immer wieder als Beispiel herangezogen.
Die größte Hürde für den angehenden Komponenten-Bauer ist das Verständnis, wie all die schönen Teile zusammenpassen.
Da eine Web Compoment gekapselt ist, kann nur über eine vorher definierte Schnittstelle von außen Zugriff auf ihr Verhalten genommen werden. Es ist also sehr sinnig, die Konzeption und den Bau einer Web Component wie den Bau einer Schnittstelle bzw. eines Interfaces zu verstehen.
Als Beispiel nehmen wir einfach die fertige Implementation der HSI-Web-Component:
<horizontal-situation-indicator id="hsi" heading="45.0" heading-select="0.0"></horizontal-situation-indicator>
Beim Bau einer Web Component müssen neben dem Namen der Web Component auch die Attribute der Web Component und ihre möglichen Werte definiert werden.
Diese Attribute verwandeln sich innerhalb der JavaScript-Repräsentation der Web Component in Properties, die mit den Attributen synchronisiert sind:
const el = document.getElementById('hsi');
el.getAttribute('heading'); // "45.0"
el.heading; // "45.0"
el.setAttribute('heading', '60.0');
el.getAttribute('heading'); // "60.0"
el.heading; // "60.0"
el.heading = '135.0';
el.getAttribute('heading'); // "135.0"
el.heading; // "135.0"
Bei dem Zugriff auf eine Property mit einem -
im Namen funktioniert der Zugriff leicht anders:
el.getAttribute('heading-select');
//el.heading-select existiert nicht
el['heading-select']; // korrekte Schreibweise in `[]`
Die Schreibweise mit []
erlaubt auch den dynamischen Zugriff auf Properties:
let attrName = 'heading-select';
el.getAttribute(attrName);
el[attrName];
Darüber hinaus kann der Entwickler einer Web Component noch festlegen, dass die Komponente JavaScript-Methoden anbietet. Diese erlauben zum Beispiel von außen der Komponente zu befehlen, komplexe Prozesse innerhalb der Komponente zu erledigen.
el.synchronizeHeading();
Da JavaScript keine Sichtbarkeiten wie public
und private
für Methoden hat, hat sich als Konvention herausgebildet, private Methoden mit einem _
zu beginnen.
Außerdem kann eine Web Component noch JavaScript-Events erzeugen, die außerhalb der Komponente registriert werden können. So emittiert z.B. das <video>
-Tag Events, wenn ein Video beendet wurde, was ohne dieses Event außerhalb des Tags keiner wissen könnte.
Web Components können fast alles darstellen, was auch regulär in einem Browser dargestellt werden kann. Besonderes Augenmerk muss aber darauf gelegt werden, dass alle benötigten Bestandteile in der einen JavaScript-Datei enthalten ist, die die Web Component definiert.
Am Einfachsten einzubinden sind die folgenden Dinge:
Alle anderen Ressourcen (Bilder, Videos, Töne) können mit einem Trick eingeschmuggelt werden: Mittels Data URLs können Binär-Dateien in Base64-Zeichenketten umgewandelt werden, die dann z.B. im src
-Attribut eines <img>
eingebunden werden können.
Besonders spannend: SVG-Bilder sind nicht nur schön kompakt in Bezug auf ihren Speicherplatz, sondern können auch direkt in das HTML eingebunden werden – benötigen also den Base64-Trick nicht.
In der Tat gibt es eine ganze Menge Tools, um das Zusammensetzen der einzelnen Teile einer Web Component zu unterstützen. In der Regel reicht aber ein kleiner flotter Node.js-Mehrzeiler als Web Component Build Tool, der aus einzelnen Dateien die eigentliche Web Component zusammensetzt. Die Kurzfassung:
const fs = require('fs');
let source = fs.readFileSync(`horizontal-situation-indicator.js`).toString();
let templateCss = fs.readFileSync(`src/horizontal-situation-indicator.css`).toString();
let templateSvg = fs.readFileSync(`src/horizontal-situation-indicator.svg`).toString();
source = source.replace(/(<style>).*(<\/style>)/ms, templateCss);
source = source.replace(/(<\/style>).*(`)/ms, templateSvg);
fs.writeFileSync(`horizontal-situation-indicator.js`, source);
Das tatsächliche Skript ist zwar etwas komplexer, das Grundprinzip ist aber ein denkbar einfaches: Die Entwicklung von SVG und CSS (oder jedem anderen Dateitypen) findet in separaten Dateien statt, die mit dem obigen Skript einfach in das JavaScript der Web Component hineinkopiert werden. Unter anderem könnte hier auch die Konvertierung von Binär-Daten in ihre Base64-Entsprechung durchgeführt werden.
get
und set
für jede Property abkürzen?Da jeder Web Component eine Liste der zu synchronisierenden Attribute / Properties mit der Methode observedAttributes
bekannt gemacht werden muss, kann genau diese Liste im constructor
auch zum programmatischen Erzeugern von Gettern / Settern verwendet werden.
this.constructor.observedAttributes.forEach((attrName) => {
Object.defineProperty(this, attrName, {
get() {
return this.getAttribute(attrName);
},
set(attrValue) {
this.setAttribute(attrName, attrValue);
}
});
});
Diese Methode hat in einigen Web-Components-Frameworks möglicherweise Nachteile – für die Vanilla-Nutzung ist sie aber weitestgehend ungefährlich.
Da Web Components sowieso nur in aktuellen Browsern zuverlässig funktionieren, kann man sich gleichzeitig auch auf fortgeschrittene CSS-Möglichkeiten verlassen. Um CSS innerhalb der Komponente von außen zu beeinflussen, verwende ich CSS-Variablen bzw. CSS-Custom-Properties. Innerhalb des CSS' des Komponente definiere ich sie direkt an der DOM-Wurzel der Komponente:
:host {
--background-color: black;
--foreground-color: white;
--heading-select-color: cyan;
--stroke-width: 0.5;
}
/*…und verwende diese CSS-Custom-Properties dann später in Variablen - bei mir z.B. als SVG-CSS-Eigenschaften:*/
#background {
fill: var(--background-color);
}
* {
fill: var(--foreground-color);
}
*[stroke] {
stroke-width: var(--stroke-width);
}
#heading-select {
fill: var(--heading-select-color);
}
Wer nun auch immer diese Komponente verwendet, kann diese CSS-Custom-Properties von außen beeinflussen:
horizontal-situation-indicator {
--heading-select-color: red;
--stroke-width: 1;
}
Bei der Beispiel-Implementation von <horizontal-situation-indicator>
kann auch bewundert werden, wie durch JavaScript diese CSS-Custom-Properties am lebenden Objekt verändert werden, und in der Komponente sich alles fröhlich verfärbt.
Ganz nebenbei haben wir für die Komponente eine weitere Schnittstelle geschaffen – in diesem Fall eine Styling-Schnittstelle.
Update: Andererseits können aber auch einzelne DOM-Knoten ohne CSS-Properties zum expliziten Styling freigegeben werden. Eine Anleitung zum Freigeben von DOM-Knoten aus dem Shadow-DOM zum CSS-Styling bei CSS-Tricks zeigt die notwendigen Anpassungen im HTML:
<div part="style-me">…</div>
…und dem CSS im Eltern-Dokument:
horizontal-situation-indicator::part(style-me) {
font-weight: bold;
}
Auch hier hat wieder der Autor der Komponente die Herrschaft über die Elemente, die er nach außen freigibt – wie bei einer Schnittstelle.
Der eigentliche Clou der HSI Web Component ist die generelle Fähigkeit von JavaScript, DOM-Elemente und ihre Eigenschaften zu verändern. Dies können sowohl CSS-Eigenschaften als auch generelle Attribute von DOM-Elementen sein.
Bei SVG bieten sich die folgenden Operationen an:
stroke
und stroke-width
zur Beeinflussung von Linienfill
zur Veränderung der Füllfarbeopacity
zur Veränderung der Durchsichtigkeit eines Elementstransform
<text>
-Knoten mittels .textContent
Bei SVG gibt es dabei die Möglichkeit, nicht nur via CSS diese Eigenschaften zu beeinflussen, sondern auch durch das Setzen von Attributen innerhalb des SVGs an einzelnen SVG-DOM-Knoten.
Auch das ist in der Beispiel-Implementation von <horizontal-situation-indicator>
zu bestaunen – hier sind die Attribute der Komponente mit Animationsmethoden verknüpft, so dass Änderungen an den Attributen bzw. Properties der Web Component zeitgleich die Darstellung des eingeschlossenen SVGs ändert.
Da eine Web Component im Endeffekt eine Schnittstelle ist, muss es dazu eine Schnittstellen-Dokumentation geben. Ohne diese Dokumentation können andere Entwickler, die die Komponente verwenden möchten, nicht zuverlässig wissen, wie die Komponente zu bedienen ist.
Als Minimum muss eine Dokumentation enthalten:
## Properties
| Name | Type | Default | Description |
| -------------- | ------- | ------- | ------------ |
| `heading` | `float` | `null` | Lorem ipsum… |
## Methods
| Name | Parameters | Description |
| -------------- | ---------- | ------------------- |
| `revHeading` | none | Lorem ipsum… |
## Events
| Name | Description |
| -------------- | -------------------------------- |
| `synchronized` | Lorem ipsum… |
## Styling
```css
component-name {
--background: color; /* Lorem ipsum… */
}
component-name::part(part-name) {} /* Lorem ipsum… */
```
Der fertige Horizontal Situation Indicator als Web Component ist in einem GitHub-Repository gelandet, und einen Blick auf die fertige Implementation der HSI-Web-Component erlaubt einen interaktiven Blick auf die Zusammenhänge in der Komponente.
]]>Aber das muss nicht sein – wie dieser Überblick über die Montage von Event-Handlern in JavaScript / jQuery zeigt.
]]>Aber das muss nicht sein – wie dieser Überblick über die Montage von Event-Handlern in JavaScript / jQuery zeigt.
Ganz grundsätzlich muss jeder Prozessschritt beim Hinzufügen eines Event-Handlers richtig angewendet werden. Der ganze Vorgang besteht aus drei Schritten:
In jedem dieser Schritte lässt sich zum Teil massiv optimieren.
Um einen Event-Handler montieren zu können, muss dieser an ein Element angekoppelt werden – in der Regel ist dies ein DOM-Element. Dazu gibt es verschiedene Methoden, DOM-Elemente zu selektieren. Je nach gewählter Methode ist dies mehr oder weniger performant.
Faustformel: Je eindeutiger das Suchmerkmal und je kleiner die Menge der zu durchsuchenden Elemente, desto schneller ist die Suche.
Die schnellste Methode ist dabei die Selektion über ein id
-Attribut – die langsamste dagegen die Suche nach einem beliebigen Attribut, im schlimmsten Fall mit der Prüfung, ob dieses Attribut einen bestimmten Wert beinhaltet.
Methode | jQuery | JavaScript | Neues JavaScript |
---|---|---|---|
ID | $('#x') |
document.getElementById('x') |
document.querySelector('#x') |
Klasse | $('.x') |
document.getElementsByClassName('x') |
document.querySelectorAll('.x') |
Tag | $('x') |
document.getElementsByTagName('x') |
document.querySelectorAll('x') |
Attribut | $('[x]') |
n/a | document.querySelectorAll('[x]') |
CSS (s.u.) | $('x y') |
n/a | document.querySelectorAll('x y') |
Sowohl die jQuery-Methode $(…)
als auch die JavaScript-Methoden .querySelector()
/ .querySelectorAll()
unterstützen die Auswahl per CSS-Selektor. Damit können auch kompliziertere Suchen im DOM durchgeführt werden, wie z.B. mit nav a
das Auffinden aller <a>
in einem <nav>
. Zum Glück ist die Browser-Unterstützung für .querySelector()
/ .querySelectorAll()
inzwischen sehr gut.
Zu beachten ist, dass die JavaScript-Methoden .querySelector()
/ .querySelectorAll()
je nach Browser um ein Mehrfaches langsamer sind als ihre „einfachen“ Geschwister .getElementById
, .getElementsByClassName
und .getElementsByTagName
.
Interessanterweise kann jede Methode nicht nur auf das gesamte Dokument angewendet werden, sondern auf eine bereits bestehende Selektion. Damit kann die Suche stark beschleunigt werden.
In jQuery existiert dafür die .find()
-Methode, die analog zu .on()
funktioniert…
var navigation = $('nav');
var navLinks = navigation.find('a');
var navBolds = navigation.find('b');
…in regulärem JavaScript bleiben die Methoden identisch zu den für die Suche im Dokument verfügbaren Methoden:
var navigation = document.querySelector('nav');
var navLinks = navigation.querySelectorAll('a');
var navBolds = navigation.querySelectorAll('b');
Diese Methode kann sehr hilfreich sein, wenn später sowieso DOM-Manipulation an übergeordneten DOM-Elementen notwendig werden.
Zu beachten ist bei regulärem JavaScript, dass die Methoden .getElementById()
und .querySelector()
ein einzelnes Element
bzw. einen einzelnen Node
(d.h. ein DOM-Element) zurückgeben, während alle anderen Selektions-Methoden eine HTMLCollection
bzw. NodeList
zurückgeben, die vereinfacht gesagt Arrays von Node
s sind.
Für das Hinzufügen von Event-Listenern bietet jQuery die Methode .on()
, und JavaScript die Methode .addEventListener()
an. (Wir ignorieren die Methoden zum Hinzufügen von Event-Handlern direkt via HTML-Attribut, da dadurch eine unglückliche Verkettung von Content (HTML) und Verhalten (JavaScript) entsteht.)
In beiden Fällen verfügt die Selektion über eine Methode, der man nur den Event-Typ und den eigentlichen Event-Listener übergeben muss.
$('nav').on('click', function() {
$(this).addClass('active');
})
Der selbe Aufruf ist in Vanilla-JavaScript etwas mehr Schreibarbeit, aber ansonsten identisch:
document.querySelector('nav').addEventListener('click', function(event) {
event.target.classList.add('active');
});
Zu beachten in JavaScript: Event-Listener können nur einem einzelnen DOM-Element hinzugefügt werden – jQuery erlaubt es, am Stück mehreren DOM-Elementen ein und denselben Event-Listener hinzuzufügen.
In beiden Fällen steht im Event-Listener mit $(this)
bzw. event.target
das DOM-Element direkt zur Verfügung, auf dem das Event ausgelöst wurde. Voraussetzung ist bei JavaScript, dass der Event-Listener als ersten Parameter eine Variable namens event
gesetzt bekommen hat.
Ein nicht unwahrscheinlicher Anwendungsfall ist, verschiedene Event-Typen mit dem selben Event-Handler bedienen zu wollen. In jQuery kann man an die .on()
-Methode eine Liste an verschiedenen Event-Typen übergeben:
// Fires on `click` `keyup` `blur`
$('nav').on('click keyup blur', function() {
$(this).addClass('active');
})
In JavaScript ist ein bisschen mehr Gehirnschmalz notwendig, denn hier müssen wir jeden Event-Listener mit einem einzelnen Aufruf hinzufügen. Das könnte man in einer Schleife tun…
// Bad example: Fires on `click` `keyup` `blur`
['click', 'keyup', 'blur'].forEach(function(eventType) {
document.querySelector('nav').addEventListener(eventType, function(event) {
event.target.classList.add('active');
});
});
…und handelt sich auf diese Weise zwei Performance-Killer ein: Einerseits wird in jedem Schleifendurchlauf document.querySelector
neu ausgewertet, andererseits wird jedes Mal Speicher für eine neue, anonyme Funktion reserviert. Glücklicherweise kann man beide Konstruktionen aus dem Schleifenkörper herausziehen:
// Better example: Fires on `click` `keyup` `blur`
var eventTarget = document.querySelector('nav');
var eventListener = function(event) {
event.target.classList.add('active');
};
['click', 'keyup', 'blur'].forEach(function(eventType) {
eventTarget.addEventListener(eventType, eventListener);
});
Das Array abgerollt sieht dann sogar noch übersichtlicher aus, und zeigt plastisch den Vorteil der vorherigen Deklaration von Event-Ziel und -Listener:
// Best example for readability: Fires on `click` `keyup` `blur`
var eventTarget = document.querySelector('nav');
var eventListener = function(event) {
event.target.classList.add('active');
};
eventTarget.addEventListener('click', eventListener);
eventTarget.addEventListener('keyup', eventListener);
eventTarget.addEventListener('blur', eventListener);
Wenn sich auf einer Seite mehrere DOM-Objekte befinden, die wir mit einem identischen Event-Handler ausstatten wollen, so gibt es in jQuery die folgende Methode.
// Add Event Handler to all `.btn`
$('.btn').on('click', function() {
$(this).addClass('active');
})
Besonders spannend: Bei .on()
und .querySelectorAll()
können auch mehrere CSS-Selektoren, durch Kommata getrennt, gleichzeitig abgefragt werden. Mit header a, footer a
kriegt man eine Liste aller <a>
in <header>
und <footer>
zurück.
In Vanilla-JavaScript wird das Hinzufügen zu Event-Listenern zu mehreren DOM-Elementen etwas umständlicher, weil ein Event-Handler immer nur einem DOM-Objekt hinzugefügt werden kann. Wenn wir also eine Liste von DOM-Objekten haben, müssen wir jedem DOM-Objekt beim Durchlaufen einer Schleife einen Handler verpassen:
// Add Event Handler to all `.btn`
document.querySelectorAll('.btn').forEach(function(btn) {
btn.addEventListener('click', function(event) {
event.target.classList.add('active');
});
});
Warum ist das in JavaScript eigentlich so deutlich weniger bequem? Der Grund ist ganz einfach: Diese Konstruktion hat massive Performance-Auswirkungen. Wir fügen damit eine größere Anzahl von Event-Handlern hinzu, die alle das Gleiche tun, aber unterschiedliche DOM-Objekte beobachten müssen. Damit muss der Browser mehr Dinge beobachten. Bei einer 10×10 Zellen umfassenden Tabelle kann ein Event-Listener für eine Tabellenzelle also auf insgesamt 100 Events verteilt werden.
Für diesen Fall bietet jQuery eine performante Alternative: Statt jedes Element einzeln mit einem Event-Handler auszustatten, wird einfach ein DOM-Objekt ausgewählt, dass im DOM oberhalb der zu beobachtenden DOM-Objekte liegt. Dieses übergeordnete Objekt wird über alle Events informiert, die unterhalb von ihm stattfinden – das sogenannte „Event Bubbling“.
In jQuery wird der Filter für das eigentliche Event-Ziel als zusätzlicher Parameter von .on()
übergeben.
// Add Event Handler to `nav`, fire if `.btn` was clicked
$('nav').on('click', '.btn', function() {
$(this).addClass('active');
})
In Vanilla-JavaScript wird das Event-Ziel innerhalb des Event-Listeners gefiltert:
// Add Event Handler to `nav`, fire if `.btn` was clicked
document.querySelector('nav').addEventListener('click', function(event) {
if (event.target.matches('.btn')) {
event.target.classList.add('active');
}
});
Kollege Malte wies mich noch darauf hin, dass ein Klick natürlich auch auf ein in dem eigentlich interessanten Element liegenden Element stattgefunden haben kann (zum Beispiel der Klick auf ein Bild, das in einem Link liegt, den wir überwachen wollen). Die Lösung dafür ist mit .closest()
einfach zu bewerkstelligen:
// Add Event Handler to `nav`, fire if `.btn` or element inside `.btn` was clicked
document.querySelector('nav').addEventListener('click', function(event) {
var targetBtn = event.target.closest('.btn');
if (targetBtn) {
targetBtn.classList.add('active');
}
});
Bezogen auf unser Beispiel mit den 100 Tabellenzellen haben wir gerade aus 100 Event-Handlern einen einzigen Event-Handler gemacht. Das spart nicht nur Speicher, sondern erlaubt es auch, auf DOM-Elemente zu reagieren, die beim Hinzufügen des Event-Handlers noch gar nicht im DOM existierten. Wenn zum Beispiel zu unserem <nav>
-Element erst nach dem Hinzufügen des Event-Handlers neue <… class=„btn“>
zum Beispiel via AJAX hinzugefügt werden, wird unser Event-Handler auf dem <nav>
auch diese Elemente mit verarbeiten, da er ja auf alle Elemente unterhalb von ihm reagiert.
Tatsächlich bemerke ich sowohl in der privaten als auch beruflichen Programmierung den Trend, für mehr Geschwindigkeit ein neues Tool einzusetzen… das kleine Probleme verursacht, die durch ein weiteres Tool gelöst werden müssen… das kleine Probleme verursacht, die durch ein weiteres Tool gelöst werden müssen…
Der Artikel dreht sich zwar primär darum, was diese Abhängigkeiten gerade für ältere Projekte bedeuten (nämlich, dass Abhängigkeiten nach ein paar Jahren sich nicht wieder auslösen lassen, weil die dafür benötigten Versionen an Tools nicht mehr zur Verfügung stehen), inzwischen bemerke ich aber auch bei aktuellen Projekten die Probleme, die übermäßige Abhängigkeiten für die Entwicklungsgeschwindigkeit bedeuten können, wenn auch nur ein Teil ausfällt.
]]>Tatsächlich bemerke ich sowohl in der privaten als auch beruflichen Programmierung den Trend, für mehr Geschwindigkeit ein neues Tool einzusetzen… das kleine Probleme verursacht, die durch ein weiteres Tool gelöst werden müssen… das kleine Probleme verursacht, die durch ein weiteres Tool gelöst werden müssen…
Der Artikel dreht sich zwar primär darum, was diese Abhängigkeiten gerade für ältere Projekte bedeuten (nämlich, dass Abhängigkeiten nach ein paar Jahren sich nicht wieder auslösen lassen, weil die dafür benötigten Versionen an Tools nicht mehr zur Verfügung stehen), inzwischen bemerke ich aber auch bei aktuellen Projekten die Probleme, die übermäßige Abhängigkeiten für die Entwicklungsgeschwindigkeit bedeuten können, wenn auch nur ein Teil ausfällt.
]]>