Fragen zu HTML5 und Co beantwortet 24 - HTML-Kommentare, Indexed DB, JavaScript-Regex, HTML5-Buch

Veröffentlicht am 3. Mai 2018

Nach längere Pause wird es mal wieder Zeit, Webtech-Fragen zu beantworten, die mich via E-Mail oder Twitter erreicht haben. An Fragen herrschte schon länger kein Mangel, ich kam bloß nicht zu schreiben … bis jetzt!

Falls euch euch Fragen zu HTML, CSS und JS unter den Nägeln brennen, dürft ihr sehr gern zu Vergrößerung meines Blogpost-Backlogs beitragen! Ich antworte immer zeitnah, es sind nur die Veröffentlichungen, die immer ein wenig Zeit brauchen.

Wo dürfen Kommentare in HTML stehen?

Wo dürfen in einer HTML5-Seite Kommentare vorkommen? Darf sich vor <html> ein Kommentar befinden?

In einem standardkonformen HTML-Dokument dürfen Kommentare an folgenden Stellen vorkommen:

  • direkt vor dem Doctype
  • direkt nach dem Doctype (also vor <html>)
  • innerhalb des <html>-Elements überall wo auch Text vorkommen kann, d.h. zwischen, vor und nach HTML-Tags
  • nach dem <html>-Element

Nicht erlaubt sind Kommentare also im Wesentlichen innerhalb eines Tags, so wie etwa hier:

<p <!--class="foo"--> ></p>

Das wäre ungültiges HTML, liefert trotzdem ein anzeigbares HTML-Dokument. Der Parser macht aus dem obigen Code <p>-Element mit einem Attribut „<!--class“ (mit dem Wert foo) sowie einem Attribut „--“ (ohne Wert). Die unerwartete Spitzklammer nach dem Tag-Namen läuft in der Parser-Logik als unexpected-character-in-attribute-name parse error und hat zur Folge, dass das Zeichen in den Attribut-Namen eingebaut wird. Am Ende erhält das <p>-Element noch „>“ als Text-Inhalt, denn die Spitzklammer für das Ende unseres geplanten „Kommentars“ wird als Ende des öffnenden <p>-Tags interpretiert.

Man sieht also: auch wenn das Markup ungültig ist, kommt am Ende ein Dokument mit vorhersagbarem Inhalt heraus. Der HTML-Parser ist eben ein ziemlicher Müllschlucker – egal was man hineinsteckt, er macht ein Dokument daraus.

Wie kann man Indexed DB verwenden?

Ich versuche gerade Indexed DB zu verstehen und habe dazu auch deine Artikel gelesen. Da ich allgemein mit JavaScript und der Webentwicklung noch nicht so fit bin, verstehe ich die Events nicht. Was genau passiert da und wie funktioniert das?

Willkommen im Club! Fast niemand versteht Indexed DB. Meiner Ansicht nach ist Indexed DB für sich genommen eine der unbrauchbarsten aller Browser-APIs ist, die seit mit HTML5 eingeführt wurden. Indexed DB ist sehr low-level, sehr kleinteilig, sehr komplizit und hat exakt null Komfortfeatures.

Allerdings kommt dieses Design nicht von ungefähr. Das Extensible Web Manifesto postuliert:

To enable libraries to do more, browser vendors should provide new low-level capabilities that expose the possibilities of the underlying platform as closely as possible.

Low-Level-APIs wie Indexed DB mögen für sich genommen nicht für Webapp-Programmierung zu gebrauchen sein, bieten aber ein Fundament für Libraries. Libraries lassen sich leichter implementieren und austauschen, um eine Vielzahl von Use Cases abzudecken. Standards sind wesentlich schwieriger zu entwickeln, weswegen man lieber einen Low-Level-Standard entwirft, auf dem sich ein Ökosystem von Libraries bilden kann, als dass man versucht, mit einem Webstandard alle möglichen Use Cases abzudecken.

