Hopp til innholdet

cjohansen.no

Objektorientert JavaScript

Det er mange måter å kode objektorientert JavaScript på. Følgende er en liten gjennomgang av de vanligste måtene å lage objekter på i JavaScript og noen tanker rundt dem.

Objekter i JavaScript

De fleste objektorienterte språk er klassebaserte. De har klasser som kan arve hverandre, og man kan instansiere objekter fra disse. JavaScript skiller seg ved at det er prototype-basert. Objekter arver direkte fra andre objekter, og det finnes ikke klasser. Derimot er det ganske vanlig å herme etter klasse-systemer, som vi skal se nærmere på.

Objektliteraler

Den aller enkleste måten å lage objekter på er objektliteraler:

var guy = {
    name: 'Christian',

    speak: function() {
        return 'My name is ' + this.name;
    }
};

console.log(guy.speak()); // Skriver "My name is Christian" til konsollet

Objektliteraler er ofte nyttig som hash-lignende konstrukter, for eksempel som en liste parametere til en metode, en returverdi som samler flere verdier (f.eks x og y om man returnerer posisjon) og andre lignende tilfeller. Objektliteraler kan også benyttes som utgangspunkt for hele objekthierarkier, noe jeg kommer tilbake til.

Via konstruktører

Den kanskje mest vanlige måten å lage nye objekter på når man ønsker å kunne opprette mange objekter av samme type er å definere en konstruktør, for så å bruke new-operatoren på den. En konstruktør er bare en vanlig funksjon, men det er vanlig praksis å gi metoden et navn som begynner med stor bokstav for å skille den fra andre funksjoner.

var Person = function(name) {
    this.name = name;

    this.speak = function() {
        return 'My name is ' + this.name;
    }
};

var christian = new Person('Christian');
console.log(christian.speak());

Dette kalles ofte en pseudoklasse fordi det har mange av karakteristikkene til typisk klassebasert objektorientering, men under panseret er det prototypen som jobber.

Konstruktøren har et stort problem: siden det ikke er noe teknisk skille på en funksjon og en konstruktør annet enn navnet har vi ingen garanti fra språkets side at ikke konstruktøren kan kjøres som en vanlig funksjon:

var chris = Person('Christian')
chris.speak()
// => TypeError: chris is undefined

Dette skjer fordi konstruktøren ikke returnerer noe. Det som er enda verre er at this nå ikke er bundet til et nytt Person-objekt, men snarere det globale objektet. Heldigvis kan vi relativt enkelt jobbe oss rundt dette:

var Person = function(name) {
    // Unngå at konstruktøren brukes som en vanlig funksjon
    if (!(this instanceof Person)) {
        return new Person(name);
    }

    this.name = name;

    this.speak = function() {
        return 'My name is ' + this.name;
    }
};

var christian = new Person('Christian');
console.log(christian.speak());

christian = Person('Christian'); // Sloppy
console.log(christian.speak()); // ...men det funker

Her har vi unngått at konstruktøren brukes som en vanlig funksjon ved å sjekke typen på this. Hvis vi bruker new vil this bindes til et nytt objekt før konstruktøren kjøres. Bruker vi ikke new vil this bindes til det globale objektet (tilordning til this blir da definering av globale variabler, uh-oh!). Dette kan vi sjekke, og rette opp for brukeren. Det kunne også vært aktuelt å kastet en exception istedet:

var Person = function(name) {
    if (!(this instanceof Person)) {
        throw 'Person is a constructor, use with new operator';
    }

    // ...
};

Prototypen

Når vi aksesserer en egenskap eller kaller en metode på et objekt så leter JavaScript opp denne på det aktuelle objektet. Dersom egenskapen/metoden ikke finnes, fortsetter JavaScript å lete via objektets prototype. Prototypen er ofte et objekt som igjen har sin prototype osv. JavaScript begynner dermed å lete etter egenskaper/metoder i selve objektet og fortsetter dermed opp prototype-kjeden.

