Montag, 26. Januar 2026

Geht's noch etwas minimalistischer, bitte?

Vor kurzem unterzog ich meine minimalistische JavaScript-Bibliothek minlib.js (2011) einer Revision, da sich seit ihrem Erscheinen viel verändert hat. Nach massiven Kürzungen und Vereinfachungen entstand schließlich ein ES6-Modul minlib.mjs, das ich hier beschreiben will.

Hatte das minlib.js von 2011 noch 10.8 KB, so ist das Modul minlib.mjs auf nur noch 5.1 KB Größe geschrumpft (natürlich unkomprimiert).

Und strenggenommen ist das ganze Modul heute überflüssig. Im besten Fall hilft es durch die Abkürzungen und typisierten Rückgabewerte, JavaScript-Code noch etwas lesbarer zu machen.

Die Verfallszeit von Frameworks

Die Programmiersprache JavaScript ist im Umfang gewachsen und wächst ständig weiter, so dass die Sprache immer flüssiger lesbar wird und unnötigen Code (syntaktisches Rauschen) zusehends vermeidet. Der Sprachkern von JavaScript - also ohne die DOM-API, die für den Zugriff auf die HTML-Elemente einer Webseite zuständig ist - basiert auf dem Standard ECMAScript, zu dem es mittlerweile im Jahrestakt neue Versionen gibt. Aber auch die DOM-API-Funktionen werden ständig verbessert.

Wir blicken zurück auf gottlob längst vergangene Zeiten, als Sprachkern und DOM-API noch sehr unreif waren und auch nicht in allen Browsern einheitlich verwendbar. Der Internet Explorer war stets das schwarze Schaf, das sich nicht um Regeln und Standards kümmerte - aber auch unter den anderen Browsern gab es störende Abweichungen. In diesen Zeiten begann der Einsatz separater JavaScript-Frameworks, die die im Standard noch fehlenden Features anboten. Die Frameworks garantierten auch den zuverlässigen Einsatz in unterschiedlichen Browsern (Browserunabhängigkeit).

Es war daher klar, dass all diese Frameworks als Lückenfüller nur eine begrenzte Lebensdauer haben würden: sie haben nur solange eine Existenzberechtigung,

  • wie es gravierende Differenzen der JavaScript-Engines in den verschiedenen Browsern gibt,
  • und wie es der bestehenden JavaScript-Syntax und der DOM-API an denjenigen praktischen und vereinfachenden Funktionen mangelt, für die die Framework-Hersteller ihre Workarounds anbieten.

In Webanwendungen war zunächst das Framework jQuery der Platzhirsch, der sich gegen Konkurrenten wie Prototype.js durchsetzte. Später liefen ihm Frameworks wie Ember.js, AngularJS, Vue.js und React den Rang ab. Diese Frameworks arbeiten mit Erweiterungen wie virtuellem DOM und Datenbindung (der Blog RisingStack gibt eine Einführung in Datenbindung und zeigt gleichzeitig, wie man sie heute mittels Definition von Settern und Gettern für Objekteigenschaften erreichen kann - mit purem JavaScript, ohne Frameworks zu benötigen).

All diese Frameworks generieren einen zusätzlichen Overhead. Viele erfinden sogar neue Zwischensprachen und Layer mit eigenen UI-Elementen (fraglos macht es Entwicklern Spaß, sich solche Layer auszudenken und Interpreter dafür zu schreiben), für deren Interpretation es dann wieder eigene Frameworks wie Handlebars.js gibt.

Aber jedes Framework erzeugt eine zusätzliche Software-Abhängigkeit. Was wir beim Rückblick auf die letzten drei Jahrzehnte wissen, ist: allein die Triade HTML, CSS und JavaScript hat im Web die Zeiten überdauert und wird sie weiter überdauern. Durch die fortgesetzte Optimierung dieser Triade auf Browserseite werden die meisten Frameworks mit der Zeit obsolet. Es ist daher empfehlenswert, mit dem zukunftsweisenden Framework Vanilla.js zu arbeiten - es ist mit seinen 0 Bytes Downloadgröße überdies extrem leichtgewichtig. 😀

Modularisierung