Statt sich mit Indexed DB herumzuschlagen lohnt es sich viel mehr, Libraries auf Indexed-DB-Basis zu verwenden. Eine kleine Auswahl:

  • localForage bietet einen einfachen Key-Value-Store auf Basis von Indexed DB, inkl. Unterstützung für Promises
  • Dexie.js ist ein Komfort-Wrapper um Indexed DB mit vielen Features, aber auch vielen Erleichterungen und einer gut durchdachten API
  • PouchDB ist eine JS-Implementierung von Apache CouchDB und kann sich dank des Sync-Protokolls mit „echten“ CouchDB-Instanzen (und anderen kompatiblen Datenbanken) synchronisieren.

Wenn man im Browser Daten speichern möchte, sollte man dringend zu einer solchen Library greifen und gar nicht erst versuchen, Indexed DB selbst zu verwenden

Komische Arrays bei String.prototype.match()

Ich bin gerade auf eine Sache gestoßen die ich mir nicht erklären kann. Folgendes Beispiel:

var text  = "xxx";
var m1 = text.match("x");
var m2 = text.match(/x/g);

Bei beiden Aufrufen wird mir ein Array zurückgegeben. Beim ersten jedoch nur ein Ergebnis gefunden, bei dem anderen natürlich mehrere. Wenn nur ein Ergebnis gefunden wird, dann hat das Array scheinbar zusätzliche Properties (input und index):

m1: Array[1]
  0: "x",
  index: 0,
  input: "xxx",
  length: 1,
  __proto__: Array[0]
m2: Array[3]
  0: "x",
  1: "x",
  2: "x",
  length: 3,
  __proto__: Array[0]

Im ECMAScript-Standard wird das bei der Funktion RegExp.prototype.exec(string) auch so beschrieben.

Erstmal verstehe ich nicht ganz, wie auf einmal ein Array noch „geheime“ Extra-Properties besitzen kann. Im Prinzip ist mir schon klar dass das Array auch nur ein Objekt ist und daher beliebige Properties bekommen kann, aber warum nutzt man dann ein Array? Und zum anderen … warum gibt es diese Properties nicht, wenn mehrere Ergebnisse gefunden werden? Es ist total verwirrend! Warum werden bei einem Ergebnis Properties hinzugefügt, die auf einmal nicht vorhanden sind, sobald es mehrere Ergebnisse gibt? Und warum werden überhaupt irgendwelche Properties auf ein Array gesteckt?

Ich würde das Phänomen so betrachten, dass String.prototype.match() unterschiedliche Fragen beantwortet, je nachdem, was für einen Regulären Ausdrück man hineinsteckt. Im Fall von m1 wird aus dem String ein „normaler“ Regex gebaut, bei m2 ist es ein Regex mit Global-Flag. Diese beiden Regulären Ausdrücke lassen die Funktion zwei unterschiedliche Fragen beantworten. Ohne Global-Flag wird ermittelt ob ein es ein Match gibt und wenn ja, wo dieses Match im String zu finden ist – daher die Property index. Technisch ist das so gelöst, dass String.prototype.match() an dieser Stelle an RegExp.prototype.exec() delegiert. Mit Global Flags werden hingegen alle Matches geliefert – und zwar die Matches selbst, nicht Informationen über die Matches. Das Verhalten ergibt sich allein allein aus dem Fehlen oder Vorhandensein des Global-Flags, nicht aus der Anzahl der sich ergebenden Ergebnisse.

Beispiel:

var text  = "xyx";
var m1 = text.match("y");  // > Ein Treffer MIT Extra-Properties
var m2 = text.match(/y/g); // > Ein Treffer OHNE Extra-Properties

