Mittwoch, 16. Februar 2011

Eine minimalistische JavaScript-Bibliothek

HTML ist, wie Stefan Münz es so schön sagte, die lingua franca des Web: HTML bleibt die Sprache, in der man als Server mit einem Browser reden sollte – flankiert von CSS-Code für die kompakte und wiederverwendbare Notation von Stil, und JavaScript-Code für die Implementierung von clientseitigem Verhalten. Manche errichten allerdings prächtige Gebäude, in Form von Web Application Frameworks und denken sich lustige Namen für diese aus, nur um ihre Verwender vor dem direkten Kontakt mit dieser vermeintlich schlimmen Trias zu bewahren: Als würden Entwickler beim Anblick von HTML-, CSS- und JavaScript-Code zu Stein erstarren.

Aber diese prächtigen Gebäude setzen sich alle nicht so richtig durch. Das Interesse, Overhead durch zusätzliche, oft nicht wirklich benötigte Indirektionsebenen zu vermeiden, ist höher als die Angst, aus Versehen doch wirklich einmal eine HTML-Notation zu verwenden, die doch tatsächlich vom Netscape Navigator 4.0 nicht mehr korrekt interpretiert wird. Und da HTML, CSS und selbst JavaScript leicht zu erlernen sind, schreckt die Zielgruppe, an die sich die prächtigen Gebäude eigentlich wenden, nicht davor zurück, statt mit abstrahierenden Frameworks direkt auf der HTML-Ebene zu arbeiten. Dazu kommt, dass es für Content Management und Webanwendungen eine Menge konkurrierende Lösungen gibt, so dass man sich bei Verwendung eines bestimmten Frameworks in eine Abhängigkeit begibt.

