Mönster för objektärvning i JavaScript ES2015

Med den efterlängtade ankomsten av ES2015 (tidigare känd som ES6), är JavaScript utrustad med syntax specifikt för att definiera klasser. I den här artikeln ska jag undersöka om vi kan utnyttja klasssyntaxen för att komponera klasser av mindre delar.

Att hålla hierarkidjupet till ett minimum är viktigt för att hålla din kod ren. Att vara smart med hur du delar upp klasser hjälper. För en stor kodbas är ett alternativ att skapa klasser av mindre delar; komponerande klasser. Det är också en vanlig strategi för att undvika duplicerad kod.

Föreställ dig att vi bygger ett spel där spelaren lever i en värld av djur. Vissa är vänner, andra är fientliga (en hundperson som jag kan säga att alla katter är fientliga varelser). Vi skulle kunna skapa en klass HostileAnimal , som utökar Animal , för att fungera som en basklass för Cat . Vid något tillfälle bestämmer vi oss för att lägga till robotar designade för att skada människor. Det första vi gör är att skapa Robot klass. Vi har nu två klasser som har liknande egenskaper. Båda HostileAnimal och Robot kan attack() , till exempel.

Om vi ​​på något sätt kunde definiera fientlighet i en separat klass eller objekt, säg Hostile , vi kan återanvända det för båda Cat som Robot . Vi kan göra det på olika sätt.

Multipelt arv är en funktion som vissa klassiska OOP-språk stöder. Som namnet antyder ger det oss möjligheten att skapa en klass som ärver från flera basklasser. Se hur Cat class utökar flera basklasser i följande Python-kod:

class Animal(object):
  def walk(self):
    # ...

class Hostile(object):
  def attack(self, target):
    # ...

class Dog(Animal):
  # ...

class Cat(Animal, Hostile):
  # ...

dave = Cat();
dave.walk();
dave.attack(target);

Ett gränssnitt är ett vanligt inslag i (skrivna) klassiska OOP-språk. Det låter oss definiera vilka metoder (och ibland egenskaper) en klass ska innehålla. Om den klassen inte gör det kommer kompilatorn att visa ett fel. Följande TypeScript-kod skulle ge ett felmeddelande om Cat hade inte attack() eller walk() metoder:

interface Hostile {
  attack();
}

class Animal {
  walk();
}

class Dog extends Animal {
  // ...
}

class Cat extends Animal implements Hostile {
  attack() {
    // ...
  }
}

Multipelarv lider av diamantproblemet (där två överordnade klasser definierar samma metod). Vissa språk undviker detta problem genom att implementera andra strategier, som mixins . Mixins är små klasser som bara innehåller metoder. Istället för att utöka dessa klasser ingår blandningar i en annan klass. I PHP, till exempel, implementeras mixins med hjälp av Traits.

class Animal {
  // ...
}

trait Hostile {
  // ...
}

class Dog extends Animal {
  // ...
}

class Cat extends Animal {
  use Hostile;
  // ...
}

class Robot {
  use Hostile;
  // ...
}

En sammanfattning:ES2015 Class Syntax

Om du inte har haft chansen att dyka in i ES2015-klasser eller känner att du inte vet tillräckligt om dem, var noga med att läsa Jeff Motts objektorienterade JavaScript — A Deep Dive into ES6 Classes innan du fortsätter.

I ett nötskal:

  • class Foo { ... } beskriver en klass som heter Foo
  • class Foo extends Bar { ... } beskriver en klass, Foo , som utökar en annan klass, Bar

Inom klassblocket kan vi definiera egenskaper för den klassen. För den här artikeln behöver vi bara förstå konstruktörer och metoder:

  • constructor() { ... } är en reserverad funktion som exekveras vid skapandet (new Foo() )
  • foo() { ... } skapar en metod som heter foo

Klasssyntaxen är mest syntaktisk socker över JavaScripts prototypmodell. Istället för att skapa en klass skapar den en funktionskonstruktor:

class Foo {}
console.log(typeof Foo); // "function"