Alle funksjoner har en prototype-egenskap (merk skille mellom objektegenskapen prototype og "prototypen" som konsept), som definerer hvilket prototype-objekt som skal gjelde for objekter instansiert fra denne funksjonen (når funksjonen benyttes som en konstruktør). Er den ikke definert lenkes prototypen mot Object.prototype (dette er feks tilfelle for objektliteraler).

var SuperHuman = function(name, power) {
    this.name = name;
    this.power = power;
};

// Lenk SuperHuman til Person via prototypen
// SuperHuman-objekter vil automatisk ha funksjonalitet definert i Person
SuperHuman.prototype = new Person();

// Legg til en metode
SuperHuman.prototype.performPower = function() {
    // ...
};

var hulk = new SuperHuman('Hulk', 'Turn green when angry');
hulk.speak(); // "My name is Hulk"
hulk.performPower(); // ...

Her ser vi et eksempel på bruk av prototypen. Når vi har en konstruktør hvor vi definerer prototypen er det nærliggende å sammenligne prototypen med en klasse, noe som i mange praktiske hensyn ikke er så ulikt, men som alikevel er fundamentalt forskjellig.

I eksempelet over definerer vi for eksempel metoden speakPerson-konstruktøren. Denne overføres til SuperHuman via SuperHuman.prototype. Når vi ber et SuperHuman-objekt utføre metoden speak skjer følgende:

  1. JavaScript sjekker objektet - ingen metode funnet
  2. JavaScript sjekker prototypen - metoden funnet
  3. Metoden fra prototype-objektet kjøres i skopet til objektet (altså med this som SuperHuman-objektet, trass i at metoden er definert i prototypen som er et Person-objekt)

Det er nå mulig å definere en speak-metode for et enkelt objekt. I dette tilfelle maskeres prototype-versjonen av metoden, men den forsvinner ikke, hverken for det aktuelle objektet eller for andre objekter basert på samme konstruktør.

Et eksempel, por favor:

var Person = function(name) {
    this.name = name;

    this.speak = function() {
        console.log('My name is ' + this.name);
    }
};

var SuperHuman = function(name, power) {
    this.name = name;
    this.power = power;
};

SuperHuman.prototype = new Person();

var hulk = new SuperHuman('Hulk', 'Turn green when angry');
hulk.speak(); // "My name is Hulk" - fra Person

hulk.speak = function() {
    console.log('Aaaaaargh');
};

hulk.speak(); // "Aaaaaargh"
delete hulk.speak;
hulk.speak(); // "My name is Hulk" - tilbake til prototypen

Hvor skal vi putte metodene?

Allerede begynner det å åpenbare seg flere steder man kan definere metoder, med lignende utfall. For eksempel kan vi definere metoder direkte på this inne i konstruktøren - disse metodene (og egenskapene) legges direkte på objektet. Metodene (og egenskapene) vi legger rett på prototypen legges kun på prototypen, ikke på enkeltobjekter.

Dersom vi legger en egenskap på prototypen til en konstruktør så vil alle objekter fra denne konstruktør dele denne samme verdien. Faktisk kopieres ikke verdien over på objektet i det hele tatt - objektet slår som forklart opp egenskapen eller metoden via prototypen. Først når du tilegner en ny verdi for et gitt objekt kopieres egenskapen eller metoden over på selve objektet.

Egenskaper og metoder definert på prototypen kun eksisterer én gang uavhengig av antall objekter, mens egenskaper definert på this i konstruktøren defineres rett på objektene - én gang per objekt. Dette betyr at å definere egenskaper og metoder på prototypen er langt mer minneeffektivt fordi du bare har en instans av metoden selv om du har uendelig mange objekter.

Prototypen for objekter

Som nevnt har alle funksjoner (egentlig konstruktører, men alle funksjoner er potensielle konstruktører, det er bruken som skiller dem) sin protoype. Objekter har ikke denne. Så hvordan henger prototypekjeden sammen? Denne artikkelen går lange veier for å forklare hvordan objekter henger sammen via en skjult link til prototypen.