Wer also zu der grossen Gruppe der Webanwendungsentwickler gehört, die ihre Anwendungen direkt mit HTML, CSS und JavaScript formulieren, ohne dabei so schreckliche Dinge wie WYSIWYG-Editoren mit automatischer HTML-Codegenerierung zu verwenden, der wird zwar kein Framework benötigen, wohl aber eine Bibliothek von JavaScript-Funktionen. Und zwar aus drei Gründen:

  1. weil er gewisse Funktionen immer wieder benötigt und sie daher nur einmal programmieren möchte (Don't Repeat Yourself),

  2. um auf browserspezifisch unterschiedlich implementierte Features mit einer Abstraktion zugreifen zu können,

  3. um den konkreten JavaScript-Code der Webanwendung flüssig lesbar und doch kompakt formulieren zu können.


Nun gibt es eine Reihe von JavaScript-Bibliotheken für solche Zwecke. Aber jede Bibliothek möchte natürlich besonders gut und umfassend werden, die Konkurrenz in allen obigen Punkten um Längen schlagen. Da erwacht der Ehrgeiz der Bibliotheksentwickler, und die Bibliotheken werden wirklich besser und umfassender - aber auch immer grösser. Dojo 1.5 hat unkomprimiert 370 KB (komprimiert immer noch 88), jQuery 1.5 hat 207 KB (komprimiert 82), und Prototype 1.7 hat unkomprimiert 169 KB (und bietet keine komprimierte Version an; wenn ich aber Frank Marcias JS Minifier verwende, reduziert sich Prototype 1.7 auf 120 KB). Das ist immerhin bedenklich: Braucht man wirklich so grosse Bibliotheken?

Man könnte einwenden, dass die Parsezeiten für Bibliotheken dieser Grössenordnung schon heute vernachlässigbar sind, während die JavaScript-Interpreter immer noch beständig weiter optimiert werden (vom IE9 hört man über traumhafte Performanceergebnisse, die selbst Google Chromes Wunderwaffe V8 noch in den Schatten stellen sollen). Bei Anwendungen, die mit den klassischen Form-Submit-Zyklen arbeiten, schlägt diese Parsezeit allerdings bei jedem Dialogschritt zu Buche. Es empfiehlt sich in jedem Fall das Ökonomieprinzip: Warum soll man etwas laden, das man zu grossen Teilen gar nicht benötigt oder verwendet? Später lässt sich nicht mehr genau sagen, welche Funktionen in welchen Teilen der Anwendung wirklich verwendet werden.

Daher stellt sich die Frage: Welche Funktionen werden besonders häufig benötigt oder ergeben einen besonders grossen Nutzen?

Da sind sicher einmal einfache DOM-Zugriffsfunktionen. Die Funktionen $() und $$() in Prototype und jQuery verwenden die Syntax von CSS-Selektoren, um Knotenmengen aus dem HTML-DOM zu extrahieren. Das ist eine kompakte Syntax, die gut ankommt und von der Zielgruppe verstanden und gelesen wird. Eine gute Wahl. Aber der Parser für die Selektorstrings will erstmal geschrieben werden. Schon eine reine Abkürzung für das geschwätzige document.getElementById macht den Code lesbarer:

// Shortcut für document.getElementById
function byId(id) {
return document.getElementById(id);
}

Mit Iteratorfunktionen füllen die Bibliotheken eine weitere Lücke im JavaScript-Sprachumfang. Diese wird zwar mit JavaScript 1.6 durch Methoden wie forEach geschlossen. Bis dahin aber mag ich nicht warten. Mit wenigen Codezeilen lässt sich die Lücke füllen, indem ich dem Array-Objekt des JavaScript-Standards eine neue each-Methode unterschiebe. Die in der Implementierung verwendete, "ausprogrammierte" indexbasierte (also zwangsläufig hässliche) Schleife hilft, viele indexbasierte Schleifen im Applikations-Code durch besser lesbare each()-Konstrukte zu ersetzen.[1]

// --- Array-Iteration mit each()
// Die Callbackfunktion erhält den Wert des Array-Elements als "this"
// Als Argument wird der aktuelle Array-Index übergeben
var _break = {}; // Spezielle Exception, um aus each() auszubrechen
Array.prototype.each = function(f,context) {
var i,n=this.length;
if (typeof f != "function") {
alert( "Programmfehler: each-Argument muss eine Funktion sein");
return;
}
try {
for (i=0;i<n;++i) {
f.call(context||this[i],this[i],i);
}
}
catch(e) {
if (e!=_break) {throw e; }
}
};

Weitere Iteratorfunktionen wie etwa any() und all(), die feststellen, ob ein oder jedes Element des Arrays eine Bedingungsfunktion erfüllt, halte ich für nicht so wichtig. Eine Filter- oder Subset-Funktion dagegen ist hilfreich, um verkettete Methodenaufrufe zu ermöglichen:
// --- subset(filter)
// Die Teilmenge aller Arrayelemente bilden,
// für die die filter-Funktion true ergibt
Array.prototype.subset = function(filter) {
var result = [];
this.each( function() {
if (filter.call(this)) { result.push(this); }
});
return result;
};

Nun können auch weitere DOM-Zugriffsfunktionen geschrieben werden wie byName(name,parentNode) und byTagName(tagname,parentNode) und byClass(classname,parentNode) (wobei der zweite Parameter parentNode optional ist, um nicht das ganze Dokument durchsuchen zu müssen, sondern nur alle Knoten, die unterhalb von parentNode liegen). Zu beachten ist, dass die native Implementierung des Rückgabewertes einer DOM-Funktion wie getElementsByTagName kein Array sein muss, sondern gemäss DOM-Spezifikation nur eine NodeList. Die Implementierung dieser NodeList ist browserspezifisch und nicht in jedem Browser erweiterbar. Um also den Rückgabewert mit each() iterieren zu können, muss das Ergebnis leider in einen Array abgebildet werden:
// Shortcut für getElementsByTagName
function byTagName(tagname,theParent) {
var i,n,result=[];
var nodeList = (theParent||document).getElementsByTagName(tagname);
for (i=0,n=nodeList.length;i<n;++i) { result.push(nodeList[i]); }
return result;
}

Ein Verwendungsbeispiel dieser Funktionen: Um alle Eingabefelder eines mit der ID adressdaten bezeichneten Formulars als Name/Wert-Paare in einem Hash einzusammeln, muss ich folgendes schreiben:
var fields = {};
byTagName( "input", byId("adressdaten") ).each( function() {
fields[this.name] = this.value;
});

Komplexere und geschachtelte Bedingungen kann man mit der Funktion byCondition(condition,parentNode) realisieren, die den Baum ab dem angegebenen Element traversiert und alle Elemente einsammelt, für die die Funktion condition zu true evaluiert (auch hier ist, weil NodeList kein Array und nicht erweiterbar sein muss, wieder eine indexbasierte Schleife nötig. Je mehr notwendige indexbasierte Schleifen wir aber in eine Bibliothek extrahieren können, umso weniger hat sie der Anwendungscode nötig):
// --- Alle Elemente, die eine Bedingung erfüllen
function byCondition( condition, theParent) {
var children, n, i, result = [];
children = (theParent || document).childNodes;
n = children.length;
for (i=0;i<n;++i) {
if (children[i].nodeType==1) {
if (condition.call(children[i])) { result.push( children[i] ); }
Array.prototype.push.apply( result,
byCondition( condition, children[i] ) );
}
}
return result;
}

Man kann die in byCondition enthaltene Traversierung des DOM auch isoliert benutzen: Wer über byCondition verfügt, hat keine Notwendigkeit mehr, selbst an irgendeiner Stelle noch einmal das rekursive Durchlaufen aller Elemente des DOM zu programmieren.

Was man sicher braucht, ist eine Funktion doRequest(url,callback,data,contentType), die HTTP-Zugriffe durchführt (wobei nur die ersten beiden Argumente obligatorisch sind) und die angegebene callback-Funktion in einen Wrapper aufnimmt, um sie bei readyState = 4 aufzurufen. Für die meisten Browser, auch für IE ab Version 7, kann dabei auf das Objekt XMLHttpRequest zugegriffen werden. Für frühere IE-Versionen kann man Fallback-Objekte verwenden. Diesen Code zur Instanzbeschaffung, verpackt in der Funktion getRequestor(), zeige ich hier nicht. Er ist direkt aus dem Wikipedia-Artikel zu XMLHttpRequest übernommen.

// --- Browserübergreifender HTTP-Request
// callback,data,action,headerFields sind optional
function doRequest(url,callback,data,action,headerFields) {
var field,value,
theData = data || null,
requestor = getRequestor();
requestor.open(action||"GET",url,!!callback);
if (callback) { requestor.onreadystatechange = function() {
if (requestor.readyState ==4) {
callback.call(requestor); // Mit requestor = this aufrufen
}
};
}
if (headerFields) {
for (field in headerFields) {
requestor.setRequestHeader(field,headerFields[field]);
}
}
requestor.send(theData);
return callback ? requestor : requestor.responseText;
}

Weiter gibt es je nach Browser verschiedene Arten, einen Ereignisbehandler zu registrieren:
// --- Browserübergreifende Registrierung einer Funktion
function registerFor( theElement, theEvent, theHandler ) {
var eventNormalized;
if (typeof theHandler != "function") {
alert( "Programmfehler: Nur Funktionen können registriert werden");
return;
}
var f = function(e) { theHandler.call(getSource(e),e); };
if (window.addEventListener) {
eventNormalized = theEvent.replace(/^on/,""); // immer ohne "on"
theElement.addEventListener(eventNormalized, f, false);
}
else {
eventNormalized = theEvent.replace(/^(?!on)/,"on"); // immer mit "on"
theElement.attachEvent(eventNormalized, f );
}
}

Die Validierung, dass der übergebene Behandler wirklich eine Funktion ist, ist sehr nützlich, denn viele Entwickler schreiben aus Gewohnheit
registerFor(window,"load",doOnLoad());  // falsch!!!
was natürlich grundfalsch ist (sie machen es, weil sie es seit DOM Level 0 gewohnt sind, Ereignisbehandler direkt im HTML zu notieren). Die Klammern nach doOnLoad sind hier falsch: es soll ja nicht die Behandlerfunktion zum Zeitpunkt des Aufrufs von registerFor evaluiert und das Ergebnis dieses Aufrufs registriert werden (das meistens undefined oder false ist), sondern die Funktion doOnLoad für die spätere Ausführung zum Zeitpunkt load vorgemerkt werden.

Ein Ereignisbehandler muss auf das Element zugreifen können, von welchem aus das Ereignis ausgelöst wurde. Am einfachsten geht das, wenn dieses Element der Funktion bereits als this-Argument bereitsteht, wie es z.B. in Prototype gelöst ist. Das macht auch meine Registrierungsfunktion registerFor: Wie zu sehen ist, wird nicht die übergebene Funktion (theHandler) selbst, sondern ein Wrapper f registriert, der bei Ereignisauslösung zunächst die Ereignisquelle ermittelt und das this-Argument mit diesem Element belegt. Die dafür verwendete browserübergreifende Funktion getSource(event) ist im Grunde ein Einzeiler, aber solche Dinge soll man nur einmal hinschreiben müssen – die Benennung als Source (wie bei Microsoft) finde ich übrigens besser als Target (wie bei Mozilla):
// Browserübergreifende Ermittlung einer Event-Source
function getSource( event ) {
return event && event.target || window.event.srcElement;
}

// Browserübergreifende Ermittlung eines key-Codes
function getKeyCode(event) {
var e = event || window.event;
return e.keyCode || e.which;
}

Weitere Funktionen: setText() und getText() setzen und lesen Texte beliebiger Elemente (in <input>-Elementen wird der value verwendet, für andere Elemente der erste textförmige Kindknoten, der ggf. erst anzulegen ist). gotoURL(url, fields) erstellt und versendet ein adhoc-Formular, damit die URL nicht mit Get-Parametern verschmutzt wird.

Zusammen mit einigen weiteren kleinen Funktionen dieser Art habe ich eine minimalistische JavaScript-Bibliothek erstellt, die unkomprimiert nur rund 8 KB umfasst und die ich nicht mehr nennenswert erweitern werde:

http://ruediger-plantiko.net/minlib/

minlib.js ist eigentlich bei der Refaktorisierung meiner MVC-JavaScript-Bibliothek global.js entstanden. Interessanterweise war die Datei vor wie nach der Refaktorisierung ungefähr gleich gross (nämlich 20KB), obwohl sie hinterher die minlib-Funktionen enthielt! Das heisst, die 8KB habe ich bei der Refaktorisierung des restlichen, MVC-spezifischen Teils wieder herausgeholt, indem ich den Code nun kompakter formulieren konnte! Das Ergebnis war eine besser lesbare Datei, die um einige nützliche wiederverwendbare Funktionen angewachsen war.

[1] Die each-Funktion ist so geschrieben, dass sie funktionsidentisch mit der each()-Funktion des Prototype-Frameworks abläuft. Das ist mit Absicht so gemacht, damit im Bedarfsfall prototype und minlib.js gleichzeitig verwendet werden können.

Kommentare :

robert hat gesagt…

Sehr schöner POST! Der mir viel gebracht hat, auch weil Javasript wieder für mich aktuell wird.

Bei dem Performance Argument bin ich mir nicht ganz sicher. Externe Bibliotheken sollten eigentlich nur einmal geladen werden und dann aus dem Cache kommen. Wie verhält sich Performance Gewinn zu der Zeit die für das Rendering benötigt wird? Wie hoch ist der Performance Gewinn im Vergleich zu den üblichen Bibliotheken. Spannen wäre ein Benchmark. Mein Vermutung wäre, das mit einem gefüllte Cache die Differenzen in modernen Browsern kaum messbar sind.

Eine Frage die ich hatte: Sollte Elment-Id" nicht eindeutig, unique sein?, weswegen das Beispiel schwierig ist:

byTagName( "input", byId("adressdaten") ).each( function() {
fields[this.name] = this.value;
});

Rüdiger Plantiko hat gesagt…

Hallo Robert,

freut mich, dass Dir mein Blog hilfreich sein konnte.

@Performance: Natürlich wird JS im Browser gecached. Bei jedem Seitenwechsel aber muss das JS, auch wenn es natürlich aus dem Speicher kommt, neu geparsed werden. Daher sind grosse Bibliotheken besonders für "Eine-Seite-Applikationen" unproblematisch, also Applikationen, bei denen einmalig HTML-Code vom Server kommt und danach nur noch mit JavaScript und HTTP-Requests auf der Seite herumgeturnt wird.

@Element-ID: Ja, ich stimme zu, die sollte unbedingt unique sein! Deswegen gibt byId() ja ein Element zurück und nicht einen Array (wie byName() und byTagName()). Die von Dir angeführte Codestelle verletzt diese Regel ja nicht:

byTagName( "input", byId("adressdaten") ).each( function() {
fields[this.name] = this.value;
});

Das heisst: Beschaffe alle Elemente, die von dem (eindeutigen) Element mit der ID "adressdaten" (nämlich z.B. dem Formularelement <form id="adressdaten">) abstammen und den Tagnamen "input" haben, und für jedes dieser Elemente füge ihr Name/Wert-Paar in den Hash ein.