Ein Problem von "frühen" JavaScripts ist, dass man sehr unbefangen mit dem globalen Namensraum umging. Ein in einer Webseite ausgeführtes JavaScript-File besteht in der Regel aus einer Reihe von Funktionen, die allesamt ab dem Zeitpunkt ihrer Deklaration global bekannt und verfügbar sind.

Um diese Verschmutzung des globalen Namensraums zu verhindern, bot die Sprache schon immer die Möglichkeit innerer Funktionen an: Funktionen, die nur als Hilfsfunktionen von anderen Funktionen gebraucht werden, können im Rumpf der verwendenden Funktion deklariert werden und sind dann - wie auch dort deklarierte Variablen - nur innerhalb dieser verwendenden Funktion bekannt.

Mit diesem Feature der inneren Funktionen konnte man in JavaScript ein Modulkonzept einführen:

  • Es gibt eine Hauptfunktion (meist anonym), die unmittelbar ausgeführt wird (sogenannte IIFE = immediately invoked function expression);
  • Rückgabewert dieser Funktion ist ein Objekt, das Referenzen auf alle Funktionen enthält, die außerhalb des Moduls bekannt sein sollen;
  • Dieses eine Objekt kann nun einer globalen Variablen zugewiesen werden, die die Sammlung aller Bibliotheksfunktionen enthält.
In der Praxis kamen dann "Module" von folgender Art vor, die wenigstens nur noch eine globale Variable brauchen (die Variable Library in diesem Beispiel - ein Objekt, dessen Eigenschaften die API-Methoden und -Attribute sind).
// "Modul" vor ES6, bietet API-Komponenten api1, api2, apivar an
Library = (function(){
  var x,y,z;  // Interne Hilfsvariablen, sichtbar nur hier und in den inneren Funktionen
  var apivar; // Durch Aufnahme ins return-Objekt kann man veröffentlichen
  return {
    "api1":api1,
    "api2":api2,
    "apivar":apivar
  };
  function api1() {
  }
  function api2() {
  }
  // Hilfsfunktionen, verwendet in den API-Funktionsimplementierungen
  function aux1() {  
  }
  function aux2() {  
  }
  function aux3() {  
  }
})();
Alternativ könnte man auch eine Generatorfunktion veröffentlichen und diese dann in den verwendenden Scripten aufrufen:
// Modul, bietet API-Funktionen api1, api2 an
function getLibrary(){
  ...
  return {
    "api1":api1,
    ...
  };
  ...
}
Aber dennoch ist klar, dass das alles nur Notbehelfe waren. Es war eine Änderung des JavaScript-Sprachkerns notwendig, um echte Module schreiben zu können. Das passierte dann mit ES6 (= ES2015). Seitdem gibt es export- und import-Anweisungen in JavaScript.

Files, die eine export-Anweisung verwenden, werden als Modul betrachtet, und alle im File deklarierten Variablen und Funktionen haben die neue Sichtbarkeit "File", wenn sie nicht in der export-Anweisung aufgeführt sind.

// ES6-Modul bietet API-Komponenten api1, api2, apivar an
export {
  api1,
  api2,
  apivar
};

var apivar; // Öffentliche Variable, da im export aufgeführt
var x,y,z;  // Interne Hilfsvariablen, sichtbar nur im Modul
  
function api1() {
}
function api2() {
}

// Hilfsfunktionen, nur in diesem Modul sichtbar
function aux1() {  
}
...

Ein Konsument der Bibliothek kann dann mit der import-Anweisung diejenigen Funktionen oder Variablen in seinen eigenen Kontext importieren, die er benötigt. Dabei kann er die importierten Objekte noch mit dem Schlüsselwort as umbenennen, falls er mit den Namen nicht zufrieden ist, die der Bibliotheksentwickler für seine Objekte gewählt hat.

import {
  api1 as f,
  apivar
} from "./minlib.mjs";

Der erste Umbau von minlib.js war daher, die Bibliothek in ein ES6-Modul umzuwandeln - und um klarer hervortreten zu lassen, dass es sich um ein Modul handelt, änderte ich das Suffix auf .mjs. Die exportierten Funktionen von minlib.mjs sind:

