Skip to content

cjohansen.no

JavaScript-organisering, del 2

Igår så vi på noen utfordringer JavaScript-utviklere møter når de føler behovet for å strukturere koden sin på en oversiktelig måte. Idag presenterer jeg noen forslag til løsning på disse utfordringene.

Ytelse: antall script og filstørrelse

De to tingene som påvirker ytelsen for sluttbrukeren er antall filer, og størrelsen på dem. Begge disse er viktige, men antall filer kan fort bli et større problem enn store filer, av to grunner:

Det siste punktet betyr at dersom du har et 100kB script inkludert før et stilark så vil nettleseren ikke så mye som sniffe på stilarket (eller annen kode som følger scriptet) før hele scriptet er på plass. Dette er også grunnen til at det er anbefalt å laste inn scriptene så sent på siden som mulig.

Filstørrelse

Filstørrelsen er et smalt problem; bruk gzip og minifiser koden. Dette er tema som jeg har skrevet om tidligere. Selv bruker jeg stort sett alltid YUI Compressor, som i de fleste målinger går for å produsere de minste filene. Den fungerer også på CSS-filer.

Antall filer

Antall filer kan minimeres på to måter: enten ved å slå flere filer sammen til én/færre, eller ved å dynamisk laste inn script etter at siden har rendret ferdig. La oss se på disse to løsningene i tur.

Slå sammen filer

I går pratet jeg varmt om å dele opp koden i flere filer, så hvordan kan jeg nå anbefale å slå de sammen igjen? Vel, jeg tenker selvfølgelig på å slå dem sammen igjen som en del av prodsettingsprosessen. Her finnes det mange verktøy du kan få hjelp av. Et søk i Google etter "asset packer" gir noen resultater, både for PHP, Ruby og andre.

Felles for alle løsningene jeg har sett er at de krever en form for konfigurering. Det syns jeg er brysomt, så jeg har begynt å kode min egen løsning, som jeg tenkte å presentere kort her.

Juicer er et prosjekt jeg har hatt i magen siden januar eller noe, men som av diverse grunner har blitt lagt til side flere ganger. Juicer skiller seg fra andre løsninger på flere måter:

Juicer fungerer både for CSS og JavaScript. For CSS er det bare å kjøre

juicer merge minfil.css minfil2.css

(altså én eller flere filer) og Juicer vil slå sammen de to filene samt alle filene som er inkludert via @import i de to filene i den nye minifiserte fila minfil.min.css.

I JavaScript finnes det som vi har sett ikke noe verktøy for å importere filer ala CSS' @import. Juicer leter derfor heller etter den lignende @depends <filnavn> i kommentarer:

menu.js

/**
 * Fancy interactive menu component
 *
 * @depends lib.js
 */
window.MyApp.Menu = {
    // ...
};

lib.js

/**
 * MyApps library stuff.
 *
 * @depends ../lib/jquery.js
 */
window.MyApp = {
    // ...
};

Gitt disse filene vil følgende kommando:

juicer merge menu.js

produsere den nye filen menu.min.js som inneholder (i rekkefølge) ../lib/jquery.js, lib.js og menu.js. Filen vil også være minifisert av YUI Compressor.

Juicer er på et veldig tidlig stadie, men jeg tror det kan bli et nyttig verktøy. Hvis du har lyst til å prøve kan du installere via RubyGems (det kommer Ruby-uavhengig versjon når prosjektet modnes mer). Prosjektet er ikke "sluppet" på noe vis enda, så enn så lenge må du hente gem-en fra meg: juicer-0.1.0.gem og installere med

gem install juicer-0.1.0.gem

som krever på Ruby og RubyGems. Pass også på å følge opp de ekstra skrittene beskrevet i Readme-en, sånn at Juicer finner YUI Compressor.

Dynamisk laste inn script senere

En annen løsning på problemet er å implementere selv det som JavaScript mangler: lazy loading av script. Lazy loading av script går ut på at du først laster inn en kjerne av nødvendige verktøy og deretter bruker en algoritme i denne kjernen til å få nettleseren til å laste ned ytterligere script etterhvert som du trenger det. Dette foregår i bakgrunnen, og brukeren vil ikke merke det med mindre de plutselig kommer "for sent".

Den enkleste måten å gjøre dette på er at dynamisk bygge et nytt script-element med document.createElement for så å legge det til i head. Utfordringen ligger i å vite når scriptet er ferdig lastet, og her er det flere strategier, men de fleste bibliotekene som gjør dette tilbyr et callback til funksjonen som laster scriptet, altså noe ala:

load("min/fil.js", function() {
    // Bruk funksjonalitet i min/fil.js
});

YUI3 som er rett rundt hjørnet gjør utstrakt bruk av dette på følgende måte:

YUI().use('node', 'event', 'io', function(Y) {
    // Y er nå YUI-objektet med ønsket funksjonalitet bygget inn
});

Selvom du laster inn scriptene asynkront etter at siden er lastet er det viktig å se til at du kun serverer minifiserte filer (for å kutte nedlastingstid), og at disse fortsatt blir skikkelig cachet (for virkelig god ytelse).

Slå sammen, eller laste seint?

Så, hvilken av disse to skal man gå for? Mitt svar er at det kommer an på.

