ES6 – classes and inheritance

javascript

OO (Object Oriented) – termin ten był chyba najbardziej wyczekiwaną częścią nowego standardu ECMAScript. Wprowadzenie klas, to coś zupełnie świeżego w świecie JavaScript. Wraz z ES6 otrzymujemy spójne podejście do tworzenia obiektów. Nową funkcjonalność zbudowano ponad to w oparciu o prototypy, by zachować wsteczną kompatybilność.


Zobacz całą serię: Let-s talk about ECMAScript 2015


Zacznijmy od przykładu prostej klasy w ES6:

class Vehicle {
 
  constructor (name, type) {
    this.name = name;
    this.type = type;
  }
 
  getName () {
    return this.name;
  }
 
  getType () {
    return this.type;
  }
 
}

let car = new Vehicle('Tesla', 'car');
console.log(car.getName()); // Tesla
console.log(car.getType()); // car

Widzimy tutaj, że pojawiają nam się nowe słowa kluczowe class oraz constructor, które znane są z innych obiektowych języków programowania. Mamy również dwie metody: getName() oraz getType(). Poniżej odpowiednik tej klasy z wykorzystaniem składni ES5:

function Vehicle (name, type) {
  this.name = name;
  this.type = type;
};
 
Vehicle.prototype.getName = function getName () {
  return this.name;
};
 
Vehicle.prototype.getType = function getType () {
  return this.type;
};

var car = new Vehicle('Tesla', 'car');
console.log(car.getName()); // Tesla
console.log(car.getType()); // car

Jest to podejście oparte o prototype.

dziedziczenie

ECMAScript2015 wspiera dziedziczenie, wykonywanie kodu klas dziedziczonych (super), metody statyczneinstancyjne oraz konstrukcję obiektu poprzez słowo kluczowe constructor.

Rozbudujmy poprzedni przykład o dziedziczenie. Zacznę tym razem od wersji ES5:

function Vehicle (name, type) {
  this.name = name;
  this.type = type;
};
 
Vehicle.prototype.getName = function getName () {
  return this.name;
};
 
Vehicle.prototype.getType = function getType () {
  return this.type;
};

function Car (name) {
  Vehicle.call(this, name, ‘car’);
}

Car.prototype = Object.create(Vehicle.prototype);
Car.prototype.constructor = Car;
Car.parent = Vehicle.prototype;
Car.prototype.getName = function () {
  return 'It is a car: '+ this.name;
};

var car = new Car('Tesla');
console.log(car.getName()); // It is a car: Tesla
console.log(car.getType()); // car

Widzimy, że całość mocno się komplikuje, a to przecież prosty przykład. Spójrzmy teraz na nowe podejście:

class Vehicle {
 
  constructor (name, type) {
    this.name = name;
    this.type = type;
  }
 
  getName () {
    return this.name;
  }
 
  getType () {
    return this.type;
  }
 
}

class Car extends Vehicle {
 
  constructor (name) {
    super(name, 'car');
  }
 
  getName () {
    return 'It is a car: ' + super.getName();
  }
 
}

let car = new Car('Tesla');
console.log(car.getName()); // It is a car: Tesla
console.log(car.getType()); // car

Wygląda znajomo? Z pewnością jest to znacznie lepsze rozwiązanie. Mamy tutaj klasę Vehicle oraz Car, która przy pomocy extends dziedziczy po tej pierwszej. W konstruktorze klasy Car wykonywany jest ponad to konstruktor klasy dziedziczonej poprzez wywołanie z super. Dodatkowo metoda getName() zostaje nadpisana w klasie Car i wykonuje metodę getName() z klasy dziedziczonej również przy użyciu słowa kluczowego super.

By w ES5 osiągnąć podobną funkcjonalność musielibyśmy kombinować z wykorzystaniem metod call lub apply.

static

Podejście obiektowe pozwala nam na definiowanie metod statycznych, które dostępne są bez konieczności tworzenia instancji obiektu za pomocą new. Dostęp do metod statycznych otrzymujemy poprzez odwołanie do nazwy klasy, w której dana metoda została zdefiniowana. Poza tym, metody statyczne mogą być również dziedziczone oraz wywoływane z podklas.

class Vehicle {
 
  constructor (name, type) {
    this.name = name;
    this.type = type;
  }
 
  getName () {
    return this.name;
  }
 
  getType () {
    return this.type;
  }
 