export {
  deepCopy,
  byName,
  select,
  selectAll, 
  byClass,
  byCondition,
  byId,
  byTagName,
  selectParent,
  getParentByCondition,
  getElement,
  setText,
  getText,
  hasClass,
  setClass,
  toggleClass,
  resetClass,
  navigateTo
};
Auf diese Funktionen werde ich nun noch im einzelnen eingehen.

Tiefe Kopie eines komplexen Datenobjekts

Die Funktion deepCopy() ist die einzige grundlegende Basisfunktion der Bibliothek, die nicht auf das HTML-DOM bezogen ist. Sie dient dazu, ein vorgelegtes komplexes Datenobjekt, bestehend aus Arrays, Objekten und einfachen Datenobjekten, vollständig zu kopieren.

Die Idee ist, es einmal in einen JSON-String zu serialisieren und danach wieder zu deserialisieren. Dieser Weg ist wesentlich effizienter als alle Implementierungen, die mit JavaScript-Code den gesamten Datenbaum durchgehen und dabei kopieren (bei der JSON-Serialisierung wird zwar ebenfalls der Datenbaum durchlaufen, aber dies geschieht intern, im nativen Code des JavaScript-Interpreters und ist damit wesentlich effizienter als expliziter JavaScript-Code jemals sein kann).

// --- Vollständige Kopie eines Datenobjekts
function deepCopy(data) {
  return JSON.parse(JSON.stringify(data));
}

Nachteil ist allenfalls, dass man die Funktion nicht auf eine bestimmte Tiefe beschränken kann - man muss immer alles kopieren. Dennoch ist die Funktion so einfach, elegant und nützlich, dass sie mir für minlib.mjs geeignet erschien.

Abkürzungen

Einige der minlib-Funktionen sind einfach Abkürzungen. Nehmen wir die Funktion byId(), die als Abkürzung für document.getElementById() fungiert. Auch umfangreiche JavaScript-Frameworks wie Prototype benutzen eine solche Abkürzung, in Prototype heißt die Funktion $() - um der größtmöglichen Kürze willen.

In der alten Version minlib.js war byId() wie folgt definiert:

function byId(id) {
  return document.getElementById(id);
}
Beim Aufruf entsteht also eine weitere Stackebene, nur um die Umleitung auf document.getElementById() auszuführen. Das kann man sich sparen, wenn man die Funktion als echten Alias definiert:
const byId = document.getElementById;  // ...aber Vorsicht! 
So einfach funktioniert es allerdings noch nicht! Wenn ich in der Konsole die so definierte Funktion auf eine wirklich existierende ID anwende:
byId("div1")
erscheint die Fehlermeldung
Uncaught TypeError: Illegal invocation
    at <anonymous>:1:1
Grund ist, dass getElementById nicht einfach eine Funktion, sondern eine Objektmethode ist. Sie erfordert ein Bezugsobjekt document als this-Objekt, um ordnungsgemäß zu funktionieren. Tatsächlich können im Ausführungskontext einer Webseite mehrere HTML-Dokumente als Bezugsobjekte vorhanden sein - etwa wenn mit Frames oder iFrames gearbeitet wird.

Nun bietet das eingebaute Function-Objekt von JavaScript eine Methode bind(o), die eine modifizierte Funktionsreferenz zurückliefert; die Modifikation besteht darin, dass bei Funktionsaufrufen das this-Objekt an das bei bind übergebene Objekt o gebunden ist.

Eine solche modifizierte Funktion benötigen wir auch hier. Wenn wir die Funktion explizit an das aktuelle document-Objekt binden,

const byId = document.getElementById.bind(document);
dann funktioniert auch der Aufruf wie erwartet - und liefert das DOM-Element zur angegebenen ID zurück, oder null, falls kein solches existiert.

Dank der Modularisierung ist kein Verwender der Bibliothek gezwungen, den vom Modul-Autor gewählten Namen zu verwenden. Wenn jemand lieber $ statt byId verwenden will, gibt er den gewünschten Namen eben beim Import an:

import { byId as $ } from "./minlib.mjs";

Die CSS-Klassen

