Anzeige
Tutorialbeschreibung

MSE: JavaScript Micro Selector Engine

MSE: JavaScript Micro Selector Engine

Wer liebt sie nicht, die Art, wie mit jQuery (bzw. Sizzle.js) Knoten innerhalb eines Dokumentes gefunden werden. Einfach jQuery('#meinSelector') eingeben und das war's. Das ist wesentlich angenehmer als das leidige "document.getElementById('meinSelector'), document.getElementsByTagName('tagName'), usw.

Wer diesem Wahnsinn entfliehen möchte, ohne jQuery und Konsorten zu nutzen oder einfach einen kleinen "Blick hinter die Kulissen" haben will, für den ist dieses Tutorial gemacht. Ich zeige euch, wie ihr eure eigene kleine Selector Engine zusammenbastelt mit einer ähnlichen Funktionalität, wie auch jQuery sie bietet (also nach IDs suchen, nach Classes, normalen Tags, pseudo-Selectoren wie :odd oder :even nutzen u. A.), die prinzipiell sogar bis zu einem eigenen kleinen Framework ausgebaut werden kann.

Viel Spaß beim Lesen, Verstehen und Nachbauen!


Hi und willkommen zur Erstellung einer eigenen kleinen Selector Engine. Bevor wir loslegen, erst mal die Erklärung, was eine Selector Engine überhaupt ist:

Als Selector Engine (im Folgenden SE) wird ein Stück Code bezeichnet, das es erlaubt, in JavaScript ähnlich oder genauso wie mit CSS Knoten innerhalb des DOMs einer Website auszuwählen bzw. anzusprechen. Wenn also über CSS ein Element mit class= 'my_class' via '.my_class' angesprochen wird, erfolgt der Zugriff mit einer SE darauf genauso.

Tipp: Wer mehr über JavaScript erfahren will, sollte sich die Bücher "JavaScript - The definitive Guide" von David Flanagan (O'reilly) und "JavaScript - The good Parts" von Douglas Crockford (O'reilly) oder zumindest Ersteres besorgen.

Und noch eine Anmerkung: Dies ist ein Tutorial, das eher den grundsätzlichen Aufbau einer SE beschreibt, keine vollständig ausgearbeitete SE generiert. Das würde auch den Rahmen BEI WEITEM sprengen. Damit will ich sagen, dass dies hier mit Sicherheit nicht der Weisheit letzter Schluss ist oder auch nur sein soll. Beispielsweise wird nicht querySelectorAll() eingesetzt, obwohl mittlerweile alle modernen Browser diese Methode kennen.

So, und jetzt legen wir los:


Schritt 1

Zunächst erstellen wir den Rahmen unserer SE. Ich nenne sie mal – der Titel des Tutorials sagt es schon – "MSE". Ihr könnt eure SE natürlich auch anders nennen. Das tut der Funktionweise keinen Abbruch.

<script type="text/javascript">
//<![CDATA[

var MSE = (function(){

var MSE = function(selector, rootElem){

};

MSE.proto = MSE.prototype = {

};

return MSE;

}());

//]]>
</script>

Und jetzt dröseln wir das Ganze mal ein wenig auf:
Das erste MSE() wird später eine (und zwar die einzige!) globale Variable. Es handelt sich hierbei um eine sich selbst aufrufende Function. Dadurch erhalten wir die Möglichkeit, später einfach "var myElem = MSE('#myIDSelector');" zu schreiben.

Das zweite MSE ist etwas ganz anderes und wird nicht global. Es handelt sich um eine sog. Closure. Closures sind Variablen (und alles andere ebenfalls!), deren Geltungsbereich nur innerhalb der aufrufenden Function liegt. Weiterhin bleiben Closures erhalten, selbst wenn die eigentliche Function schon beendet ist. Da eine genaue Erklärung, was Closures sind, den Rahmen dieses Tutorials sprengen würde, verweise ich an dieser Stelle mal auf folgende Erklärung.

MSE.proto = MSE.prototype = {} definiert das Prototypenobject des zweiten MSE. Das Prototypenobject ist in JavaScript das, was Klassen in z.B. PHP sind: Blaupausen (also Baupläne) für mit einer Function (hier: dem zweiten MSE) erzeugte Objekte.