I Firefox eksponeres denne linken via egenskapen __proto__. Les den nevnte artikkelen for en forklaring, men bruk __proto__ kun for testing/læring - den finnes for eksempel ikke i IE.

Tilgangskontroll

Vi kan med JavaScript lage public, private og privilegerte metoder. En priviligert metode er en metode som er public, men som har tilgang på private egenskaper og metoder.

var Person = function(name) {
    var doSomething = function() {
        // Denne metoden er privat
    };

    this.setName = function(newName) {
        // Denne metoden er priviligert - den setter verdien på den private egenskapen name
        name = newName;
    };

    this.getName = function() {
        // Også priviligert
        return name;
    };
};

Person.prototype.speak = function() {
    // Denne er public, men har ikke tilgang til private egenskaper og metoder
    return 'My name is ' + this.getName();
};

var chris = new Person('Christian');
chris.name; // Ingenting, egenskapen finnes ikke hverken på objektet eller prototypekjeden
chris.getName(); // 'Christian'
chris.setName('Christian J');
chris.speak(); // 'My name is Christian J'

Fordi this.prototype ikke er tilgjengelig i konstruktøren så kan vi slutte at alle priviligerte metoder (altså, metoder som trenger tilgang til private egenskaper) må defineres rett på objektet, og kan ikke settes på prototypen. Dette kan medføre ytelsesproblemer dersom du skal instansiere mange objekter.

Funksjonsbasert

Man kan også bruke helt vanlige funksjoner til å lage nye objekter, noe jeg touchet innom i et tidligere eksempel (der jeg sjekket om metoden/konstruktøren ble kalt med new-operatoren). En funksjonstilnærming går ut på å lage en funksjon som returnerer et objekt. Objektet kan opprettes inne i funksjonen på en hvilken som helst av de ovenfornevnte måtene.

I dette mønsteret er alle variabler og funksjoner vi definerer inne i metoden (men utenom selve objektet som blir opprettet) private egenskaper/metoder, og alle metoder på objektet er priviligerte. Merk også at hele objektet defineres direkte i funksjonen, slik at vi ikke nyter fordelen av å gjemme bort delt funksjonalitet på prototype-egenskapen.

var person = function(name) {
    // name er her en privat egenskap

    // Dette er en privat metode
    function help() {
        // ...
    }

    return {
        speak: function() {
            return 'My name is ' + name + help();
        },

        setName: function(newName) {
            name = newName;
        },

        getName: function() {
            return name;
        }
    };
};

Her sparer vi objektets tilstand i variabler og funksjoner i en closure. Dermed består objektet av et "faktisk objekt" og en kontekst, der det "faktiske objektet" definerer det ytre API-et og konteksten (closuren) den indre tilstanden.

Objekthierarkier

Som jeg nevnte innledningsvis er det fullt mulig - og kanskje i beste JavaScript-ånd - å begynne med et enkelt objekt, for eksempel fra et objektliteral, og så lage nye objekter ved å linke de sammen via prototypen.

var obj = { name: 'Testobjekt' };

var F = function() {};
F.prototype = obj;

var newObj = new F();

newObj er nå et nytt objekt basert på det opprinnelige obj. Som dere ser er dette litt voldsomt rent syntaktisk, så om du går for denne varianten er det greit med en hjelpemetode:

function spawn(obj) {
    var F = function() {};
    F.prototype = obj;

    return new F();
}

var person = {
    speak: function() {
        console.log('My name is ' + this.name);
    }
};

var chris = spawn(person);
chris.name = "Christian";
chris.speak(); // "My name is Christian"

Hvilken skal jeg bruke?

Dette er et åpenbart spørsmål når det finnes så mange måter å gjøre dette på, også andre metoder jeg ikke har dekket her. Det er mange veier til Rom, og hvilken man bruker er langt på vei et spørsmål om smak og stil. Det man kan tenke på er som nevnt over hvorvidt man legger egenskaper direkte på objektene eller på prototypen.

Hvordan skriver du objektorientert JavaScript?

Muligens relatert

2006 - 2010 Christian Johansen Creative Commons Lisens