Warum „mißbraucht“ man hier ein Array? Ich nehme an, dass man als Ergebnis von String.prototype.match() eine Liste von Ergebnissen haben wollte, und da gab es vor ECMAScript 6 nur die Wahl zwischen Pest und Cholera. Entweder man baut sich ein Pseudo-Array wie das berüchtigte Arguments-Objekt, dem all die praktischen Array-Methoden fehlen oder man erweitert eben ein Array, das alle Features hat – aber dann eben auch einiges, was ein Array nicht haben sollte. Das ist beides nicht optimal (und letzteres ist ein warnendes Beispiel dafür, warum man for-in-Schleifen nicht mit Arrays verwenden sollte). In ES6 würde man vermutlich ein normales Objekt bauen und einen Iterator implementieren, aber als damals String.prototype.match() eingeführt wurde war das eben noch nicht möglich.

Ist das HTML5-Buch noch aktuell?

Würdest du dein HTML5 Buch von 2010 bei einem Umzug selbst noch mal einpacken oder meinste dass 8 Jahre später die Entwicklung schon zu sehr weiter gegangen ist?

Das erste Kapitel über die Hintergründe von HTML5 hat vielleicht noch historischen Wert, aber ansonsten kann man das Buch aufgrund von Fossilität getrost in die Tonne treten.

Weitere Fragen?

Habt ihr auch dringende Fragen zu Frontend-Technologien? Nur her damit! Alle Fragen in diesem Post wurden mir per E-Mail oder Twitter gestellt und ihr könnt das genau so machen! Einfach über einen der genannten Kanäle anschreiben oder gleich das komplette Erklärbären-Paket kommen lassen.

PostMessage zwischen Service Worker und Client(s)

Veröffentlicht am 11. April 2018

Ein Service Worker ist nur ein Worker und entsprechend sollte der Nachrichtenaustausch via postMessage() und MessageEvent eine Kleinigkeit sein – möchte man meinen! Tatsächlich ist die ganze Angelegenheit beim Service Worker etwas weniger trivial als bei anderen Workern, was an ein paar besonderen Umständen liegt:

  1. Eine Seite/App wird immer von einem Service Worker kontrolliert …
  2. … der zu einem gegebenen Zeitpunkt ggf. noch inaktiv sein könnte …
  3. … und nicht der einzige vorhandene Worker sein muss …
  4. … aber seinerseits mehrere Seiten/Apps (Clients) kontrollieren könnte.

Diese Gemengelage führt zu einer etwas asymmetrischen API, die zwar logisch, aber nicht direkt offensichtlich ist.

Eine Seite/App kann immer nur einen Service Worker haben. Allerhöchstens könnte es verschiedene Versionen geben, wenn sich z.B. nach der Installation eines Updates ein neuer Worker inaktiv in Warteposition hinter dem aktuellen, aktiven Worker steht. Daher kann es für eine Seite/App nur eine Service-Worker-Message-Quelle geben, die unter navigator.serviceWorker anzuzapfen ist:

navigator.serviceWorker.addEventListener("message", (evt) => {
  window.alert(`Nachricht vom SW: ${ evt.data }`);
});

Es kann natürlich sein, dass gar kein Service Worker aktiv ist oder es überhaupt niemals einen Service Worker für die Seite/App geben wird, aber das ist für das Registrieren eines Event Listeners nicht relevant. Wenn es keine Message-Events gibt (entweder mangels Nachrichten oder mangels Senders), wird der Handler einfach nie aufgerufen. Es ist, als würde man an einen Briefkasten aufstellen – das kann man machen, auch wenn man niemals einen Brief erhalten wird.

Anders sieht es beim Senden von Nachrichten aus. Hier wird ein Ziel benötigt und dieses Ziel muss in der Lage sein, Nachrichten anzunehmen. Das heißt, dass wir darauf warten müssen dass ein Service Worker aktiv wird und dann auch explizit diesen als Adressaten auswählen müssen. In Code bedeutete das:

// Nachrichten zum SW senden
// 1. warten bis der SW aktiv ist
navigator.serviceWorker.ready
  .then( (registration) => {
    // 2. Zugriff auf aktiven SW erhalten
    if (registration.active) {
      // 3. Nachricht senden
      registration.active.postMessage(23);
    }
  });