Schon in der alten minlib.js gab es Funktionen hasClass(), setClass() und resetClass(), die alle auf dem Attribut className von DOM-Elementen operierten. Dieses Attribut enthält alle aktiven CSS-Klassen des Elements, in Form einer durch Leerzeichen getrennten Liste. Um eine einzelne Klasse ausfindig zu machen, wurde ein regulärer Ausdruck verwendet, der mit der Wortgrenzen-Zusicherung \b arbeitete:
function hasClass(elem,theClass) {
  return !! elem.className.match(  new RegExp("\\b"+theClass+"\\b") );
}
Mittlerweile gibt es die Schnittstelle DOMTokenList, die eine ganze Reihe sinnvoller Operationen auf einer Menge von Tokens erlaubt (hier also verwendet für die Liste der für dieses Element aktuell aktiven CSS-Klassen).

Andererseits ist wegen der doppelten Indirektion der Zugriff über das separate Attribut classList immer etwas unklar. Wenn er schon nicht vermeidbar ist, könnte man ihn einmal in einer Funktion vergraben - und dann immer mit dieser Funktion arbeiten. Dies wäre daher die Reimplementierung von hasClass():

function hasClass(elem,theClass) {
  return elem.classList.contains(theClass);
}
Analog sind die folgenden drei Funktionen definiert:
function setClass(elem,theClass) {
  elem.classList.add(theClass); 
}

function resetClass(elem,theClass) {
  elem.classList.remove(theClass);
}

function toggleClass(elem,theClass,force) {
  return elem.classList.toggle(theClass,force);
}

Das DOM mit CSS-Selektoren durchsuchen

Eine der praktischsten Funktionen, um die das DOM API seit 2009 in den Browsern erweitert wurde, ist die Möglichkeit, die HTML-Seite mit einem CSS-Selektor zu durchsuchen. Das Modul, das in CSS für den Stil von Webseiten zuständig ist, ist nun also auch im JavaScript zugänglich. Hierzu gibt es die Funktionen element.querySelector() und element.querySelectorAll(). Beide Funktionen durchsuchen das vom Bezugs-element aufgespannte HTML-Fragment auf Elemente, die dem CSS-Selektor genügen. Während die erste, element.querySelector(), beim ersten Treffer mit der Suche aufhört und das gefundene Element zurückliefert, bringt die zweite Funktion, element.querySelectorAll(), eine vollständige Liste aller Elemente, die die Bedingung erfüllen.

Hier gibt es nur zwei Unschönheiten:

  1. Die Namen der Funktionen sind zu lang. Das kennen wir schon von getElementById().
  2. Der Rückgabetyp von querySelectorAll() ist kein Array, sondern eine NodeList. Um Array-Methoden wie filter(), map() oder reduce() auf das Ergebnis anzuwenden, muss man dieses zuerst in einen Array konvertieren.
Die minlib.mjs bietet ersatzweise zwei Funktionen select(selector,context) und selectAll(selector,context) an, die einen kurzen, passenden Namen haben und sich flüssig lesen. Die Funktion selectAll() konvertiert das Ergebnis auch gleich noch in einen Array:
// --- Shortcut für querySelectorAll
function selectAll(selector,context=document) {
  return [...context.querySelectorAll(selector)];
}

// --- Shortcut für querySelector
function select(selector,context=document) {
  return context.querySelector(selector);
}
Eine weitere Funktion selectParent(context,selector) sucht in der Kette der Vorfahren das erste Element, auf das der CSS-Selektor selector passt - und gibt dieses zurück. Dies ist sehr ähnlich der Standardmethode Element.closest(selector). Der einzige Unterschied besteht darin, dass selectParent mit dem direkten Vorfahren des Bezugselements zu suchen beginnt, während Element.closest auch schon das Bezugselement selbst prüft.

Die Funktion byTagName(tagName,context=document) gehört ebenfalls in diese Familie. Einerseits ist sie eine Abkürzung der Standardmethode getElementsByTagName(tagName). Darüberhinaus liefert sie aber auch einen Array der gefundenen Elemente zurück (statt der weniger flexiblen NodeList). byTagName(tagName) funktioniert identisch wie select(tagName). Durch den Funktionsnamen byTagName wird aber die Intention des Codes klarer. Daher erschien es mir sinnvoll, diese Funktion in der Sammlung zu behalten.

Den Baum hinauf- und hinabsteigen