Ved å slå sammen og servere alt i én fil kan den inisielle nedlastingsmengden bli veldig stor. Dette betyr at når brukeren kommer til nettstedet ditt med tom cache kan ting lugge litt. Dette kan du også jobbe rundt ved å slå sammen til et fåtall filer, fremfor bare én. Deretter kan du anstrenge deg for å laste inn så lite som mulig på vanlige innganger (som feks forsiden), for så å servere resten når brukeren allerede har et grunnlag.

Ved å slå sammen til én eller et fåtall filer trenger du ikke bekymre deg for hvorvidt kode du ønsker å bruke er tilgjengelig. Ei heller trenger du å tenke på at du ikke må laste samme fil flere ganger og lignende problemstillinger. Å laste alt er en enklere løsning for deg som utvikler.

Hvorvidt det er akseptabelt å laste alt i en (eller et fåtall) sjau(er) kommer an på:

Dersom du sender ut veldig mye kode er det kanskje bedre å laste inn filene asynkront for å spre arbeidsmengden.

Dersom du har mye kode som brukes sjelden er det dårlig gjort å be brukeren laste ned alt i en jafs.

Dersom du laster kode asynkront er det en viss sjans for at brukeren må vente f.eks første gang hun trykker på en knapp fordi nettleseren må laste ned scriptet som skal kjøres for denne knappen. Laster du inn scriptene asynkront bør du passe på at enkeltfilene ikke er for store.

Det er mange ting å tenke på her, og midt oppe i det hele kommer det jo elementet av hva utvikleren(e) som koder på systemet foretrekker. Også bør man huske at når man serverer all koden er det bare ved tom cache at brukeren på vente. Laster du inn scriptene som det siste du gjør på siden vil også brukeren ha hele siden tilgjengelig mens scriptet laster, så det er ikke aktiv venting det er snakk om.

Med andre ord: "it depends".

Innlede kode

Når vi koder ikke-påtrengende JavaScript er alle event handlerne borte fra HTML-en vår. Dette betyr at vi trenger kode som legger til disse dynamisk. Igår nevnte jeg noen måter å gjøre dette på:

Vi så hvordan begge disse hadde både fordeler og ulemper. Heldigvis lar det seg løse. Ved å lene oss på konvensjoner kan vi kjøre all start-kode fra ett sentralt sted, men flytte komponent-spesifik kode til hver enkelt komponent.

Autoload

Jeg har den senere tiden prøvd et enkelt autoload-konsept. Hver "klasse" som har business idet siden laster definerer en autoload-metode som tar imot et options-objekt. Denne metoden inneholder relativt generisk oppstartskode, som blir sidespesifik i kraft av innsendte options.

Sentralt har jeg et lite stykke kode (vist under) som looper navnerommet for applikasjonen og finner alle "klasser" som definerer en autoload, og kjører disse:

/**
 * Fire up autoloaded objects when document is loaded.
 *
 * @depends mootools.js
 */
window.addEvent('domready', function() {
    var component, args;

    // Loop all namespaces directly under myApp namespace
    for (var object in myApp) if (myApp.hasOwnProperty(object)) {
        component = myApp[object];

        // Check that the component defines autoload
        if (!(typeof myApp.autoload === "function")) {
            continue;
        }

        // Look for arguments in myApp.autoload
        args = $defined(myApp.autoload[object]) ? myApp.autoload[object] : null;

        // Fire autoload
        $debug("Autoloading " + object);
        component.autoload(args);
    }
});

Enn så lenge er det ikke noe sidespesifik kode som kjøres. Men så kommer trikset da. I tillegg til denne koden har jeg et objekt som inneholder verdier for options for enkelte komponenter:

// Namespace for configuring autoloaded modules
myApp.autoload = {
    ModuleName: { options: "object" },
    OtherModule: {},
    // ...
};

Et praktisk eksempel

Jeg har i et aktuelt prosjekt eksempelvis en dropdownmeny. For å øke brukbarheten i den er den ikke gjort med :hover (CSS), men heller med JavaScript for å kunne implementere litt delay på mouseout osv. Denne komponenten ser ut som følger:

/**
 * Dropdown menu component. Takes a ul/ol element as argument and turns it into
 * an interactive dropdown menu. Every li directly under the list that contains
 * a ul/ol element as direct child will act as a drop down when it gets focus
 * (ie mouseover and focus from keyboard).
 *
 * @params list The list element
 */
myApp.Menu = (function() {
    // Implementation...
})();

/**
 * Autoload menu. Options are
 *
 * menuList - The ul or ol element that is the root of the menu
 *
 * @param {Object} options
 */
myApp.Menu.autoload = function(options) {
    if (options && options.menuList) {
        new myApp.Menu(options.menuList);
    }
};

Autoload-koden er som vist over, og konfigureringsobjektet ser nå ut som:

myApp.autoload = {
    // Add dropdown functionality to menu (defined in widgets/menu.js)
    Menu: { menuList: $$('#nav ul')[0] }
};

Hva har jeg oppnådd med dette?

Med andre ord er ting godt separert samtidig som de ved hjelp av noen konvensjoner henger sammen på en (forhåpentligvis) tydelig måte.

Det var noen rutiner for strukturering som jeg holder meg til. Jeg regner med at dere andre sitter på deres egne, og jeg er veldig interessert i å høre hvordan disse fungerer, og hva dere syns om opplegget jeg her har presentert.

I morgen er det årets siste "luke" i kalenderen, vel møtt!

Possibly related

2006 - 2010 Christian Johansen Creative Commons License