Mit der Anweisung return MSE; sagen wir der ersten MSE (im folgenden Constructor-Function), dass die innere Function MSE bei einem Aufruf zurückgegeben werden soll.


Schritt 2

Unsere Constructor-Function ist jetzt fertig. Jetzt bearbeiten wir die inner MSE-Function:

var MSE = function(selector, rootElem){
   return new MSE.proto.init(selector, rootElem);
};

Wir lassen sie also ein neues Prototypenobject zurückgeben. Ein Object? Aber init() ist doch der Aufruf einer Function?!? Stimmt. Geht ja auch nur, wenn wir das hier auch schreiben (besonderes Augenmerk bitte auf die letzte Zeile legen):

var MSE = function(selector, rootElem){
   return new MSE.proto.init(selector, rootElem);
},
toString = Object.prototype.toString,
isObj = function(x){
   return (toString.call(x) === '[object Object]');
},
isArr = function(x){
   return (toString.call(x) === '[object Array]');
},
isFn = function(x){
   return (toString.call(x) === '[object Function]');
},
isStr = function(x){
   return (toString.call(x) === '[object String]');
},
isHTML = function(x){
   return (isObj(x) && x.nodeType && x !== window);
},
isRObj = function(x){
   return (isObj(x) && !isHTML(x) && !isFn(x) && x !== (null || window));
},
WHITESPC = /\s+/g,
NTH = /\:nth\([0-9]\)/,
proto;

MSE.proto = MSE.prototype = {
   constructor : MSE,
   init : function(selector, rootElem){

      /* Weiterer Code */
   
      return this;
   }
};

proto = MSE.proto.init.prototype = MSE.proto;

Ja, ich weiß, es sieht verwirrend aus. Ist aber eigentlich gar nicht so schlimm. Mal oben anfangen:

MSE() gibt jetzt bei einem Aufruf das eigene Prototypenobject als neues Object zurück. Das bedeutet, wir können später Folgendes schreiben: "var myElem = MSE('#myIDSelector'); " und stellen sicher, dass die Variable myElem Zugriff auf alle Eigenschaften und Methoden des Prototypenobjects hat. Das sind zwar noch keine außer constructor und init(), aber ich verspreche euch, es werden mehr.

Die Methode init() des Prototypenobjects gibt einfach das Prototypenobject selbst zurück (via this. this ist also das Prototypenobject selbst. Logisch, oder?).

Die Eigenschaft constructor gibt später an, welche Constructor-Function das Object erzeugt hat (dreimal dürft ihr raten :))

In der letzten Zeile machen wir MSE.proto zum Prototypenobject von MSE.proto.init(). Fertig.

Und was macht der restliche Kram? Nun, das sind Aufrufe von Object.prototype.toString auf übergebene Parameter. Wenn Object.prototype.toString auf ein Object angewendet wird, gibt es abhängig von der [[Class]] des Objects den String '[object [[Class]]]' aus. Für ein Array wäre das z.B. '[object Array]'.

Mit isArr beispielsweise prüfe ich, ob der übergebene Parameter wirklich von Typ Array ist. Wenn er es ist, gibt isArr true zurück, ansonsten false.

WHITESPC und NTH sind Regular Expressions, also Pattern eines Mustervergleichs. Nehmen wir zu Demonstrationszwecken mal Folgendes an:

Ihr habt eine Variable "var halloWelt = 'Hallo Welt, dies ist ein Satz mit vielen          Whitespaces';".

Schreibt ihr jetzt "var halloWelt = halloWelt.replace(WHITESPC, '_');", dann gibt der Aufruf "alert(halloWelt)" aus:

"hallo_Welt,_dies_ist_ein_Satz_mit_vielen__________Whitespaces".

Alles klar? Mehr zu Regular Expressions gibt's hier.


Schritt 3

So, jetzt verpassen wir dem Standard-Prototypenobject mal ein paar Initialisierungseigenschaften:

MSE.proto = MSE.prototype = {
   constructor : MSE,
   length : 0, // Default-Length ist 0
   rootElem : document, // Default-rootElement ist das Document-Object
   selector : '', // Default-Selector ist ein leerer String
   nodes : [], // Default-Nodes ist ein leeres Array
   init : function(selector, rootElem){

      /* Weiterer Code */
   
      return this;
   }
};

Eigentlich selbsterklärend, was hier passiert.

Jedes neu erzeugte Prototypenobject hat eine Default-Length-Eigenschaft mit dem Wert 0, eine Default-rootElem-Eigenschaft die auf das aktuelle Document-Object verweist, eine Default-Selector-Eigenschaft mit einem leeren String als Wert und eine Default-Nodes-Eigenschaft, die als Wert ein leeres Array enthält.

Mit dem initialisieren über die Methode init() werden wir diese Default-Werte dann überschreiben.


Schritt 4

Jetzt wird es Zeit, dass wir uns mal um die init()-Methode des Prototypenobjects kümmern. Mal überlegen:
  1. Wir wollen direkt nach DOM-Knoten mit ID, Class-Attribute oder einfachen Tags suchen können
  2. Wenn wir nach Elementen mit Class-Attribut suchen, kann dieses Element mehrere Class-Definitionen besitzen
  3. Als Selector-Parameter soll es möglich sein, bei der Suche nach DOM-Knoten mit Id-Attribute zu schreiben '#myIDSelector', bei Class-Attributen '.myClassSelector', usw.. Javascript kennt aber nur die Suche mit 'myIDSelector' bzw. 'myClassSelector' . D. h., die Raute oder der Punkt müssen weg.
  4. Es soll möglich sein, optional ein rootElement anzugeben, aber kein Muss.
Da es sich hier um ein Tutorial handelt, habe ich mich entschieden, die Pseudo-Selector-Functions nicht mit in die init()-Methode zu stecken. Das macht die Sache schonmal einfacher und ändert eigentlich (fast) nichts an der Art der Nutzung.

Zunächst das Suchen nach einer ID und das Initialisieren der insgesamt (je nachdem) nötigen Variablen:

init : function(selector, rootElem){
      // Entweder das übergebene rootElement nutzen oder, falls nicht angegeben, document
      var root = (rootElem || document),
      matches = [],
      selection = '',
      i,j, allNodes, classes;
		
      // Kein Selector? Default-Prototyp zurückgeben und abbrechen!	
      if(!selector){
            return this;
      }
		
       // Wenn selector ein String ist...
      if(isStr(selector)){

             // ... dann schneide das erste Zeichen weg und speichere den Rest in selection...
            selection = selector.slice(1);

            // ... und prüfe, ob das erste Zeichen von selector eine Raute ist. Ist dem so....
            if(selector.charAt(0) === '#'){

                  // ... nutze getElementById(), um der nodes-Eigenschaft dieses Prototypeobjects 
                  // den entsprechenden DOM-Knoten als Array zu geben (Achtet auf die []-Klammern!).
                  this.nodes = matches = [ root.getElementById(selection) ];

                  // Setze die Length-Eigenschaft des Prototypeobjects auf 1.
                  this.length = 1;

                  // Setze die Selector-Eigenschaft auf den mit selector übergebenen String
                  this.selector = selector;

                  // Gib als rootElem dieses Prototypeobjects das rootElement an
                  this.rootElem = root;

                  // Gib das modifizierte Prototypeobject zurück
                  return this;
           }
      }
}
Info: Die Arbeit mit this kann in JS verwirrend sein, wenn man sich nicht sicher ist, worauf this gerade verweist. Ein schöner Artikel dazu steht im Blog von Peter Kröner (eigentlich geht es da um EcmaScript 5 & Neuerungen, aber und Function.prototype.bind() gibt er eine kurze Einführung zu this).

Weiterhin ist es sinnvoll zu wissen, dass der Programmablauf innerhalb einer Function gestoppt bzw. abgebrochen wird, sobald eine return-Anweisung auftaucht. Wir werden dies im weiteren Verlauf des Tutorials nutzen.



Schritt 5