Das Promise unter navigator.serviceWorker.ready liefert eine ServiceWorkerRegistration, sobald es einen aktiven Service Worker gibt. Das ServiceWorkerRegistration-Objekt enthält Properties für Service Worker in verschiedenen Stadien: installing, waiting und active. Eine Seite/App wird immer von exakt einem Worker kontrolliert, aber wenn sich neben dem aktivem Worker beispielsweise grade ein Update installiert, sind trotzdem zwei Worker vorhanden. Eine Message muss immer an einen Worker gehen, also rufen wir dort postMessage() auf.

Natürlich spricht auch nichts dagegen, einem nicht-kontrollierenden Worker eine Nachricht zu senden oder mehrere Nachrichten an mehrere Worker zu verteilen:

// Nachrichten zum SW senden
// 1. warten bis der SW aktiv ist
navigator.serviceWorker.ready
  .then( (registration) => {
    // 2. Zugriff auf aktiven SW erhalten
    if (registration.active) {
      // 3. Nachricht senden
      registration.active.postMessage(23);
    }
    // 4. Zugriff auf SW für bereits installiertes Update erhalten
    if (registration.waiting) {
      // 5. Nachricht senden
      registration.waiting.postMessage(42);
    }
  });

Aufseiten des Workers ist das Empfangen von Nachrichten recht simpel: das message-Event auf dem globalen Objekt fängt alle Meldungen aller verbundener Seiten/Apps ein. In der source-Eigenschaft des Event-Objekts befindet sich das Client-Objekt, das die sendende Seiten/App repräsentiert. Ein solches Client-Objekt implementiert seinerseits postMessage(), so dass sich Nachrichten leicht zurücksenden lassen:

// Nachrichten aus der Webseite empfangen
self.addEventListener("message", (evt) => {
  const client = evt.source;
  client.postMessage(`Pong: ${ evt.data }`);
});

Zugriff auf alle übrigen Clients gibt es über die asynchrone Methode self.clients.matchAll(), so dass sich eine Nachricht von einem Client in den Worker an alle anderen Clients weiterverteilen lässt:

self.addEventListener("message", async (evt) => {
  const messageSource = evt.source;
  const clients = await self.clients.matchAll();
  for (const client of clients) {
    if (client !== messageSource) {
      client.postMessage(`Message from ${ messageSource.id }: ${ evt.data }`);
    }
  }
});

Und schon funktioniert der Nachrichtenaustausch zwischen Client(s) und Service Worker problemlos! Eigentlich ist das ganze Wirrwarr nur eine Konsequenz aus der grundsätzlichen Funktionsweise von Service Worker und somit hoffentlich nur auf den ersten Blick leicht irritierend.

Conditional CSS mit Pseudo-Booleans

Veröffentlicht am 29. März 2018

Wie dem einen oder anderen Twitter-Verfolger aufgefallen sein mag, ringe ich zur Zeit mit einem Projekt rund um per JavaScript generiertes CSS. Besagter CSS-Generator soll im Prinzip aus einem gegebenen Input immer den gleichen Output liefern, aber es soll auch ein paar an- und abschaltbare Features geben. Da stellt sich die Frage: wie sieht die bestmögliche API zum An- und Abschalten aus? Natürlich wäre es möglich, der JavaScript-Funktion ein paar das Design bzw. CSS betreffende Konfigurations-Parameter zu verpassen, aber da es in meinem Fall wirklich um die Feinjustierung von CSS geht, passt mir das nicht wirklich ins Konzept. Lieber würde ich dafür CSS-Variablen (die es ja schon länger gibt) und Booleans einsetzen, zum Beispiel so:

/* Pseudo-CSS */

body {
  --show-border: true;
}

.foo {
  if (var(--show-border)) {
    border: 1px solid red;
  }
}