  static create (name, type) {
    return new Vehicle(name, type);
  }
 
}

let car = Vehicle.create('Tesla', 'car');
console.log(car.getName()); // Tesla
console.log(car.getType()); // car

get / set

Kolejnym standardowym elementem języków obiektowych są tak zwane settery oraz gettery. Pozwalają one zdefiniować dostęp do pól obiektu (get) oraz ostawienie ich wartości (set).

class Car {
 
  constructor (name) {
    this._name = name;
  } 
 
  set name (name) {
    this._name = name;
  }
 
  get name () {
    return this._name;
  }
 
}

let car = new Car('Tesla');
console.log(car.name); // Tesla

car.name = 'BMW';
console.log(car.name); // BMW

enhanced object properties

Warto również nadmienić, że ES6 to również skrócone oraz wyliczane tworzenie własności w obiekcie oraz przemodelowane funkcje, które widzieliśmy już w powyższych przykładach.

skrócone własności

To nic innego, jak tworzenie własności na podstawie nazwy zmiennej. Najlepiej zilustruje to poniższy przykład:

// ES6
let x = 1,
    y = 2,
    obj = { x, y };

console.log(obj); // Object { x: 1, y: 2 }

// ES5
var x = 1,
    y = 2,
    obj = {
      x: x,
      y: y
    };

console.log(obj); // Object { x: 1, y: 2 }

W przypadku składni ES6 nie musimy podawać nazwy własności obiektu jeśli chcemy, by miała ona taką samą nazwę jak nazwa zmiennej, której wartość ma zostać przypisana do obiektu.

wyliczane własności

Kolejną funkcjonalnością ECMAScript2015 są wyliczane własności obiektu. Pozwalają one na użycie zmiennych oraz innych wartości do dynamicznego tworzenia nazw własności.

// ES6
let getKey = () => '123',
    obj = {
      foo: 'bar',
      ['key_' + getKey()]: 123
    };

console.log(obj); // Object { foo: 'bar', key_123: 123 }

// ES5
var getKey = function () {
      return '123';
    },
    obj = {
      foo: 'bar'
    };

obj['key_' + getKey()] = 123;
console.log(obj); // Object { foo: 'bar', key_123: 123 }

metody w obiekcie

Na koniec metody tworzone jako własności obiektu. Widzieliśmy je już w poprzednich przykładach, dotyczących podejścia obiektowego w ES6.

// ES6
let obj = {
  name: 'object name',
  toString () { // 'function' keyword is omitted here
    return this.name;
  }
};

console.log(obj.toString()); // object name

// ES5
var obj = {
  name: 'object name',
  toString: function () {
    return this.name;
  }
};

console.log(obj.toString()); // object name

70-480 – Tworzenie i wdrażanie obiektów i metod

70-480

Wdrażanie obiektów natywnych; tworzenie obiektów i właściwości niestandardowych dla obiektów natywnych przy użyciu prototypów i funkcji; dziedziczenie z obiektu; wdrażanie metod macierzystych i tworzenie metod niestandardowych.



To już ostatnia część sekcji Wdrażanie i edycja struktur i obiektów dokumentu, która stanowi około 24% całego materiału do opanowania. Przygotowania do egzaminu 70-480 można uznać za zaawansowane.

Zajmę się dzisiaj obiektami natywnymi w JavaScript, tworzeniem własnych obiektów oraz ich rozszerzaniem w ramach dziedziczenia. Pokażę także wzorzec tworzenia modułu, z którego często korzystam pisząc aplikacje w AngularJS.

Natywnie w JavaScript

JavaScript dostarcza nam szereg natywnych obiektów i metod, które możemy wykorzystywać w naszym kodzie beż żadnych dodatkowych bibliotek. Często możemy sobie nawet nie zdawać sprawy, że przypisując do zmiennej liczbę, tworzony jest nowy obiekt new Number(liczba), czy przypisując wartość tekstową, tworzony jest obiekt new String(tekst). Wszystkie obiekty tworzone są ponad to na podstawie głównego obiektu Object. Jest to sposób na dziedziczenie wspólnych cech obiektów znane z wielu języków programowania.

Na stronie w3schools.com znajdziemy opisy wszystkich podstawowych obiektów oraz ich metody:

Poza tym, w JavaScript dostępne są także wartości specjalne (null, undefined oraz operator typeof).

Różnica pomiędzy null, a undefined przedstawia się następująco:

typeof undefined		// undefined
typeof null				// object
null === undefined		// false
null == undefined		// true
var person = null;

– wartość null, typ object

var person = undefined;

– wartość undefined, typ undefined

Rozszerzanie natywnych obiektów w JavaScript

Większość typów w JavaScript posiada natywne, wbudowane metody oraz dodatkowe pola, które budują domyślne zachowania. Spójrzmy choćby na typ Function:

Object.getOwnPropertyNames(Function.prototype)
// ["length", "name", "arguments", "caller", "constructor", "bind", "toString", "call", "apply", "toMethod"]

Każda nowo tworzona funkcja posiada również te metody. Nie możemy ich edytować oraz zmieniać, ale możemy dodawać nowe metody oraz dodawać lub edytować istniejące pola do już istniejących typów. Służy do tego model prototype, w którym zawarte są wszystkie natywne oraz domyślne dla każdego tworzonego obiektu (new), metody oraz właściwości.

Stworzę w ten sposób nową (prostą i lekko naiwną) metodę dla typu Array, która usunie dany element z tablicy:

Array.prototype.remove = function(member) {
  var index = this.indexOf(member);
  if (index > -1) {
    this.splice(index, 1);
  }
  return this;
}

Metoda stara się znaleźć element w tablicy za pomocą funkcji indexOf, a następnie usuwa go za pomocą metody splice. W przeciwnym wypadku nie robi nic. Na koniec zwraca referencje do samej siebie, co pozwala wywoływać metody jedna po drugiej.

Jej użycie jest od tej chwili możliwe na zbiorze danych znajdujących się w tablicy:

['one', 'two', 'three'].remove('two'); // ["one", "three"]
['one', 'two', 'three'].remove('four'); // ["one", "two", "three"]

Dziedziczenie w JavaScript

Wiedząc już o własności prototype, możemy skupić się na jednej z najważniejszych cech programowania obiektowego, a mianowicie dziedziczeniu. Jest ono dostępne zarówno w JavaScript, jako w pełni obiektowym języku.

Poniżej przykład dziedziczenia opartego o prototypy. Tworząc obiekt, który dziedziczyć ma metody oraz właściwości swojego rodzica musimy przypisać do pola prototype klasy dziedziczącej referencję do obiektu rodzica.

function Parent() {}

Parent.prototype.setName = function (name) {
	this.name = 'Parent: ' + name;
};

Parent.prototype.getName = function () {
	console.log(this.name);
};
function Child() {}

Child.prototype = new Parent();

Child.prototype.setName = function (name) {
	this.name = 'Child: ' + name;
};
var parent = new Parent();
parent.setName('Mark');
parent.getName();

var child = new Child();
child.setName('Rob');
child.getName();

Na konsoli przeglądarki otrzymamy w ten sposób:

// Parent: Mark
// Child: Rob

Metoda setName w obiekcie Child została przysłonięta przez własną implementację. Metoda getName została natomiast w pełni odziedziczona z obiektu Parent. Jest to na tyle proste, że nie ma chyba sensu opisywać tego dalej.

Wzorzec modułu

Jak już wspomniałem we wstępie wpisu, pisząc aplikacje w AngularJS często korzystam ze wzorca modułu. Można go spotkać choćby w serwisach, o których pisałem we wpisie: Factory vs Service vs Provider.

Idea znów jest bardzo prosta. Chcemy stworzyć moduł, który posiadać będzie swoją implementację wewnętrzną (prywatne metody oraz zmienne) oraz metody publiczne, wystawione na świat. Spójrzmy od razu na przykład:

var UserModule = function () {
	
  var _username = '',
    _setUsername = function (username) {
      _username = username;
    },
    _sayHello = function () {
      return 'Hello, ' + _username;
    };
    
  return {
    setUsername: _setUsername,
    sayHello: _sayHello
  };
	
};

var UserModule = new UserModule();
UserModule.setUsername('mrzepinski');
console.log(UserModule.sayHello());

Dzięki takiej definicji możemy enkapsulować swój kod i tworzyć prywatne metody oraz zmienne. Poprzez użycie obiektu zwracanego za pomocą słowa return, wystawiamy tylko te metody, które zostały przez nas odpowiednio przygotowane. W tym wypadku zmienna _username oraz metody _setUsername i _sayHello dostępne są tylko wewnątrz implementacji modułu.