Hopp til innholdet

cjohansen.no

Hjelperen $()

De fleste rammeverkene tilbyr en "magisk" "dollarfunksjon". Vi ser litt på hva den ofte gjør, og hvordan den kan realiseres.

Dagens innlegg er først og fremst en tutorial. Når vi er ferdig kan vi gjøre følgende:

var element = $("element");
element.addClassName("super");
console.log(element.hasClassName("super")); // true

Formålet med denne øvelsen er:

Bakgrunn

De fleste rammeverkene har en funksjon hvis navn kun er et dollartegn. Både prototype.js og jQuery var tidlig ute med dette. Vanligvis fungerer dollaren slik at den henter elementer på id (altså samme som document.getElementById(id)) - men i tillegg er det returnerte elementet utvidet med HTML-spesifikk nyttefunksjonalitet så som feks addClassName(className) eller computedStyle(property).

I tillegg er det vanlig at funksjonen er litt fleksibel i forhold til input - gir du den et DOM-element får du det tilbake med ekstra funksjonalitet og gir du den en array får du en array med elementer tilbake. På denne måten kan du eksempelvis kalle funksjonen uavhengig om du vet om en verdi er en streng (id på felt) eller et faktisk felt.

Spesifikasjon

Ok, før vi begynner kan vi starte med en kort liste over funksjonalitet:

Legg merke til at det er forskjell mellom å ta imot vilkårlig mange strenger og en array med strenger:

$("vilkårlig", "mange", "strenger");
$(["en", "array", "med", "strenger"])

Testoppsett

Vi bruker YUI Test, og har følgende simple test-HTML:

<div id="sample"></div>
<div id="sample2"></div>
<div id="sample3"></div>

i tillegg har testcaset en setup-metode, denne kjøres før hver test i testcaset:

setUp: function() {
    this.sample = document.getElementById("sample");
    this.sample2 = document.getElementById("sample2");
    this.sample3 = document.getElementById("sample3");
}

Finne elementer

La oss starte med å hente elementer. Funksjonen skal støtte et mangfold av input. Vi ser for oss en metode getElements i navnerommet cj som gjør denne jobben:

testGetWithStrings: function() {
    var assert = YAHOO.util.Assert;

    // Single string
    assert.areEqual(this.sample, cj.getElements("sample"));

    // Arbitrary many arguments
    var elements = cj.getElements("sample", "sample1");
    assert.areEqual(this.sample, elements[0]);
    assert.areEqual(this.sample1, elements[1]);

    // Array argument
    elements = cj.getElements(["sample", "sample1"]);
    assert.areEqual(this.sample, elements[0]);
    assert.areEqual(this.sample1, elements[1]);
},

testGetWithElements: function() {
    var assert = YAHOO.util.Assert;

    // Single element
    assert.areEqual(this.sample, cj.getElements(this.sample));

    // Arbitrary many arguments
    var elements = cj.getElements(this.sample, this.sample1);
    assert.areEqual(this.sample, elements[0]);
    assert.areEqual(this.sample1, elements[1]);

    // Array argument
    elements = cj.getElements([this.sample, this.sample1]);
    assert.areEqual(this.sample, elements[0]);
    assert.areEqual(this.sample1, elements[1]);
},

testGetWithMixed: function() {
    var assert = YAHOO.util.Assert;

    // Arbitrary many arguments
    var elements = cj.getElements("sample", this.sample1);
    assert.areEqual(this.sample, elements[0]);
    assert.areEqual(this.sample1, elements[1]);

    // Array argument
    elements = cj.getElements([this.sample, "sample1"]);
    assert.areEqual(this.sample, elements[0]);
    assert.areEqual(this.sample1, elements[1]);
}

Kjør testene (de skal feile).

Implementasjonen

Implementasjonen er relativt rett frem:

var cj = {};

cj.getElements = function() {
    // Extract array argument
    var args = arguments;
    args = args.length === 1 && args[0] instanceof Array ? args[0] : args;

    // Return array if there were more than one argument
    // (or argument was an array)
    if (args.length > 1) {
        var results = [];

        for (var i = 0; i < args.length; i++) {
            results.push(cj.getElements(args[i]));
        }

        return results;
    }

    // Process single argument
    return typeof args[0] === "string" ?
           document.getElementById(args[0]) : args[0];
};

Jeg minner om at arguments er en array-lignende verdi som inneholder en liste med alle parameterne funksjonen ble kalt med. Dersom det er flere enn én parameter, eller det ene parameteret er en array kjører vi funksjonen for hver enkelt parameter og returnerer resultatet i en array.

Vi kan verifisere at denne koden løser det nåværende problemet ved å kjøre testene.

Ekstrafunksjonalitet

Nå som vi har funksjonen som henter elementer på plass kan vi se på tilleggsfunksjonaliteten. Det ble nevnt at det skal være nogenlunde lett å legge til tilleggsfunksjonalitet, så det kan jo være en idé å samle alle utvidelses-metodene i et eget navnerom. Vi snakker jo om elementer, så cj.Element gir mening.