Natürlich funktioniert der obige Code in dieser Form nicht und entsprechende Funktionalität in CSS einzubauen wäre auch nach aktuellem Diskussionsstand alles andere als trivial. Dennoch kann man dem Traum von CSS-Booleans mit Hilfe von Custom Properties recht nahe kommen – zumindest für bestimmte Fälle:

/* Echtes CSS */
body {
  --show-border: 1; /* Alternativ 0 für "false" */
}

.foo {
  border-color: red;
  border-style: solid;
  border-width: calc(1px * var(--show-border));
}

So einfach kann man es sich zurecht tricksen: die gewünschte Rahmenbreite wird entweder mit 1 multipliziert und taucht damit unverändert auf oder verschwindet durch die Multiplikation mit 0. Ein Standardwert, der greift, wenn die „Boolean-Property“ nicht gesetzt ist, kann über den Fallback-Parameter der var-Funktion umgesetzt werden:

/* Echtes CSS */
body {
  --show-border: 1;
}

.foo {
  border-color: red;
  border-style: solid;
  border-width: calc(1px * var(--show-border, 0));
}

Hier wird also standardmäßig keine Border (bzw. eine Border mit 0px Breite) angezeigt.

Im Detail ist das natürlich nicht das gleiche wie echte Booleans, denn es werden ja auch im False-Fall (bzw. im 0-Fall) bestimmte border-Properties gesetzt. Man sieht nur dann 0px Breite nicht direkt etwas davon. Der Trick würde spätestens auffallen, sobald Kindelemente des betroffenen Elements border-Properties erben oder die Kaskade ins Spiel kommt. Für viele Fälle ist das aber akzeptabel. Für meinen CSS-Generator reicht es zumindest für einige der konfigurierbaren Anzeige-Optionen – und das, was nicht mit CSS-Fake-Booleans herbei getrickst werden kann, wandert dann eben in die JavaScript-API.

Maps vs. Plain Objects in JavaScript

Veröffentlicht am 7. Februar 2018

Als Webtechnologie-Erklärbär genieße ich den zweifelhaften Luxus, vergleichsweise selten selbst Code schreiben zu dürfen (und stattdessen sehr viel über Code zu erzählen). Nur im Sommer oder zum Jahresende, wenn niemand eine Schulung haben will, komme ich mal dazu, etwas nicht völlig triviales zu programmieren. Dabei zeigt sich immer, was ich selbst bei meinen eigenen Schulungen gelernt habe. Dieses Jahr hat sich bei mir (d.h. in meinem Code) die Benutzung von Maps (und in geringerem Maße auch Sets) durchgesetzt und normale Objekte bzw. Objekt-Literals verwende ich nur für ausgesuchte Zwecke. Dieser Blogpost versucht euch zu überzeugen, es mir gleichzutun und Maps in euren aktiven JS-Wortschatz aufzunehmen.

Use Cases für Objekte in JS

Von null und undefined abgesehen ist alles in JavaScript entweder ein Objekt oder verhält sich wie eins. Objekte bündeln Primitives oder Unter-Objekte nebst zugehörigen Funktionen und füllen damit genau die Rolle aus, die Objekte in so ziemlich jeder Programmiersprache haben. Andere Programmiersprachen verwenden zur Definition von Objekten oft ein Klassensystem, bei den die Programmierer ein Template für eine Datenstruktur nebst Funktionalität definieren (sowie bei statisch typisierten Sprachen einen entsprechenden Typ).

Vergleichsweise ungewöhnlich an JavaScript ist, dass man mit Objekten recht rücksichtslos umgehen kann. Mit Objekt-Literalen wie { foo: 42 } lassen sich beliebige Objekte aus dem Nichts erzeugen, eine Klasse oder ein Typ muss nicht deklariert werden. Und sofern keine speziellen Vorkehrungen getroffen werden kann das Objekt jederzeit manipuliert werden. Werte können überschrieben werden, Attribute können jederzeit gelöscht oder hinzugefügt werden.