Um nach Tags zu suchen, müssen noch ein bisschen mehr Code dazunehmen. Dieser sieht eigentlich ziemlich genauso aus, nutzt aber statt getElementById() die Function getElementsByTagName(). Und da getElementsByTagName() eine NodeList zurückgibt statt eines echten Arrays, müssen wir diese im Anschluss durchlaufen und jedes enthaltene Element an ein echtes Array übergeben, um später Array-Methoden wie push(), sort(), slice() u. Ä. nutzen zu können.

Info: Was ist eine NodeList? Eine Nodelist sieht zwar im Aufbau aus wie ein Array, ist aber keines. Sie ist ein sog. "Array-like-Object". NodeList[0] greift also auf den ersten NodeList-Eintrag zu, aber NodeList.slice(1, 2) beispielsweise lässt sich auf ihr nicht anwenden.

init : function(selector, rootElem){
      var root = (rootElem || document),
      matches = [],
      selection = '',
      i,j, allNodes, classes;
		
      // Kein Selector? Default-Prototyp zurückgeben und abbrechen!	
      if(!selector){
            return this;
      }
		
      if(isStr(selector)){
            selection = selector.slice(1);
            if(selector.charAt(0) === '#'){
                  this.nodes = matches = [ root.getElementById(selection) ];
                  this.length = 1;
                  this.selector = selector;
                  this.rootElem = root;
                  return this;
            }
			
            // Hier suchen wir nach Tagnames. Dafür nutzen wir den vollen selector, weil 
            // bei der Suche nach Tags ja weder '#' noch '.'am Anfang des Strings stehen, 
            // sondern z.B. 'div' oder 'p'
            matches = root.getElementsByTagName(selector);

            // matches ist eine NodeList. Wir durchlaufen die NodeList mit einer for-Schleife und 
            // packen jedes enthaltene Element in die Nodes-Eigenschaft des Prototypeobjects. 
            // Gleichzeitig erhöhen wir die Length-Eigenschaft des Prototypeobjects.
            for(i = 0; i < matches.length; i = i+1){
                  this.nodes[i] = matches[i];
                  // var i beginnt bei 0, die length soll aber bei 1 Element nicht 0 sondern 1 sein!
                  this.length = i + 1; 
            }
 
// Ab hier passiert das Gleiche wie vorher bei der Suche nach Elementen mit einer ID. this.rootElem = root; this.selector = selector; return this; } }

Schritt 6

Hey, damit wären wir ja schon fast soweit, dass wir einen ersten Test machen könnten. Jetzt fehlt eigentlich nur noch das Suchen nach Elementen mit Class-Attribut. Cool. Das erfordert zwar etwas mehr Aufwand, aber keine Angst – ist einfacher, als es aussieht:
init : function(selector, rootElem){
      var root = (rootElem || document),
      matches = [],
      selection = '',
      i,j, allNodes, classes;
		
      // Kein Selector? Default-Prototyp zurückgeben und abbrechen!	
      if(!selector){
            return this;
      }
		
      if(isStr(selector)){
            selection = selector.slice(1);
            if(selector.charAt(0) === '#'){
                  this.nodes = matches = [ root.getElementById(selection) ];
                  this.length = 1;
                  this.selector = selector;
                  this.rootElem = root;
                  return this;
            }

            // Hier ist die Suche nach Elementen mit angegebenem Class-Attribut:

            // Wenn das erste Zeichen von Selector ein '.' ist...
            if(selector.charAt(0) === '.'){

                  // ... hole ALLE Knoten des Documents ('*' = alle Tags)
                  allNodes = root.getElementsByTagName('*');

                  // allNodes ist eine NodeList. Also ist klar, was wir tun müssen: mit einer 
                  // Schleife darauf iterieren (die NodeList durchlaufen)				
                  for(i = 0; i < allNodes.length; i = i+1){

                        // Jetzt wird's interessant: className ist der JavaScript-Bezeichner für das 
                        // Class-Attribut eines DOM-Knotens (weil 'class' ein
                        // reserviertes Wort ist). Erinnert ihr euch noch an das Regular-Expression-
                        // Beispiel mit "Hallo Welt, ... " ? GENAU DAS machen
                        // machen wir jetzt mit den classNames, lassen sie aber via split() 
                        // als Array an classes übergeben anstatt mit replace() etwas zu ersetzen.
                        classes = allNodes[i].className.split(WHITESPC);
                        
                        // Jetzt wird über classes iteriert...
                        for(j = 0; j < classes.length; j = j+1){

                              // ... und wenn der Eintrag in classes zu selection 
                              // (also selector ohne '.') passt...
                              if(classes[j] === selection){

                                    // ... dann verschiebe den entsprechenden NodeList-Eintrag in matches!
                                    matches.push(allNodes[i]);
                              }
                        }
                  }

                  // Übergib matches an die Nodes-Eigenschaft des Prototypeobjects
                  this.nodes = matches;

                  // Setze die Length-Eigenschaft des Prototypeobjects auf die Länge von matches
                  this.length = matches.length;

                  // Der Rest ist altbekannter Kram.
                  this.rootElem = root;
                  this.selector = selector;
                  return this;
            }

            matches = root.getElementsByTagName(selector);
            for(i = 0; i < matches.length; i = i+1){
                  this.nodes[i] = matches[i];
                  this.length = i + 1;
            }
            this.rootElem = root;
            this.selector = selector;
            return this;
      }
}



 

Halbzeit!

Wahnsinn! Wir sind jetzt soweit, dass wir mit einem Aufruf von MSE() sowohl nach DOM-Knoten mit ID, Class-Attribut oder nur nach Tags suchen können. Wir bekommen dann ein Object geliefert, über dessen nodes-Eigenschaft auf die DOM-Knoten direkt zugegriffen werden kann. Als Beispiel:

Folgendes HTML-Gerüst:
<body>
      <div id="testID">Ein TestDIV mit ID = testID</div>
      <ul>
            <li class="testClass">Ein Listenpunkt mit class = testClass</li>
            <li class="testClass">Ein Listenpunkt mit class = testClass</li>
            <li class="testClass">Ein Listenpunkt mit class = testClass</li>
            <li class="testClass">Ein Listenpunkt mit class = testClass</li>
      </ul>
      <p>Ein P-Tag ohne ID oder Class-Attribut</p>
</body>


könnten wir so durchlaufen und bearbeiten:
<script type="text/javascript">
//<![CDATA[

var classElem = MSE('.testClass');
var idElem = MSE('#testID');
var pElem = MSE('p');

// Erstem Listenpunkt einen roten Hintergrund geben? Einfach:
classElem.nodes[0].style.backgroundColor = '#ff0000';

// Einen DOM-0-konformen EventListener auf dem Div-Element registrieren? Nichts leichter als das:
idElem.nodes[0].onclick = function(){
      // Macht die Schrift fett, this verweist auf den DOM-Knoten!
      this.style.fontWeight = 'bold';
}

// Bei click-Event auf erstem Listenpunkt die Schriftfarbe Weiß? Dass ich nicht lache:
classElem.nodes[0].onclick = function(){
      this.style.color = '#ffffff';
}

//]]>
</script>


Sooo, das wär's dann. Nein? Ach so, die Pseudoklassen-Methoden fehlen ja noch. Ok, kommt auch gleich.


Schritt 8

Die Suche nach Elementen via Pseudo-Selektoren kann jetzt relativ leicht implementiert werden. Dazu hängt ihr unter diese Zeile:
proto = MSE.proto.init.prototype = MSE.proto;


folgenden Code:
proto.get = function(modifier){

      // Sicher stellen, dass modifier gegeben ist und im nächsten Schritt, dass es ein String ist:
      if(modifier){
            if(isStr(modifier)){
                  
                  /* Hier kommt dann der weitere Code hin */

            }
      }

      // Egal, ob modifier gegeben ist oder nicht, das Object zurückgeben.
      return this;

}


Damit wird im Prototypeobject eine neue Methode get() implementiert. Fortan ist diese Methode dann auf allen mit MSE() erzeugten Objects verfügbar.


Schritt 9

Jetzt schaffen wir uns die Möglichkeit, mit get() das erste bzw. letzte Element zu holen:

proto.get = function(modifier){

      if(modifier){
            if(isStr(modifier)){
                  
                 if(modifier === ':first'){
                        this.nodes = this.nodes.slice(0, 1);
                        this.length = 1;
                        this.selector = this.selector + modifier;
                        
                        // Wieso hier ein return? Na, wir müssen ja nicht den gesamten Function-body
                        // durchlaufen lassen, wenn wir in dieser if()-Abfrage sind
                        return this;
                 }

                 if(modifier === ':last'){
                        this.nodes = this.nodes.slice(this.nodes.length - 1);
                        this.length = 1;
                        this.selector = this.selector + modifier;
                        return this;
                 }

            }
      }

      return this;

}


Spätestens jetzt sollte auch jedem klar werden, wieso wir statt NodeLists echte Arrays haben wollten. Methoden wie slice() würden – wie gesagt – auf einer NodeList einfach nicht funktionieren.

Mit this.nodes.slice(this.nodes.length - 1) wird übrigens das letzte Element von this.nodes direkt in einem Array mit nur einem Eintrag zurückgegeben.

Es wird übrigens jedesmal wieder das jeweils modifizierte Prototypeobject zurückgegeben! Das sollte man beachten. Soll heißen:

Wenn var x = MSE('.myClass');

z. B. 5 Elemente zurückgibt, also

x.nodes.length === 5 // true

ist, wäre

var x = MSE('.myClass').get(':first');

x.nodes.length === 1 // true !!

Gleiches würde passieren, wenn dies geschieht:
var x = MSE('.myClass');
x.nodes.length === 5


x.get(':first');
x.nodes.length === 1
!


Schritt 10

Wir nähern uns dem Ende! Was noch fehlt, um meine Ankündigung vom Anfang dieses Tutorials wahr zu machen, sind get(':nth(indexNummer)'), get(':even') und get(':odd').

Das sieht dann so aus:

proto.get = function(modifier){

      // Achtung, diese Variablen sind neu dazugekommen!!
      var temp = [],
            i, j;

      if(modifier){
            if(isStr(modifier)){
                 
                 if(modifier.search(NTH) > -1){
                        modifier = parseInt(modifier.replace(/\:nth\(/, '').replace(/\)/, ''), 10);
                        this.nodes = this.nodes.slice(modifier - 1, modifier);
                        this.length = 1;
                        this.selector = this.selector + modifier;
                        return this;
                 }
			
                 if(modifier === ':even'){
                        for(i = 0, j = 2; i < this.nodes.length; i = i+j){
                                temp.push(this.nodes[i]);
                        }
				
                        this.nodes = temp;
                        this.length = temp.length;
                        this.selector = this.selector + modifier;
                        return this;
                 }
			
                 if(modifier === ':odd'){
                        for(i = 1, j = 2; i < this.nodes.length; i = i+j){
				temp.push(this.nodes[i]);
                        }
				
                        this.nodes = temp;
                        this.length = temp.length;
                        this.selector = this.selector + modifier;
                        return this;
                 }
 
                 if(modifier === ':first'){
                        this.nodes = this.nodes.slice(0, 1);
                        this.length = 1;
                        this.selector = this.selector + modifier;
                        return this;
                 }

                 if(modifier === ':last'){
                        this.nodes = this.nodes.slice(this.nodes.length - 1);
                        this.length = 1;
                        this.selector = this.selector + modifier;
                        return this;
                 }

            }
      }

      return this;

}



Ende

Na also, das war ja gar nicht so schwer! Ich hoffe, dieses Tutorial hat euch genauso viel Freude gemacht wie mir und dass jetzt einiges etwas klarer wurde. Für alle, die den Code noch mal im Gesamten sehen wollen, gibt es das alles auch zum Download. Je nachdem, wie die Reaktionen ausfallen, zeige ich euch in einem weiteren Tutorial auch noch, wie wir in MSE eine Methode für die Registrierung von EventListenern realisieren, wie wir eine DOM-Ready-Methode implementieren und eine Methode für das Setzen von CSS-Rules.

Für die Fortgeschrittenen:
MSE() ist mit JSLint geprüft und unterstützt chaining (kennt man ja von jQuery).

Gruß
mindraper

Kommentare
Achtung: Du kannst den Inhalt erst nach dem Login kommentieren.

x
×
×