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?

DOMNodeInserted-Events durch Mutation Observer ersetzen

Veröffentlicht am 17. Januar 2018

In den letzten Wochen hatte ich mal wieder Zeit ein wenig selbst zu programmieren und wollte ein wenig mit Höhen und Breiten von dynamisch generierten DOM-Elementen rechnen. Das Problem hierbei ist, dass die wahren Höhen und Breiten eines Elements erst feststehen, wenn es im Dokument gelandet ist. Also galt es, den Zeitpunkt des Einfügens eines Elements in das DOM abzupassen. Das klingt einfacher, als es ist, wenngleich es sich nach kurzer Überlegung dann doch recht simpel lösen ließ.

Das Problem ist, dass es mal ein Event namens DOMNodeInserted gab, das jedoch zusammen mit allen anderen Mutation Events aus den Standards geflogen ist. Als Ersatz sollen MutationObserver herhalten, doch einen direkten Weg zur Beobachtung des eingefügt-werdens gibt es nicht. Also nehmen wir den indirekten Weg:

  1. Wir setzen einen MutationObserver nicht auf unser Ziel-Element, sondern auf irgendein Element im Dokument an, das eines Tages (direkt oder indirekt) unser Element beinhalten wird. Das könnten z.B. document.body oder document.firstElementChild sein.
  2. Der Observer durchsucht bei childList-Ereignissen (d.h. Änderungen an den Kindelementen des observierten Elements) die neu hinzugefügten Knoten nach unserem Ziel-Element. Wird es gefunden, führen wir einen Callback aus.

Die einfachste Implementierung dieses Patterns sieht wie folgt aus:

function observeInsertion (targetNode, callback, rootNode = document.firstElementChild) {
  const insertionObserver = new MutationObserver( (mutations) => {
    for (const mutation of mutations) {
      if (mutation.type === "childList") {
        for (const added of mutation.addedNodes) {
          if (added === targetNode || added.contains(targetNode)) {
            callback(targetNode);
            insertionObserver.disconnect();
            return;
          }
        }
      }
    }
  });
  insertionObserver.observe(rootNode, {
    childList: true,
    subtree: true,
  });
}

In Aktion auf CodePen!

MutationObserver sammeln durch eine DOM-Änderung anfallende Mutations-Events ein und liefern sie im Paket an den Observations-Callback – mutations ist also ein Array von Objekten. Aus diesem picken wir uns in einer for-of-Schleife die childList-Events und durchsuchen die hinzugefügten DOM-Knoten nach unserem Ziel-Element. Da wir den gesamten DOM-Tree beobachten (subtree: true) wird uns das Einfügen unseres Ziel-Elements nicht entgehen, egal wie tief verschachtelt es stattfindet. Sobald wir das Einfügen unseres Elements beobachtet haben, triggern wir den Callback, schalten den MutationObserver mit disconnect() ab und steigen durch das return aus der dann fertig abgearbeiteten Observer-Callback-Funktion aus.

Dieser Code (bzw. seine TypeScript-Variante) haben für mich soweit ganz gut funktioniert. Vermutlich könnte man für den Einsatz auf breiter Front eine noch effizientere Variante basteln, die nicht für jede zu beobachtende Node einen eigenen Observer benötigt, aber im allerbesten Fall kommt man ganz ohne ein Element-Wurde-Eingefügt-Event aus. Sofern es nicht gerade um (wie in meinem Fall) unsauberes Gewurschtel mit Computed Styles geht, gibt es wirklich wenig Gründe, einen Ersatz für DOMNodeInserted überhaupt zu wollen, denn mit Event Delegation und anderen intelligenten Techniken ist es eigentlich egal, wann und ob ein Element im DOM gelandet ist.

Am Ende habe ich den in diesem Artikel gezeigten Mutation Observer selbst aus meinem Code geworfen und die CSS-Rechnerei in CSS-Variablen ausgelagert.

Progressive Web Apps ohne HTTPS auf Mobilgeräten testen

Veröffentlicht am 29. September 2017

Progressive Web Apps (bzw. Service Worker) funktionieren nicht ohne HTTPS und das ist auch gut so. Wir haben 2017 und Webstandards sollten wahrlich Secure By Default sein. HTTPS ist kein Hexenwerk, bedeutet aber doch einen gewissen Aufwand, gerade wenn man als Entwickler nur mal schnell einen Prototyp zusammenschrauben möchte. Daher lassen Browser für die lokale Entwicklung praktischerweise Service Worker auch ohne HTTPS zu – wenn in der Adressleiste irgendwas mit localhost oder 127.x.y.z steht, geht's auch ohne grünes Schlösschen. Also alles in Butter? Nicht ganz! Denn „lokale Entwicklung“ von PWA dürfte in so ziemlich allen Fällen auch das Testen auf Mobilgeräten beinhalten. Und bei deren Browsern wird, auch wenn sie im lokalen WLAN hängen, ganz sicher etwas in der Adressleiste stehen, das den Service Worker HTTPS verlangen lassen wird.

Es gibt diverse browserspezifische Wege, die das Problem zu umschiffen. Die Firefox-Config kennt den Flag devtools.serviceWorkers.testing.enabled, der das HTTPS-Requirement abschaltet und Chrome kann man mit komischen Flags starten oder den Mobile-Browser in lokale Devtools einklinken … alles ziemlich kompliziert und speziell für bestimmte Browser. Es geht aber auch einfacher.

Die HTTPS-Ausnahme für lokale Entwicklung greift, sobald in der Browser-Adressleiste localhost oder 127.x.y.z steht. Also warum nicht einfach einen kleinen Proxy-Server auf dem Entwicklungs-Rechner laufen lassen, der alle Anfragen auf den lokalen App-Server umbiegt? Wenn man diesen Proxy-Server auf dem Mobilgerät verwendet und localhost eingibt, landet man auf dem App-Server und genießt dabei die HTTPS-Ausnahmeregelung. Mit dem Node-Modul http-proxy ist der Proxy-Server schnell gebaut:

// npm install http-proxy
require("http-proxy").createProxyServer({
  target: "http://localhost/projekte/foo/" // lokale App läuft hier
}).listen(8001);

Dann noch Mobilgerät und Entwicklungs-Rechner in ein gemeinsames Netzwerk pflanzen, die IP des Entwicklungs-Rechners mit dem passenden Port als Proxy auf dem Mobilgerät eintragen und schon funktionieren nicht nur Service Worker, sondern auch mit andere Web-APIs, die normalerweise HTTPS brauchen.