Um die Knoten des Baums beginnend bei einem Bezugselement zu durchsuchen, gibt es in minlib.js zwei Funktionen, je nach Suchrichtung:
  • byCondition(condition,context) wendet auf die Nachfahren des Elements context die Funktion condition an und gibt einen Array aller Elemente zurück, für die diese Funktion "etwas Wahres" im JavaScript-Sinne zurückliefert (also einen Wert x, der die Bedingung x != false erfüllt). Wird nichts gefunden, wird der leere Array [] zurückgeliefert.
  • getParentByCondition(context,condition) durchsucht die Kette der Vorfahren, bis es ein Element gefunden hat, das die Bedingungsfunktion condition() erfüllt - und liefert dieses zurück. Wird sie nicht fündig, ist der Rückgabewert null.

    Wie schon bei selectParent() ist der Unterschied zur Standardfunktion Element.closest() nur der, dass das Bezugselement selbst nicht geprüft wird, sondern die Prüfungen beim Vaterelement beginnen.

Das name-Attribut

Das name-Attribut muss in einer Webseite nicht eindeutig sein - in der überwiegenden Mehrzahl der Fälle ist es aber eindeutig, und man ist an einer Funktion interessiert, die das Element zum angegebenen Namen zurückliefert.

Die folgende Funktion leistet das. Sie macht sich das optional chaining mit dem Operator ?. zunutze, um den Fall, dass es gar kein Element des angegebenen Namens gibt, nicht gesondert behandeln zu müssen. In diesem Fall gibt die Funktion einfach undefined zurück:

function byName(name) {
  return document.getElementsByName(name)?.[0];
}
Nun kann ein Elementname auf einer Webseite in verschiedenen Formularen vorkommen. Es ist also sinnvoll, einen zweiten Parameter als Kontextparameter vorzusehen, um nur den durch diesen Parameter aufgespannten Teilbaum des HTML zu durchsuchen. Per Default soll einfach document verwendet werden. Die endgültige Implementierung von byName(name,context) sieht dann folgendermaßen aus:
function byName(name,context=document) {
  return (context == document) ? 
    document.getElementsByName(name)?.[0] :
    [...document.getElementsByName(name)].find(x=>context.contains(x));
}
Um im (häufigeren) Fall, dass der implizite Kontext document verwendet wird, möglichst schnell zu sein, ist der alternative Fall separat programmiert: hier wird die von der API-Funktion document.getElementsByName() zurückgegebene NodeList in einen Array verwandelt, um mit der Array-Funktion find() das erste Element zu finden, das im angegebenen context enthalten ist.

Tatsächlich gibt es Fälle, in denen es sogar sinnvoll ist, dass mehrere Eingabeelemente denselben Namen tragen - z.B. kann eine Reihe von Checkboxes oder Radiobuttons den gleichen Namen haben, sich aber im Wert unterscheiden. Beim Formularsubmit werden dann die Name-/Wert-Paare der jeweils angekreuzten Elemente / übertragen.

Ein typisches Beispiel dafür stammt aus der HTML-Spezifikation: eine Webseite zur Bestellung von Pizzen, bei denen mit Checkboxes die verschiedenen Zutaten gewählt werden können. All diese Checkboxes tragen den gleichen Namen topping:

<form method="post">
  <label>Customer name: <input name="custname"></label>
  <label>Telephone:     <input type="tel" name="custtel"></label>
  <label>Email address: <input type="email" name="custemail"></label>
  <fieldset>
    <legend>Pizza Size </legend>
    <label><input type="radio" name="size" value="small">Small</label>
    <label><input type="radio" name="size" value="medium">Medium</label>
    <label><input type="radio" name="size" value="large">Large</label>
  </fieldset>
  <fieldset>
    <legend>Pizza Toppings </legend>
    <label><input type="checkbox" name="topping" value="bacon">Bacon</label>
    <label><input type="checkbox" name="topping" value="cheese">Extra Cheese</label>
    <label><input type="checkbox" name="topping" value="onion">Onion</label>
    <label><input type="checkbox" name="topping" value="mushroom">Mushroom</label>
  </fieldset>
  <label>Preferred delivery time:
    <input type="time" min="11:00" max="21:00" step="900" name="delivery">
  </label>
  <label>Delivery instructions:
    <textarea name="comments"></textarea>
  </label>
  <button>Submit order</button>