Uttaget här är att JavaScript inte är ett klassbaserat, OOP-språk. Man kan till och med hävda att syntaxen är vilseledande och ger intrycket att den är det.

Skapa ES2015-klasser

Gränssnitt kan efterliknas genom att skapa en dummy-metod som ger ett fel. När den har ärvts måste funktionen åsidosättas för att undvika felet:

class IAnimal {
  walk() {
    throw new Error('Not implemented');
  }
}

class Dog extends IAnimal {
  // ...
}

const robbie = new Dog();
robbie.walk(); // Throws an error

Som föreslagits tidigare bygger detta tillvägagångssätt på arv. För att ärva flera klasser behöver vi antingen flera arv eller mixins.

Ett annat tillvägagångssätt skulle vara att skriva en hjälpfunktion som validerar en klass efter att den definierats. Ett exempel på detta finns i Vänta ett ögonblick, JavaScript stöder flera arv! av Andrea Giammarchi. Se avsnittet "En grundläggande objekt.implementfunktionskontroll."

Dags att utforska olika sätt att tillämpa flera arv och mixins. Alla undersökta strategier nedan är tillgängliga på GitHub.

Object.assign(ChildClass.prototype, Mixin...)

Före ES2015 använde vi prototyper för arv. Alla funktioner har en prototype fast egendom. När du skapar en instans med new MyFunction() , prototype kopieras till en egenskap i instansen. När du försöker komma åt en egenskap som inte finns i instansen kommer JavaScript-motorn att försöka slå upp den i prototypobjektet.

För att demonstrera, ta en titt på följande kod:

function MyFunction () {
  this.myOwnProperty = 1;
}
MyFunction.prototype.myProtoProperty = 2;

const myInstance = new MyFunction();

// logs "1"
console.log(myInstance.myOwnProperty);
// logs "2"
console.log(myInstance.myProtoProperty);

// logs "true", because "myOwnProperty" is a property of "myInstance"
console.log(myInstance.hasOwnProperty('myOwnProperty'));
// logs "false", because "myProtoProperty" isn’t a property of "myInstance", but "myInstance.__proto__"
console.log(myInstance.hasOwnProperty('myProtoProperty'));

Dessa prototypobjekt kan skapas och modifieras under körning. Till en början försökte jag använda klasser för Animal och Hostile :

class Animal {
  walk() {
    // ...
  }
}

class Dog {
  // ...
}

Object.assign(Dog.prototype, Animal.prototype);

Ovanstående fungerar inte eftersom klassmetoder inte kan räknas upp . I praktiken betyder detta Object.assign(...) kopierar inte metoder från klasser. Detta gör det också svårt att skapa en funktion som kopierar metoder från en klass till en annan. Vi kan dock kopiera varje metod manuellt:

Object.assign(Cat.prototype, {
  attack: Hostile.prototype.attack,
  walk: Animal.prototype.walk,
});

Ett annat sätt är att ta bort klasser och använda objekt som mixins. En positiv bieffekt är att mixin-objekt inte kan användas för att skapa instanser, vilket förhindrar missbruk.

const Animal = {
  walk() {
    // ...
  },
};

const Hostile = {
  attack(target) {
    // ...
  },
};

class Cat {
  // ...
}

Object.assign(Cat.prototype, Animal, Hostile);

Proffs

  • Mixins kan inte initieras

Nackdelar

  • Kräver en extra kodrad
  • Object.assign() är lite oklar
  • Återuppfinna prototypiskt arv för att fungera med ES2015-klasser

Komponera objekt i konstruktörer

Med ES2015-klasser kan du åsidosätta instansen genom att returnera ett objekt i konstruktorn:

class Answer {
  constructor(question) {
    return {
      answer: 42,
    };
  }
}

// { answer: 42 }
new Answer("Life, the universe, and everything");

Vi kan utnyttja den funktionen för att komponera ett objekt från flera klasser i en underklass. Observera att Object.assign(...) fungerar fortfarande inte bra med mixin-klasser, så jag använde objekt här också:

const Animal = {
  walk() {
    // ...
  },
};

const Hostile = {
  attack(target) {
    // ...
  },
};

