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?

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.