La oss for øyeblikket skyve til siden det faktum at metodene skal settes på de returnerte objektene, og heller implementere funksjoner som kan kalles med elementet som parameter:

testHasClassName: function() {
    var assert = YAHOO.util.Assert;

    assert.isFalse(cj.Element.hasClassName(this.sample, 'test'));

    this.sample.className = "holy moly";
    assert.isTrue(cj.Element.hasClassName(this.sample, 'holy'));
    assert.isFalse(cj.Element.hasClassName(this.sample, 'hol'));
},

testAddClassName: function() {
    var assert = YAHOO.util.Assert;

    cj.Element.addClassName(this.sample, 'test');
    assert.areEqual('test', this.sample.className);
    assert.isTrue(cj.Element.hasClassName(this.sample, 'test'));

    cj.Element.addClassName(this.sample, 'another');
    assert.areEqual('test another', this.sample.className);
},

testRemoveClassName: function() {
    var assert = YAHOO.util.Assert;
    this.sample.className = "test another";
    cj.Element.removeClassName(this.sample, "another");
    assert.areEqual("test", this.sample.className);

    cj.Element.removeClassName(this.sample, "bla");
    assert.areEqual("test", this.sample.className);

    cj.Element.removeClassName(this.sample, "test");
    assert.areEqual("", this.sample.className);
}

Kjør testene (de feiler).

Implementasjon

Implementasjonen her er ganske rett frem. For å legge til klassenavnet er det bare å legge til en space og det nye klassenavnet. Fjerning og testing på klassenavnet kan gjøres med et regulært uttrykk. Som illustrert i testHasClassName er vi nøye på å sjekke at hasClassName("tes") gir feil når elementet har klassen test.

Nå som vi har et navnerom cj.Element føles det jo naturlig for getElements også å ligge her, så vi døper i samme slengen den om til cj.Element.get. Hele løsningen så langt ser da ut som:

String.prototype.trim = function() {
    return this.replace(/^\s+|\s+$/g, '');
};

var cj = {};

cj.Element = {
    get: function() {
        // Extract array argument
        var args = arguments;
        args = args.length === 1 && args[0] instanceof Array ? args[0] : args;

        if (args.length > 1) {
            var results = [];

            for (var i = 0; i < args.length; i++) {
                results.push(cj.getElements(args[i]));
            }

            return results;
        }

        // Process single argument
        return typeof args[0] === "string" ?
           document.getElementById(args[0]) : args[0];
    },

    hasClassName: function(element, className) {
        return new RegExp("\\b" + className + "\\b").test(element.className);
    },

    addClassName: function(element, className) {
        if (!cj.Element.hasClassName(element, className)) {
            element.className = (element.className + " " + className).trim();
        }
    },

    removeClassName: function(element, className) {
        var regex = new RegExp("\\b" + className + "\\b");
        element.className = element.className.replace(regex, "").replace(/\s+/, " ");
    }
};

Kjør testene. Oops! Nå kjører klassenavn-testene våre, men metoden som finner elementer feiler i alle tre testene. Vel, det viser seg at vi bare har glemt å reflektere refaktoreringen i testene våre. Det fikser vi fort, voila!

Utvidede elementer

I implementasjonen over hentet jeg inn String.prototype.trim-implementasjonen som vi gjorde tidligere. Hvorfor løste jeg ikke klassenavns-funksjonene på tilsvarende måte (altså ved å legge dem i noens prototype)? Vel, jeg kunne ha gjort som følger:

HTMLElement.prototype.hasClassName = function(className) {
    return new RegExp("\\b" + className + "\\b").test(this.className);
};

...og det ville ha fungerte i alle fornuftige nettlesere. Dette inkluderer som kjent ikke Internet Exploder. I IE fungerer det ikke å jobbe med prototypen til Element (eller HTMLElement). Dette fungerer for eksempel i Firefox, og da vil det også funke selvom du henter elementene "manuelt" via document.getElementById(id). Men siden IE ikke er med på leken må vi utvide enkeltelementer.

extend

Ok, så la oss lage en funksjon som utvider et element:

testExtend: function() {
    var assert = YAHOO.util.Assert;

    cj.Element.extend(this.sample2);
    assert.isNotUndefined(this.sample2.hasClassName);
    assert.isFalse(this.sample2.hasClassName("test"));

    this.sample2.className = "test";
    assert.isTrue(this.sample2.hasClassName("test"));

    assert.isUndefined(this.sample2.get, "Get method should not be transferred");
    assert.isUndefined(this.sample2.extend, "Extend method should not be transferred");
    assert.isNotUndefined(this.sample2.addClassName);
},

testAutoExtend: function() {
    var assert = YAHOO.util.Assert;
    var sample3 = document.getElementById("sample3");

    var sample3b = cj.Element.get("sample3");
    assert.areEqual(sample3, sample3b);
    assert.isNotUndefined(sample3.hasClassName);
}