</form>
In solchen Fällen kann es vorkommen, dass man im JavaScript alle vom Benutzer gewählten Toppings ermitteln möchte.

Mit minlib.mjs könnte man hierfür die Funktion selectAll verwenden:

const toppings = selectAll("[name=topping]:checked").map(x=>x.value)
verwenden. Dieser Einsatz erschien mir allerdings so speziell, dass ich keine eigene Funktion byNameAll() in minlib.mjs dafür vorsehen wollte.

Texte schreiben und lesen

Die Funktionen setText(idOrNode,text) und getText(idOrNode) sind sich funktional praktisch gleich geblieben. Sie erlauben das Einlesen oder Schreiben von Texten in DOM-Elemente: entweder als untergeordnete Textknoten, oder - z.B. bei <input>-Elementen, im value-Attribut. Die beiden Funktionen abstrahieren also von der konkreten Art, wie ein Text in Elementen notiert wird.

Als erstes Argument idOrNode kann die ID eines Elements oder das Element selbst übergeben werden (wenn man es kennt). Um selbst API-Funktionen mit dieser Art von polymorphen Argumenten zu schreiben, bietet minlib.mjs die Hilfsfunktion getElement(idOrElement) an:

function getElement(idOrElement) {
  return idOrElement instanceof Element ? idOrElement : byId( idOrElement );
}

HTTP-Requests

In minlib.js gab es die Funktion doRequest für das Ausführung von HTTP-Requests, um weitere Ressourcen vom Backend zu laden (z.B. JSON-Files mit Rohdaten, die in das HTML-UI einzufüllen sind). Sie operierte mit dem Object XMLHttpRequest, das damals für solche Aufgaben benötigt wurde. Die Funktion doRequest habe ich in minlib.mjs ersatzlos gestrichen. Denn mit der Fetch API ist diese Funktion vollständig in den Browser eingezogen.
const response = await fetch("index.json");
const content = await response.json( );
// Ab hier: das JSON-Objekt content verarbeiten (z.B. ins HTML-Dokument einfüllen)

Navigation

Um von einer HTML-Seite zu einer anderen HTML-Seite zu navigieren, verwendet man normalerweise Formulare. Kontext kann dabei in Form von Formularfeldern übertragen werden. Formular und Formularfelder sind bei der Verwendung zum Navigieren meist unsichtbar. Die Methode POST wird in der Regel dem GET vorgezogen, damit die URL des Navigationsziels nicht mit den Formularfeldern verschmutzt wird.

Es gibt jedoch Fälle, in denen es unpraktisch ist, ein Formular mit seinen Feldern fix vorzubelegen: es soll möglich sein, auf verschiedene Seiten zu wechseln und dabei auch eine von Fall zu Fall variierende Menge von Formularfeldern mitzusenden. In solchen Fällen ist es praktisch, ein Formular in der aktuellen Seite "on the fly" aufzubauen, versteckte Formular mit den gewünschten Namen und Werten zu definieren und dann dieses dynamisch generierte Formular zu versenden, wodurch die Navigation ausgelöst wird. Genau dies leistet die Funktion navigateTo():

function navigateTo(url="", fields={}) {

// adhoc-Formular erzeugen, befüllen und abschicken
  const submitter = document.createElement("form");
  Object.assign(submitter,{action:url,method:"post"});

// Formular mit versteckten Formularfeldern abfüllen
  for (const [name,value] of Object.entries(fields)) {
    const field = document.createElement("input");
    Object.assign(field,{type:"hidden",name,value});
    submitter.appendChild(field);
  }

  document.body.appendChild(submitter);
  submitter.submit();

}

Fazit

Die meisten Probleme, die es 2011 im JavaScript-Bereich noch gab, sind mittlerweile gelöst. Auch erlaubt das Modularisierungskonzept nun feine und feinste Steuerung aller benutzten Softwareeinheiten. Vielleicht erweist sich das eine oder andere Funktiönchen von minlib.mjs in diesem neuen Umfeld ja doch noch als nützlich.

Keine Kommentare :