Objekte werden aufgrund dieser großen Benutzerfreundlichkeit sowie aus Mangel an Alternativen für zwei sehr unterschiedliche Use Cases verwendet:

  • Als eher statische Objekte in dem Sinne, wie sie auch in anderen Programmiersprachen vorkommen (Objekte/Structs), d.h. als in ihrer Struktur unveränderliche Bündelungen von Daten und Funktionalität.
  • Als eher dynamische Key-Value-Datenstrukturen, in die jederzeit beliebige neue Einträge eingefügt oder aus denen Daten gelöscht werden.

Vom Grundprinzip her sind JS-Objekte also durchaus als eine Key-Value-Datenstruktur zu beschreiben, die beliebige Werte beliebigen String-Keys zuordnet:

// Speichert 42 unter dem Key "foo"
let x = { foo: 42 };

// Überschreibt 42 unter "foo" mit 23
x.foo = 23;

// Liest den unter "foo" gespeicherten Wert aus
let y = x.foo;

// Löscht den Eintrag "foo" nebst Daten aus dem Objekt
delete x.foo;

Diese Vermischung der Use Cases führt offensichtlich nicht zum Untergang des Abendlandes, aber optimal ist sie nicht. Objekte sind keine besonders gute Datenstruktur und mit Maps bietet uns modernes JS mit Maps eine hervorragende Alternative.

Objekte vs. Maps

Herkömmliche JavaScript-Objekte sind Key-Value-Paare, bei denen die Key immer Strings sind. Was kein String ist, aber als Objekt-Key verwendet wird, wird stringifiziert:

let o = {};

o.foo = 1; // Klappt
o[42] = 2; // Klappt
o[{}] = 3; // Klappt


const allKeys = Object.keys(o);
allKeys.every( x => typeof x === "string")
// > true

console.log(allKeys);
// > [ '42', 'foo', '[object Object]' ]

Bei Objekten fungieren also streng genommen nicht die Keys als Keys, sondern die String-Repräsentationen der Keys sind die Keys! Wenn zwei verschiedene Objekte die gleiche String-Repräsentationen haben, sind sie als Objekt-Key austauschbar:

let o = {};

const x = { a: 23 };
const y = { a: 42 };

o[x] = 1;

console.log(o[y]); // > 1

Die Objekte x und y sind völlig unterschiedlich, als Key aber austauschbar. Autsch! Bei Maps hingegen sind die Objekte selbst die Keys:

let m = new Map();

const x = { a: 23 };
const y = { a: 42 };

m.set(x, 1);

console.log(m.get(y)); // > undefined

Nur Objekte, die gemäß === gleich sind (mit wenigen Ausnahmen) werden von Maps als gleiche Keys betrachtet, was in so gut wie allen Fällen sinnvoller ist, als Key-Objekte stets und ständig zu stringifizieren.

Jedes JavaScript-Objekt hat seine Prototypen-Kette – Plain Objects ebenso wie Maps. Das Problem ist hier, dass diese Prototypen-Kette tatsächlich relevant wird, sobald man mit normalen Objekten und der Punkt/Eckklammer-Notation arbeitet:

let o = {}; // leeres Objekt... oder?
typeof o.toString; // > function! Ups...

Die Eigenschaft toString ist eigentlich nicht wirklich auf dem Objekt vorhanden, dank der Vererbungs-Kette aber irgendwie doch schon. Dem Problem lässt sich in der Theorie entgegentreten, indem man Objektzugriff nur per hasOwnProperty() (d.h. unter Umgehung der Prototypen-Kette) durchführt und/oder Objekte grundsätzlich per Object.create(null)(d.h. ganz ohne Prototyp) anlegt. Sollte das auch nur einmal nicht passieren, kann durch das Überschreiben eines „magischen Keys“ wie toString das Verhalten eines Objekts verändert werden. Beide o.g. Maßnahmen helfen im Übrigen nichts, wenn der fragliche magische Key __proto__ ist. Man stelle sich nur folgendes Szenario vor:

const wordCountForInput = {};
const getUserInput = () => "__proto__"; // Stub
const countWords = (x) => x.split(" ").length;
const userInput = getUserInput();
const wordsInUserInput = countWords(userInput);
wordCountForInput[userInput] = wordsInUserInput;

Hier wird mitnichten eine Zahl unter einem String-Key abgespeichert! Durch den magischen Key __proto__ wird vielmehr versucht, ein Number-Primitive als Prototyp des Objekts zu definieren. Das hat in, keinem modernen Browser einen Effekt, aber das bedeutet auch, dass keine Zahl abgespeichert wird. Wäre der Wert an dieser Stelle nicht eine Zahl sondern ein Objekt, könnte alles mögliche passieren – sobald irgendein Nutzer in irgendein Input __proto__ eingibt, kann man seinem Code nicht mehr über den Weg trauen. Das ist zugegebenermaßen ein wenig wahrscheinliches Szenario, aber warum würde man sich dieses Problem überhaupt ans Bein binden wollen, wenn doch Maps zur Verfügung stehen?

const m = new Map();

m.has("toString"); // > false - was auch sonst?

m.set("__proto__", 42); // klappt
m.get("__proto__"); // > 42

Maps haben neben weniger Edge Cases auch den großen Vorteil, Iterierbare Objekte zu sein, Iteratoren für Keys und Values zu bieten und darüber hinaus auch strikt die Insertion Order beizubehalten. Bedeutet:

  1. Über Maps kann ohne weiteres mit for-of-Schleifen iteriert werden
  2. Map.prototype.keys() liefert einen Iterator über alle Keys
  3. Map.prototype.values() liefert einen Iterator über alle Values
  4. In allen drei Fällen werden die Werte in exakt der Reihenfolge ausgespuckt, in der Sie in die Map eingefügt wurden

Somit ist der folgende mit Maps umgesetzte Code …

const m = new Map();
m.set("a", 1);
m.set("b", 2);

for (const [ key, value ] of m) {} // Klappt
const keys = m.keys();   // > Iterator "a", "b"
const vals = m.values(); // > Iterator 1, 2

const clone = new Map(m); // Maps können mit Iterables initialisiert werden

… zwar auch mit normalen Objekten machbar …

const o = {};
o.a = 1;
o.b = 2;

for (const [ key, value ] of o) {} // Klappt nicht
for (const key in o) {} // Bester Ersatz, aber bezieht Prototypen mit ein
const keys = Object.keys(o); // > Array "a", "b"
const vals = Object.values(o); // > Array 1, 2 (seit ES2017)

const clone = Object.assign({}, o);

… aber wieder mit Edge Cases und weniger Komfort. Bei for-in ist zu beachten, dass auch über Prototyp-Properties iteriert wird und die Reihenfolge der Properties ist weder durch den Standard noch durch real existierende Implementierungen garantiert – die Keys und Values können also in jeder beliebigen Reihenfolge ausgegeben werden! Das ist natürlich in sehr vielen Fällen kein Problem, nur warum würde man all den Kleinigkeiten überhaupt die Chance geben wollen, zu einem Problem zu werden?

Kurz und gut: Maps können fast alles, was Plain Objects können, aber mit weniger Edge Cases und mehr eingebauten Features. Das bedeutet allerdings längst nicht, dass man Maps jetzt auch für wirklich alles benutzen sollte.

Was für Objekte spricht

Objekte haben gegenüber Maps auch zahlreiche Vorteile. Die Literal-Syntax ist im Vergleich zum komplizierten new Map() natürlich unschlagbar einfach und auch einfache Wertzuweisung schlägt get() und set() im Benutzerfreundlichkeits-Wettkampf klar. Wenn man den ganzen Tag Code schreibt, ist das ein nicht zu vernachlässigender Bonus.