Den siste testen er ment å sjekke at elementer er utvidet automatisk (altså gjennom prototypen til HTMLElement). Den vil aldri passere i IE. Kjør testen (som feiler).

Implementasjon

Implementasjonen av denne metoden er denne tutorialens mest interessante. La oss se koden først og gå gjennom den etterpå:

extend: function(element) {
    // Already extended, abort
    if (element.hasClassName) {
        return element;
    }

    // Loop all methods in cj.Element
    for (var method in cj.Element) if (cj.Element.hasOwnProperty(method)) {
        // Skip get() and extend()
        if (method === "get" || method === "extend") {
            continue;
        }

        // Anonymous closure - scope
        (function() {
            var methodName = method;

            // Extend element with method
            element[methodName] = function() {
                // First argument is element
                var args = [element];

                // Complete argument list with arguments passed to the method
                for (var i = 0; i < arguments.length; i++) {
                    args.push(arguments[i]);
                }

                // Run original method in elements context
                return cj.Element[methodName].apply(element, args);
            };
        })();
    }

    return element;
}

Aller først sjekker vi at elementet ikke allerede er utvidet ved å teste for en vilkårlig valgt utvidelses-metode. På denne måten unngår vi å utvide elementer mer enn én gang. Senere skal dette bli nyttig i nettlesere hvor prototype-magien er nok, da vil ingen enkeltelementer bli utvidet på denne måten fordi de allerede arver funksjonaliteten gjennom prototypen sin.

Neste skritt er å loope gjennom alle metodene i cj.Element. Vi sjekker at vi ikke ved en feil får egenskaper og/eller metoder som arves fra prototypekjeden ved å sjekke cj.Element.hasOwnProperty(method), og deretter avbryter vi dersom metoden er get eller extend - disse trenger ikke å legges til på elementet.

Så blir det interessant:

  1. Metoden vi tilordner elementet skaper en closure. Denne closuren vil i utgangspunktet ha referanse til det samme lokale skopet for alle metodene, noe som vil føre til at method i loopen vil være den samme verdien for alle metodene. For å unngå dette lager vi en anonym closure for å redefinere skopet og setter metodenavnet i en lokal variabel. (Puh!)
  2. Vi benytter oss av at i JavaScript så er obj[prop] === obj.prop for alle typer verdier (også metoder) og tilordner en ny metode på elementet.
  3. Fremfor å tilordne metoden fra cj.Element direkte tilordner vi en anonym funksjon som legger til elementet som utvides som første parameter.
  4. Den anonyme funksjonen kjører den opprinnelige metoden med elementet som første parameter til apply (dvs at this inne i metoden i cj.Element vil være elementet når metoden kjøres via elementet).

Det dette betyr er at vi nå kan legge til så mange metoder vi bare vil i cj.Element, så lenge metoden tar et element som første parameter. Vi kan verifisere ved å kjøre testene.

Autoextending og sammenkobling

Foreløpig skjer det ikke noe extending fra cj.Element.get. Dette er en triviell sak å ordne:

// Siste linje i cj.Element.get endres til
return cj.Element.extend(typeof args[0] === "string" ?
       document.getElementById(args[0]) : args[0]);

Et annet problem er at autoextending ikke virker. Det er ikke så rart siden vi ikke har gjort noe for å få det til. Med den testen vi nå har i extend så kan vi trygt utvide HTMLElement.prototype for nettlesere som støtter det - elementene vil ikke bli dobbeltutvidet alikevel.

Å legge til tilsvarende funksjonalitet på HTMLElement.prototype er heldigvis ganske simpelt og kan øke ytelsen på løsningen vår betraktelig. Utvidelsen er akkurat som andre utvidelser: vi skal utvide et objekt - i dette tilfellet HTMLElement.prototype - med et sett med egenskaper:

cj.Element.extend(HTMLElement.prototype);

Denne løsningen byr kun på ett problem:

var args = [element];

Når vi utvider prototypen er det prototypen som er element, mens elementet er tilgjengelig som this (fordi vi nå opererer i konteksten til prototypen til HTMLElement). Dette kan vi løse for begge tilfeller med en kondisjonell tilordning:

var args = [this instanceof Element ? this : element];

Kjør testene.

Final touches

Vel, da var vi nesten i mål. Vi mangler nå bare den karakteristiske signaturen:

test$: function() {
    var assert = YAHOO.util.Assert;
    var sample3 = $("sample3");
    assert.isNotUndefined(sample3.hasClassName);
}

Som kan implementeres relativt smertefritt:

if (!window.$) {
    window.$ = cj.Element.get;
}

Legg merke til at vi er defensive nok til å passe oss for å overskrive eventuelt eksisterende dollar-funksjoner.

Kjør hele testcaset.

Dette kan være et svært nyttig lite verktøy på mindre prosjekter der mengden kode ikke tilsier at man trenger et helt rammeverk. Uansett er det en morsom øvelse å implementere.

Håper noen fant dette interessant. Vel møtt i morgen for en titt på kjeding av metodekall.

Muligens relatert

2006 - 2010 Christian Johansen Creative Commons Lisens