class Cat {
  constructor() {
    // Cat-specific properties and methods go here
    // ...

    return Object.assign(
      {},
      Animal,
      Hostile,
      this
    );
  }
}

Sedan this hänvisar till en klass (med icke-antal metoder) i ovanstående sammanhang, Object.assign(..., this) kopierar inte metoderna för Cat . Istället måste du ställa in fält och metoder på this uttryckligen för Object.assign() för att kunna tillämpa dessa, som så:

class Cat {
  constructor() {
    this.purr = () => {
      // ...
    };

    return Object.assign(
      {},
      Animal,
      Hostile,
      this
    );
  }
}

Detta tillvägagångssätt är inte praktiskt. Eftersom du returnerar ett nytt objekt istället för en instans, motsvarar det i huvudsak:

const createCat = () => Object.assign({}, Animal, Hostile, {
  purr() {
    // ...
  }
});

const thunder = createCat();
thunder.walk();
thunder.attack();

Jag tror att vi kan hålla med om att det senare är mer läsbart.

Proffs

  • Det fungerar, antar jag?

Nackdelar

  • Mycket obskyrt
  • Noll nytta av ES2015-klasssyntax
  • Misbruk av ES2015-klasser

Klassfabriksfunktion

Detta tillvägagångssätt utnyttjar JavaScripts förmåga att definiera en klass vid körning.

Först behöver vi basklasser. I vårt exempel, Animal och Robot fungera som basklasser. Om du vill börja om från början fungerar en tom klass också.

class Animal {
  // ...
}

class Robot {
  // ...
}

Därefter måste vi skapa en fabriksfunktion som returnerar en ny klass som utökar klassen Base , som skickas som en parameter. Dessa är mixinerna:

const Hostile = (Base) => class Hostile extends Base {
  // ...
};

Nu kan vi skicka vilken klass som helst till Hostile funktion som returnerar en ny klass som kombinerar Hostile och vilken klass vi än skickade till funktionen:

class Dog extends Animal {
  // ...
}

class Cat extends Hostile(Animal) {
  // ...
}

class HostileRobot extends Hostile(Robot) {
  // ...
}

Vi skulle kunna gå igenom flera klasser för att tillämpa flera mixins:

class Cat extends Demonic(Hostile(Mammal(Animal))) {
  // ...
}

Du kan också använda Object som basklass:

class Robot extends Hostile(Object) {
  // ...
}

Proffs

  • Lättare att förstå eftersom all information finns i klassdeklarationens rubrik

Nackdelar

  • Att skapa klasser vid körning kan påverka startprestanda och/eller minnesanvändning

Slutsats

När jag bestämde mig för att undersöka detta ämne och skriva en artikel om det, förväntade jag mig att JavaScripts prototypiska modell skulle vara till hjälp för att skapa klasser. Eftersom klasssyntaxen gör att metoderna inte kan räknas upp, blir objektmanipulation mycket svårare, nästan opraktisk.

Klasssyntaxen kan skapa illusionen att JavaScript är ett klassbaserat OOP-språk, men det är det inte. Med de flesta tillvägagångssätt måste du modifiera ett objekts prototyp för att efterlikna flera arv. Det sista tillvägagångssättet, med klassfabriksfunktioner, är en acceptabel strategi för att använda mixins för att komponera klasser.

Om du tycker att prototypbaserad programmering är restriktiv, kanske du vill titta på ditt tänkesätt. Prototyper ger oöverträffad flexibilitet som du kan dra nytta av.

Om du, av någon anledning, fortfarande föredrar klassisk programmering, kanske du vill titta på språk som kompilerar till JavaScript. TypeScript, till exempel, är en superset av JavaScript som lägger till (valfritt) statisk skrivning och mönster som du känner igen från andra klassiska OOP-språk.

Kommer du att använda någon av ovanstående metoder i dina projekt? Hittade du bättre tillvägagångssätt? Låt mig veta i kommentarerna!

Den här artikeln har granskats av Jeff Mott, Scott Molinari, Vildan Softic och Joan Yin. Tack till alla SitePoints experter för att göra SitePoint-innehåll till det bästa det kan bli!