Außerdem ist es beileibe nicht immer wünschenswert, Objekte unstrinifiziert anhand von Objekt-Identität zu vergleichen. Ich persönlich hatte jüngst eine Map konstruiert, die als Keys x/y-Koordinaten-Objekte verwendete und diesen Koordinaten jeweils andere Objekte zuordnete. Mein Problem: viele hundert Zeilen später wollte ich für gegebene Koordinaten das passende Objekt aus der Map fischen, doch das dann dafür zur Verfügung stehende x/y-Koordinaten-Objekt war (bei gleichem Inhalt) eben ein anderes Objekt. In diesem Fall funktionieren Maps nicht:

const m = new Map();
const coords = { x: 1, y: 2 };
m.set(coords, 42);
const sameCoords = { x: 1, y: 2 };
m.has(sameCoords); // > false - logisch, wenn auch unpraktisch

Manchmal ist eben eine Stringifizierung doch das Mittel der Wahl für Vergleiche. Außerdem gibt es natürlich viele Objekt-Use-Cases, bei denen die Prototyp-Kette ein Feature und kein Hindernis darstellt. Hier haben Maps nichts zu suchen.

Wir stellen also fest: Maps können viele der Aufgaben erfüllen, die in JavaScript klassischerweise von Plain Objects übernommen werden. Es bleibt die Frage, ob und wann sie denn auch wirklich statt eines Plain Objects zum Einsatz kommen sollten.

Fazit und abschließende Empfehlungen

Maps haben einen Use Case, bei dem sie in modernem JS in allen Fällen normale Objekte verdrängen sollten: Key-Value-Datenstrukturen mit unbekannten Keys. Wann immer sich über die Programmlaufzeit ein Objekt mit Daten füllen soll, die zum Zeitpunkt des Programmierens noch nicht bekannt sind, sind Maps das Mittel der Wahl. Unter alle anderen Umständen sind weiterhin normale Objekte vorzuziehen, da sich dort die Nachteile nicht auswirken und das, was im Datenstruktur-Use-Case ein lästiger Edge Case ist (die Prototyp-Kette), ein nützliches Feature ist.

Für die folgenden Fälle sind und bleiben Plain Objects das Mittel der Wahl:

// Struct - Keys bekannt, Prototypen egal
const coords = { x: 1, y: 2 };

// Klasse (Bündel aus Daten und Funktionen)
// Prototypen sind hier ein wichtiges Feature!
class Car {
  drive () {
    console.log("Brumm");
  }
}

// Funktionsparameter (wie Structs)
function foo (options) { ... }
foo({ bar: 23, baz: 42 });

In diesem Szenario sind wir mit einer Map besser beraten:

const userInput = "Hello Hello __proto__ toString";

const countWords = (str) => {
  const counter = new Map();
  const words = str.split(" ");
  for (const word of words) {
    if (counter.has(word)) {
      counter.set(word, counter.get(word) + 1);
    } else {
      counter.set(word, 1);
    }
  }
  return counter;
}

countWords(userInput);
// > Map { 'Hello' => 2, '__proto__' => 1, 'toString' => 1 }

Der Code verschluckt sich nicht an __proto__, liefert garantiert die Wörter in der Reihenfolge, wie sie im Ursprungs-Input vorkamen und toString steht im Output, weil das Wort im Input war – keine Prototyp-Kette der Welt kann dafür verantwortlich sein.

Der einzig gute Grund, auch im Jahr 2018 weiterhin Plain Objects als Key-Value-Datenstruktur zu verwenden, ist der gute alte Legacy-Code. Libraries wie Lo-Dash und viele Millionen Zeilen Bestandscode wurden konzipiert, bevor Maps in ECMAScript landeten. Ich hätte kein Problem damit, bestehende APIs mit guten alten Objekten zu füttern, denn wenn sie sich bis heute gehalten haben, kümmern sich sie bestimmt fein säuberlich um all die bisher aufgezählten Edge Cases und es kann eigentlich nichts schiefgehen. Richtig?