Знайомство з класами у JavaScript

На відміну від більшості формально об’єктно-орієнтованих мов програмування, JavaScript не підтримував класи і класичне наслідування в якості основного засобу визначення подібних і пов'язаних об'єктів, коли його було створено. Це спантеличило багато розробників, і починаючи з pre-ECMAScript 1, весь час до ECMAScript 5 включно, багато бібліотек створювали утиліти, щоб імітувати підтримку класів в JavaScript. У той час, коли деякі JavaScript розробники булі впевнені, що мова не потребує класів, велика кількість бібліотек, створених спеціально для цієї мети призвела до включення класів в ECMAScript 6.

Під час вивчення класів ECMAScript 6 корисно зрозуміти основні механізми, які використовують класи, тому цей розділ починається з обговорення, як розробники домоглися поведінки подібної до класу засобами ECMAScript 5. Однак, як ви побачите згодом, ECMAScript 6 класи не є саме такими класами, як класи в інших мовах. Вони мають унікальні особливості, які надає динамічний характер JavaScript.

Подібні до класів структури в ECMAScript 5

В ECMAScript 5 та раніше, JavaScript не мав класів. Найближчим еквівалентом до класу було створення конструктору з подальшим зв'язуванням методів до прототипу конструктора, такий підхід, як правило, називається створенням власних типів. Наприклад:

function PersonType(name) {
    this.name = name;
}

PersonType.prototype.sayName = function() {
    console.log(this.name);
};

let person = new PersonType("Nicholas");
person.sayName();   // виводить "Nicholas"

console.log(person instanceof PersonType);  // true
console.log(person instanceof Object);      // true

В цьому коді, PersonType — це функція–конструктор, яка створює єдину властивість з ім'ям name. Метод sayName() зв'язаний з прототипом, таким чином одна й та сама функція є доступною всім екземплярам об'єкту PersonType. Потім, новий екземпляр об'єкту PersonType створюється за допомогою оператора new. Отриманій об'єкт person вважається екземпляром об'єктів PersonType та Object відповідно до наслідування прототипів.

Цей базовий шаблон є основою багатьох JavaScript бібліотек, які імітують класи, і саме з нього беруть початок класи.

Оголошення класів

Найпростішою формою класів в ECMAScript 6 є оголошення класу, яке виглядає схожим на класи в інших мовах.

Базове оголошення класу

Оголошення класів починаються з ключового слова class за яким слідує ім'я класу. Інша частина синтаксису виглядає подібною до визначення методів в літералах об'єктів, не вимагаючи коми між ними. Наприклад, ось просте оголошення класу:

class PersonClass {

    // еквівалент конструктора PersonType
    constructor(name) {
        this.name = name;
    }

    // еквівалент PersonType.prototype.sayName
    sayName() {
        console.log(this.name);
    }
}

let person = new PersonClass("Nicholas");
person.sayName();   // виводить "Nicholas"

console.log(person instanceof PersonClass);     // true
console.log(person instanceof Object);          // true

console.log(typeof PersonClass);                    // "function"
console.log(typeof PersonClass.prototype.sayName);  // "function"

Оголошення класу PersonClass поводиться подібно до PersonType з попереднього прикладу. Але замість того, щоб визначати функцію як конструктор, оголошення класу дозволяє вам визначити конструктор безпосередньо в середині класу за допомогою спеціального методу з ім'ям constructor. Оскільки методи класу використовують лаконічний синтаксис, немає ніякої необхідності використовувати ключове слово function. Всі інші імена методів не мають особливого сенсу, так що ви можете додати стільки методів, скільки ви хочете.

I> Власні властивості, властивості які виконуються в екземплярі, а не в прототипі, можуть бути створені тільки в середині конструктора класу, або у методі. В цьому прикладі name є власною властивістю. Я рекомендую створювати всі можливі власні властивості всередині функції конструктора, щоб єдине місце в класі відповідало за них усіх.

Цікаво, що оголошення класів є лише синтаксичним цукром для існуючих оголошень користувацьких типів. Оголошення PersonClass фактично створює функцію, яка має поведінку як у методу constructor, тому typeof PersonClass виведе "function" в якості результату. Метод sayName() також зводиться до того, що відповідає методу PersonClass.prototype в цьому прикладі, подібно відносинам між sayName() та PersonType.prototype у попередньому прикладі. Ці подібності дозволяють вам змішувати користувацькі типи та класи, не турбуючись занадто багато про те, що саме ви використовуєте.

Навіщо використовувати синтаксис класів

Незважаючи на подібності між класами та користувацькими типами, є деякі важливі відмінності, про які ви маєте пам'ятати:

  1. Оголошення класів, на відміну від оголошень функцій, не піднімаються. Оголошення класів поводяться подібно до оголошення let, і таким чином існують в тимчасовій мертвій зоні, поки виконання не досягне оголошення;
  2. Весь код в середині оголошення класу автоматично запускається в суворому режимі. Не має потреби оголошувати суворий режим в середині класу;
  3. Усі методи є неперелічуваними. Це є суттєвою відмінністю від користувацьких типів, де ви маєте використовувати Object.defineProperty() щоб зробити метод неперелічуваним;
  4. Всі методи не мають внутрішнього методу [[Construct]] і будуть викликати помилку, якщо ви спробуєте викликати їх з new;
  5. Виклик конструктора класу без new провокує помилку;
  6. Спроба переписати ім'я класу всередині методу класу викличе помилку.

Маючи все це на увазі, оголошення PersonClass з попереднього прикладу є прямо тотожнім до наступного коду, який не використовує синтаксис класу:

// прямий еквівалент до PersonClass
let PersonType2 = (function() {

    "use strict";

    const PersonType2 = function(name) {

        // переконуємося, що функція була викликана з new
        if (typeof new.target === "undefined") {
            throw new Error("Constructor must be called with new.");
        }

        this.name = name;
    }

    Object.defineProperty(PersonType2.prototype, "sayName", {
        value: function() {

            // переконуємося, що метод не був викликаний з new
            if (typeof new.target !== "undefined") {
                throw new Error("Method cannot be called with new.");
            }

            console.log(this.name);
        },
        enumerable: false,
        writable: true,
        configurable: true
    });

    return PersonType2;
}());

По-перше, зауважте, що тут ми маємо два оголошення PersonType2: за допомогою let у зовнішній області видимості, та з const всередині НВФВ (IIFE) (негайно виконуваного функціонального виразу). Ось як методи класу отримують заборону на перезапис ім'я класу, в той час, як код поза класом має можливість це роботи. Функція конструктор перевіряє new.target, щоб переконатися, що оголошення було виконане з new; якщо ні, то буду кинуто помилку. Далі, метод sayName() визначений як неперелічуваний, і метод перевіряє new.target, щоб переконатися, що його не було викликано з new. Фінальний крок повертає функцію–конструктор.

Цей приклад показує, що можливо зробити все, що роблять класи без використання нового синтаксису, та синтаксис класу значно спрощує всю функціональність.

A> Сталі імена класів

A> Ім'я класу визначається тільки як при використанні const всередині самого класу. Це означає, що ви можете замінити ім'я класу поза класом, але не всередині методу класу. Наприклад:

class Foo {
   constructor() {
       Foo = "bar";    // викличе помилку при виконанні
   }
}

// але працюватиме після оголошення класу
Foo = "baz";

A> В цьому коді, Foo всередині конструктора класу представляє собою окреме зв'язування з Foo поза класом. Внутрішній Foo оголошено так, наче оголошення відбувалось з const, і тому він не може бути перезаписаний. Помилка виникає коли конструктор намагається переписати Foo будь-яким значенням. Але, в той час як зовнішнє Foo визначене, якби це було з let оголошенням, ви можете переписати його значення будь-коли.

Вирази класів

Класи і функції схожі в тому, що мають дві форми: оголошення та вирази. Функції та класи оголошуються за допомогою певного ключового слова (function або class, відповідно), що йде за ідентифікатором. Функції мають форму виразу, якій не потребує ідентифікатора після function, та схожим чином, класи мають форму виразу, яка не потребує ідентифікатора після class. Ці вирази класів розроблені для того, щоб бути використаними в при оголошенні змінних, або передані в якості аргументів функцій.

Базовий вираз класу

Ось вираз класу, який відповідає попередньому прикладу PersonClass, а далі слідує певний код, що його використовує:

let PersonClass = class {

    // еквівалент конструктора PersonType
    constructor(name) {
        this.name = name;
    }

    // еквівалент до PersonType.prototype.sayName
    sayName() {
        console.log(this.name);
    }
};

let person = new PersonClass("Nicholas");
person.sayName();   // виведе "Nicholas"

console.log(person instanceof PersonClass);     // true
console.log(person instanceof Object);          // true

console.log(typeof PersonClass);                    // "function"
console.log(typeof PersonClass.prototype.sayName);  // "function"

Як показує цей приклад, вирази класу не вимагають ідентифікатору після class. Крім синтаксису, вирази класу функціонально еквівалентні оголошенням класу.

В анонімному виразі класу, як в попередньому прикладі, PersonClass.name є пустим рядком. Коли використовується оголошення класу, PersonClass.name буде "PersonClass".

I> Використання оголошення або виразів класу, взагалі є питанням стилю. На відміну від оголошення функцій і функціональних виразів, обидва, оголошення класів і вирази класу, не підіймаються і тому вибір має невеликий вплив на поведінку під час виконання коду. Єдина істотна відмінність полягає в тому, що вирази анонімного класу мають name властивість, яка є пустим рядком, в той час як оголошення класів завжди мають властивість name, яка відповідає назві класу (наприклад,PersonClass.name є "PersonClass", коли використовується оголошення класу).

Іменовані вирази класів

Попередній розділ використовував анонімний вираз класу в прикладі, але так само, як у функціональних виразах, ви також можете давати імена виразам класів. Щоб робити це, вставте ідентифікатор після ключового слова class, наприклад:

let PersonClass = class PersonClass2 {

    // еквівалент конструктора PersonType
    constructor(name) {
        this.name = name;
    }

    // еквівалент до PersonType.prototype.sayName
    sayName() {
        console.log(this.name);
    }
};

console.log(typeof PersonClass);        // "function"
console.log(typeof PersonClass2);       // "undefined"

В цьому прикладі, вираз класу має ім'я PersonClass2. Ідентифікатор PersonClass2 ідентифікатор існує тільки разом з оголошенням класу, таким чином він може бути використаний всередині методів класу (таких як метод sayName() в цьому прикладі). За межами класу, typeof PersonClass2 є "undefined" тому, що тут не існує зв'язування з PersonClass2. Щоб зрозуміти чому так стається, погляньте на еквівалентне оголошення, яке не використовує класи:

// прямий еквівалент до іменованого виразу класу PersonClass
let PersonClass = (function() {

    "use strict";

    const PersonClass2 = function(name) {

        // переконуємося, що функцію було викликано з new
        if (typeof new.target === "undefined") {
            throw new Error("Constructor must be called with new.");
        }

        this.name = name;
    }

    Object.defineProperty(PersonClass2.prototype, "sayName", {
        value: function() {
            console.log(this.name);
        },
        enumerable: false,
        writable: true,
        configurable: true
    });

    return PersonClass2;
}());

Створення іменованого виразу класу не на багато відрізняється від того, що відбувається всередині рушія JavaScript. Для оголошень класу, зовнішнє зв'язування (оголошується з let) має те ж ім'я, що і внутрішнє зв'язування (оголошується з const). Іменований вираз класу використовує своє ім'я при оголошенні const, тому PersonClass2 визначається для використання тільки всередині класу.

У той час як іменовані вирази класу поводяться відмінно від найменованих функціональних виразів, є ще багато схожого між ними. Обидва вони можуть бути використані в якості значень, і це відкриває безліч можливостей, які я опишу далі.

Класи як об’єкти першого роду

В програмуванні, певна сутність може називатися об'єктом першого роду (First-class citizen), якщо вона може бути використана як значення, маючи на у вазі, що вона може бути передана функції, повернута функцією, та зв'язана зі змінною. Функції в JavaScript є об'єктами першого класу (іноді їх просто називають функціями першого класу), і це частина того, що робить JavaScript унікальним.

ECMAScript 6 продовжує цю традицію, роблячи класи також об'єктами першого класу. Це дозволяє використовувати класи багатьма різними шляхами. Наприклад, вони можуть буду передані функції в якості аргументу:

function createObject(classDef) {
    return new classDef();
}

let obj = createObject(class {

    sayHi() {
        console.log("Hi!");
    }
});

obj.sayHi();        // "Hi!"

В цьому прикладі, функція createObject() викликана з анонімним виразом класу в якості аргументу, створює сутність класу за допомогою new, і повертає цю сутність. Змінна obj згодом зберігає повернуту сутність.

Іншим цікавим випадком застосування виразів класу є створення синглтонів) при негайному викликові конструктора класу. Щоб це зробити, ви маєте використати new з виразом класу і додати круглі дужки в кінці. Наприклад:

let person = new class {

    constructor(name) {
        this.name = name;
    }

    sayName() {
        console.log(this.name);
    }

}("Nicholas");

person.sayName();       // "Nicholas"

Тут вираз анонімного класу створюється і потім виконується негайно. Ця модель дозволяє використовувати синтаксис класу для створення синглтонів, не залишаючи посилання на клас доступні для перевірки. (Пам'ятайте, що PersonClass створює тільки зв’язування всередині класу, а не зовні.) Круглі дужки в кінці виразу класу є показником того, що ви викликаєте функцію, що в той самий час дозволяє вам передавати їй аргумент.

Приклади в цьому розділі сфокусовані на класах з методами. Але ви також можете створювати властивості аксесори класу, використовуючи синтаксис, схожий на літерали об'єкту.

Властивості аксесори

В той час, як власні властивості мають створюватися в середині конструктора, класи дозволяють вам створювати властивості аксесори в середині прототипу. Щоб створити «гетер» (метод зчитування властивості), використовуйте ключове слово get потім пробіл, а потім ідентифікатор; щоб створити «сетер» (метод для запису властивості), робіть те ж саме, використовуючи ключове слово set. Наприклад:

class CustomHTMLElement {

    constructor(element) {
        this.element = element;
    }

    get html() {
        return this.element.innerHTML;
    }

    set html(value) {
        this.element.innerHTML = value;
    }
}

var descriptor = Object.getOwnPropertyDescriptor(CustomHTMLElement.prototype, "html");
console.log("get" in descriptor);   // true
console.log("set" in descriptor);   // true
console.log(descriptor.enumerable); // false

В цьому коді, клас CustomHTMLElement зроблений, як обгортка навколо існуючого елементу DOM. Він має і «гетер», і «сетер» для html, що делегує метод innerHTML на сам елемент. Властивість–аксесор створюється через CustomHTMLElement.prototype і, так само як і інші методи, буде створено як неперелічувана. Ось еквівалент відтворення без класів:

// повна відповідність до попереднього прикладу
let CustomHTMLElement = (function() {

    "use strict";

    const CustomHTMLElement = function(element) {

        // переконуємося, що функція викликана з new
        if (typeof new.target === "undefined") {
            throw new Error("Constructor must be called with new.");
        }

        this.element = element;
    }

    Object.defineProperty(CustomHTMLElement.prototype, "html", {
        enumerable: false,
        configurable: true,
        get: function() {
            return this.element.innerHTML;
        },
        set: function(value) {
            this.element.innerHTML = value;
        }
    });

    return CustomHTMLElement;
}());

Як і в попередніх прикладах, цей показує, скільки коду ви можете заощадити, використовуючи клас замість еквіваленту без класу. Визначення властивості аксесору для html саме по собі має такий же розмір як еквівалентне оголошення класу.

Обчислювані імена членів

Подібності між літералами об'єктів і класами ще не закінчилися. Методи класу і властивості аксесори можуть також мати обчислювані імена. Замість того щоб використовувати ідентифікатор, використовуйте квадратні дужки навколо виразу, що має той же синтаксис, який використовується для обчислювані імен літералів об'єкту. Наприклад:

let methodName = "sayName";

class PersonClass {

    constructor(name) {
        this.name = name;
    }

    [methodName]() {
        console.log(this.name);
    }
};

let me = new PersonClass("Nicholas");
me.sayName();           // "Nicholas"

Ця версія PersonClass використовує змінну, щоб привласнити ім'я методу всередині його визначення. Рядок "sayName" присвоюється змінній methodName, а потім methodName використовується для оголошення методу. Метод sayName() пізніше є доступним безпосередньо.

Властивості аксесори можуть використовувати зчисленні імена схожим чином, ось так:

let propertyName = "html";

class CustomHTMLElement {

    constructor(element) {
        this.element = element;
    }

    get [propertyName]() {
        return this.element.innerHTML;
    }

    set [propertyName](value) {
        this.element.innerHTML = value;
    }
}

Тут «гетер» і «сетер» для html встановлюються з використанням змінної propertyName. Доступ до властивості з використанням .html стосується тільки визначення.

Ви побачили, що існує багато подібностей між літералами класів та об'єктів, між методами, властивостями–аксесорами, та зчисленними іменами. Є ще одна подібність, яку слід розглянути: генератори.

Методи-генератори

У Главі 8 ви навчилися визначати генератори в літералі об'єкту, додаючи зірочку (*) до ім'я методу. Такий самий синтаксис працює і для класів, дозволяючи будь-якому методу бути генератором. Ось приклад:

class MyClass {

    *createIterator() {
        yield 1;
        yield 2;
        yield 3;
    }

}

let instance = new MyClass();
let iterator = instance.createIterator();

Цей код створює клас з ім'ям MyClass з методом-генератором createIterator(). Метод повертає ітератор, значення якого жорстко закодовані в генераторі. Методи-генератори корисні, коли ви маєте об'єкт який представляє колекцію значень і ви хочете легко перебрати ці значення. Масиви, набори, та мапи, всі мають кілька методів-генераторів для обліку, щоб надати розробникам різні засоби для взаємодії з їх елементами.

Незважаючи на те, що методи-генератори дуже корисні, визначення ітератору за замовчуванням для вашого класу є набагато потужнішим, якщо клас представляє колекції значень. Ви можете визначити ітератор за замовчуванням для класу, використовуючи Symbol.iterator, щоб визначите метод-генератор, як наприклад:

class Collection {

    constructor() {
        this.items = [];
    }

    *[Symbol.iterator]() {
        yield *this.items.values();
    }
}

var collection = new Collection();
collection.items.push(1);
collection.items.push(2);
collection.items.push(3);

for (let x of collection) {
    console.log(x);
}

// Output:
// 1
// 2
// 3

У цьому прикладі використовується зчисленне ім'я методу генератора, що делегується ітератору values() масиву this.items. Будь-який клас, який керує набором значень повинен включати ітератор за замовчуванням, тому що деякі операції специфічні для колекцій вимагають щоб колекції, якими вони оперують, мали ітератор. Тепер, будь-який екземпляр Collection може бути використаний безпосередньо в циклі for-of або з оператором розширення.

Додавання методів і властивостей аксесорів до прототипу класу корисно, коли ви хочете показати їх в екземплярах об'єкту. Якщо ж, з іншого боку, ви хочете мати методи або властивості аксесори в самому класі, то ви маєте використовувати статичні члени.

Статичні члени

Визначення додаткових методів безпосередньо в конструкторах для імітації статичних членів є ще одним поширеним шаблоном в ECMAScript 5 і більш ранніх версіях. Наприклад:

function PersonType(name) {
    this.name = name;
}

// статичний метод
PersonType.create = function(name) {
    return new PersonType(name);
};

// метод екземпляру
PersonType.prototype.sayName = function() {
    console.log(this.name);
};

var person = PersonType.create("Nicholas");

В інших мовах програмування, фабричний метод з ім'ям PersonType.create() буде вважатися статичним методом, тому що його данні не залежать від екземпляру PersonType. ECMAScript 6 спрощує створення статичних методів використовуючи формальний запис static перед ім'ям методу або властивості аксесора. Наприклад, ось еквівалент останнього прикладу у вигляді класу:

class PersonClass {

    // еквівалент конструктору PersonType
    constructor(name) {
        this.name = name;
    }

    // еквівалент PersonType.prototype.sayName
    sayName() {
        console.log(this.name);
    }

    // еквівалент PersonType.create
    static create(name) {
        return new PersonClass(name);
    }
}

let person = PersonClass.create("Nicholas");

Визначення PersonClass має один статичний метод з ім'ям сreate(). Синтаксис методу такий самий, що й для sayName() за винятком ключового слова static. Ви можете використовувати ключове слово static з визначенням будь-якого методу або властивості аксесора всередині класу. Єдине обмеження полягає в тому, що ви не можете використовувати static з визначенням методу constructor.

W> Статичні члени не доступні з екземплярів. Ви завжди повинні отримувати доступ до статичних членів безпосередньо з класу.

Успадкування з похідними класами

До появи ECMAScript 6, реалізація успадкування від власних типів було дуже коштовним процесом. Правильне успадкування потребувало багатьох кроків. Для прикладу, давайте розглянемо наступний код:

function Rectangle(length, width) {
    this.length = length;
    this.width = width;
}

Rectangle.prototype.getArea = function() {
    return this.length * this.width;
};

function Square(length) {
    Rectangle.call(this, length, length);
}

Square.prototype = Object.create(Rectangle.prototype, {
    constructor: {
        value:Square,
        enumerable: true,
        writable: true,
        configurable: true
    }
});

var square = new Square(3);

console.log(square.getArea());              // 9
console.log(square instanceof Square);      // true
console.log(square instanceof Rectangle);   // true

Square успадковується від Rectangle, і щоб зробити це, він має перезаписати Square.prototype з новим об'єктом створеним від Rectangle.prototype, а також викликати метод Rectangle.call). Ці кроки часто плутали новачків JavaScript і були джерелом помилок для досвідчених розробників.

Класи значно полегшують реалізацію успадкування. Достатньо використати ключове слово extends, щоб вказати на функцію з якої клас має успадковувати щось. Прототипи підтягуються автоматично, а також ви маєте доступ до конструкторуа головного класу за допомогою метода super(). Ось еквівалент попереднього прикладу в ECMAScript 6 запису:

class Rectangle {
    constructor(length, width) {
        this.length = length;
        this.width = width;
    }

    getArea() {
        return this.length * this.width;
    }
}

class Square extends Rectangle {
    constructor(length) {

        // те саме що й Rectangle.call(this, length, length)
        super(length, length);
    }
}

var square = new Square(3);

console.log(square.getArea());              // 9
console.log(square instanceof Square);      // true
console.log(square instanceof Rectangle);   // true

Цього разу клас Square успадковується від Rectangle з використанням ключового слова extends. Конструктор класу Square використовує super() щоб викликати конструктор класу Rectangle з зазначеними аргументами. Зверніть увагу, що на відміну від версії коду в ECMAScript 5, ідентифікатор Rectangle використовується лише з оголошенням класу (після extends).

Класи, які успадковуються від інших, відомі нам як похідні класи. Похідні класи вимагають обов'язкового використання super() при визначенні конструктору. Якщо ви не зробите цього, то буду кинуто помилку. Якщо ж ви не будете використовувати конструктор, то super() буде викликатися автоматично з усіма аргументами при створенні нового екземпляру класу. Наприклад, наступні два класі є ідентичними:

class Square extends Rectangle {
    // без конструктора
}

// Те ж саме, що й:

class Square extends Rectangle {
    constructor(...args) {
        super(...args);
    }
}

Другий клас в цьому прикладі показує еквівалент використання конструктора за замовчуванням для всіх похідних класів. Всі аргументи передаються в тому ж самому порядку, як і в конструкторі базового класу. Але в нашому конкретному випадку, функціональність буде не зовсім вірною, тому що конструктор класу Square потребує лише одного аргументу. Ось чому тут краще вочевидь визначити конструктор.

W> Є декілька речей, які треба мати на увазі, використовуючи super():

  1. W> Ви можете використовувати super() тільки з похідними класами. Якщо ви спробуєте використати його в непохідному класі (який не використовує extends), або в функції, то це викличе помилку.
  2. W> Перед тим як намагатися використати this в конструкторі ви маєте викликати super(). Оскільки super() відповідає за ініціалізацію this, спроба використання this перед тим, як викликати super() призведи до помилки.
  3. W> Використання super() можна позбутися тільки в тому випадку, якщо повертати об'єкт в конструкторі класу.

Перекриття методів класу

Методи похідних класів завжди перекривають методи базового класу з таким самим ім'ям. Наприклад, ви можете додати метод getArea() до Square, щоб перевизначити його функціонал:

class Square extends Rectangle {
    constructor(length) {
        super(length, length);
    }

    // переписуємо й перекриваємо Rectangle.prototype.getArea()
    getArea() {
        return this.length * this.length;
    }
}

Оскільки ми визначили getArea() в середині Square, метод Rectangle.prototype.getArea() не буде більше викликатися для нових екземплярів класу Square. Звичайно, ви завжди можете викликати метод базового класу з таким ім'ям, використовуючи super.getArea(). Наприклад, як тут:

class Square extends Rectangle {
    constructor(length) {
        super(length, length);
    }

    // переписуємо, перекриваємо, і викликаємо Rectangle.prototype.getArea()
    getArea() {
        return super.getArea();
    }
}

У цьому випадку використання super працює так само, як і посилання на super, яке було розкрите у Главі 4 (див. «Легкий доступ до прототипу через посилання super»). Значення this встановлюєте коректно автоматично, так що ви можете просто викликати метод.

Успадковані статичні члени

Якщо базовий клас має статичні члени, то вони будуть також доступними у похідному класі. Успадкування працює так само, як і в інших мовах, але це нова концепція для JavaScript. Ось приклад:

class Rectangle {
    constructor(length, width) {
        this.length = length;
        this.width = width;
    }

    getArea() {
        return this.length * this.width;
    }

    static create(length, width) {
        return new Rectangle(length, width);
    }
}

class Square extends Rectangle {
    constructor(length) {

        // те саме що й Rectangle.call(this, length, length)
        super(length, length);
    }
}

var rect = Square.create(3, 4);

console.log(rect instanceof Rectangle);     // true
console.log(rect.getArea());                // 12
console.log(rect instanceof Square);        // false

В цьому коді до класу Rectangle додається новий статичний метод create(). Цей метод доступний як Square.create() через успадкування, та поводиться так само, як метод Rectangle.create().

Похідні класи з виразів

Мабуть, найпотужнішим аспектом похідних класів в ECMAScript 6 є можливість визначити похідний клас з виразу. Ви можете використовувати extends з будь-яким виразом, якщо він вирішується функцією з [[Construct]] і прототипом. Наприклад:

function Rectangle(length, width) {
    this.length = length;
    this.width = width;
}

Rectangle.prototype.getArea = function() {
    return this.length * this.width;
};

class Square extends Rectangle {
    constructor(length) {
        super(length, length);
    }
}

var x = new Square(3);
console.log(x.getArea());               // 9
console.log(x instanceof Rectangle);    // true

Rectangle визначено в стилі ECMAScript 5 конструктора, в той час як Square є класом. Оскільки Rectangle має [[Construct]] та прототип, клас Square може успадковувати властивості безпосередньо звідти.

Сприйняття будь–якого типу виразу після extends дає такі потужні можливості, як динамічне визначення того, що успадковувати і звідки. Наприклад:

function Rectangle(length, width) {
    this.length = length;
    this.width = width;
}

Rectangle.prototype.getArea = function() {
    return this.length * this.width;
};

function getBase() {
    return Rectangle;
}

class Square extends getBase() {
    constructor(length) {
        super(length, length);
    }
}

var x = new Square(3);
console.log(x.getArea());               // 9
console.log(x instanceof Rectangle);    // true

Функція getBase() викликається безпосередньо при оголошенні класу. Вона повертає Rectangle, що робить цей приклад функціонально тотожним до попереднього. Оскільки, ви можете визначити базовій клас динамічно, це дає можливість створювати різні підходи успадкування. Наприклад, ви фактично можете створювати домішки:

let SerializableMixin = {
    serialize() {
        return JSON.stringify(this);
    }
};

let AreaMixin = {
    getArea() {
        return this.length * this.width;
    }
};

function mixin(...mixins) {
    var base = function() {};
    Object.assign(base.prototype, ...mixins);
    return base;
}

class Square extends mixin(AreaMixin, SerializableMixin) {
    constructor(length) {
        super();
        this.length = length;
        this.width = length;
    }
}

var x = new Square(3);
console.log(x.getArea());               // 9
console.log(x.serialize());             // "{"length":3,"width":3}"

В цьому прикладі домішки використовуються замість класичного наслідування. Функція mixin() приймає будь-яку кількість аргументів, які репрезентують об'єкти домішок. Вона створює функцію з ім'ям base та прив'язує властивості кожного об'єкту домішок до прототипу. Функція base повертається функцією mixin(), таким чином Square може використовувати extends. Майте на увазі, що ви використовуєте extends, тому повинні викликати super() всередині конструктора.

Екземпляр Square має і getArea() від AreaMixin, і serialize від SerializableMixin. Це досягається за допомогою наслідування через прототипи. Функція mixin() динамічно наповнює прототип нової функції усіма власними властивостями кожної домішки. (Майте на увазі, що при наявності властивостей з однаковими назвами у кількох домішках, лише остання додана властивість буде записана.)

W> Після extends може бути використаний будь-який вираз, але не всі вирази будуть давати валідний клас. Особливо, зазначені нижче типи виразів будуть давати:

  • W> null
  • W> функції-генератори (описані в Главі 8)

W> В цих випадках спроба створити новий екземпляр класу буде давати помилку, оскільки, ми не маємо можливості звернутися до [[Construct]].

Успадкування через вбудовані об’єкти.

З самого початку існування масивів JavaScript, розробники бажали створювати свої власні спеціальні типи масивів через успадкування. В ECMAScript 5 та раніше це було неможливо. Спроби використовувати класичне наслідування не давали робочий код. Наприклад:

// поведінка вбудованого масиву
var colors = [];
colors[0] = "red";
console.log(colors.length);         // 1

colors.length = 0;
console.log(colors[0]);             // undefined

// спроба успадкування з масиву в ES5

function MyArray() {
    Array.apply(this, arguments);
}

MyArray.prototype = Object.create(Array.prototype, {
    constructor: {
        value: MyArray,
        writable: true,
        configurable: true,
        enumerable: true
    }
});

var colors = new MyArray();
colors[0] = "red";
console.log(colors.length);         // 0

colors.length = 0;
console.log(colors[0]);             // "red"

Вивід console.log() в кінці цього коду показує як використання класичного наслідування JavaScript з масивами призводить до непередбачуваних результатів. Властивість length та числові властивості екземпляру MyArray не поводяться таким самим чином, як вони працюють для вбудованих масивів, тому що даний функціонал не поширюється на об’єкт через застосування Array.apply() або з посиланням на прототип.

Однією з цілей класів ECMAScript 6 є зробити можливим успадкування для всіх вбудованих об’єктів. В рамках досягнення цього, у модель успадкування класів було додано дві значні зміни в порівнянні з класичною моделлю успадкування в ECMAScript 5 та старіших версіях:

  • В класичному успадкуванні моделі ECMAScript 5 значення this вперше створюється похідним типом (наприклад, MyArray), і згодом викликається конструктор базового типу (як метод Array.apply()). Це означає, що this створюється як екземпляр MyArray і потім декорується додатковими властивостями від Array.
  • В успадкуванні, що базується на класах з ECMAScript 6, значення this вперше створюється від базового типу (Array) і потім змінюється конструктором похідного класу (MyArray). В результаті чого this починає працювати з усією вбудованою функціональністю базового типу та коректно отримує весь відповідний додатковий функціонал.

Наступний приклад показує в дії створення спеціального масиву за допомогою успадкування, що базується на класах:

class MyArray extends Array {
    // пустий
}

var colors = new MyArray();
colors[0] = "red";
console.log(colors.length);         // 1

colors.length = 0;
console.log(colors[0]);             // undefined

Клас MyArray успадковується безпосередньо від Array та в подальшому поводиться як Array. Взаємодія з числовими властивостями змінює властивість length, та маніпуляції з властивістю length змінюють числові властивості. Це означає, що ви можете як правильно успадковувати властивості від Array, щоб створювати ваші власні похідні класи-масиви, так і успадковувати поведінку інших вбудованих об’єктів. З додаванням всієї цієї функціональності ECMAScript 6 та похідні класи ефективно вирішують питання наслідування від вбудованих об’єктів, але цей випадок все ще вартий досліджень.

Властивість Symbol.species

Цікавим аспектом успадкування від вбудованих об’єктів є те, що метод, який має повертати екземпляр вбудованого об’єкту, натомість буде автоматично повертати екземпляр похідного класу. Таким чином, якщо ви маєте похідний клас з ім’ям MyArray, який успадковується від Array, то такий метод як slice() повертає екземпляр класу MyArray. Наприклад:

class MyArray extends Array {
    // пустий
}

let items = new MyArray(1, 2, 3, 4),
    subitems = items.slice(1, 3);

console.log(items instanceof MyArray);      // true
console.log(subitems instanceof MyArray);   // true

В цьому коді метод slice() повертає екземпляр класу MyArray. Зазвичай, метод slice(), при успадкуванні від Array мав би повертати екземпляр Array. Але за кулісами є властивість Symbol.species, яка робить описані вище зміни.

Symbol.species — добревідомий символ, який використовується для того, щоб визначити статичну властивість-аксесор, яка повертає функцію. Ця функція виконує роль конструктора там, де має буди створено екземпляр класу всередині екземпляру методу (замість того, щоб використовувати конструктор). Наступні типи вбудованих об’єктів мають визначений Symbol.species:

  • Array
  • ArrayBuffer (розглядається в Главі 10)
  • Map
  • Promise
  • RegExp
  • Set
  • Typed Arrays (розглядається в Главі 10)

Кожен з цих типів має визначену за замовчуванням властивість Symbol.species яка повертає this, маючи на увазі, що властивість буде завжди повертати функцію-конструктор. Якщо ви реалізуєте такий функціонал у власному класі, то код матиме наступний вигляд:

// кілька вбудованих типів використовують Symbol.species схожим чином
class MyClass {
    static get [Symbol.species]() {
        return this;
    }

    constructor(value) {
        this.value = value;
    }

    clone() {
        return new this.constructor[Symbol.species](this.value);
    }
}

У цьому прикладі Symbol.species зазвичай використовується для того, щоб прив’язати статичну властивість-аксесор до MyClass. Зверніть увагу, що ми маємо тільки гетер, без сетеру, тому що ми не можемо змінити Symbol.species класу. Будь-яке звернення до this.constructor[Symbol.species] повертає MyClass. Метод clone() використовує це визначення, щоб повернути новий екземпляр класу, замість того, щоб повертати сам клас MyClass, що дозволяє похідному класу переписувати це значення. Наприклад:

class MyClass {
    static get [Symbol.species]() {
        return this;
    }

    constructor(value) {
        this.value = value;
    }

    clone() {
        return new this.constructor[Symbol.species](this.value);
    }
}

class MyDerivedClass1 extends MyClass {
    // empty
}

class MyDerivedClass2 extends MyClass {
    static get [Symbol.species]() {
        return MyClass;
    }
}

let instance1 = new MyDerivedClass1("foo"),
    clone1 = instance1.clone(),
    instance2 = new MyDerivedClass2("bar"),
    clone2 = instance2.clone();

console.log(clone1 instanceof MyClass);             // true
console.log(clone1 instanceof MyDerivedClass1);     // true
console.log(clone2 instanceof MyClass);             // true
console.log(clone2 instanceof MyDerivedClass2);     // false

Тут MyDerivedClass1 успадковується від MyClass і не змінює властивість Symbol.species. Коли викликаєтеся clone(), то він повертає екземпляр класу MyDerivedClass1, тому що this.constructor[Symbol.species] повертає MyDerivedClass1. Клас MyDerivedClass2 успадковується від MyClass та переписує Symbol.species, щоб повернути MyClass. Коли викликається clone() для екземпляру класу MyDerivedClass2, то повернене значення буде екземпляром MyClass. Використовуючи Symbol.species, будь-який похідний клас може визначати який тип значення буде повернено, коли метод повертає екземпляр класу.

Наприклад, Array застосовує Symbol.species щоб визначити клас, який використовується для методів, які повертають масив. В класі, успадкованому від Array, ви можете визначити тип об’єкту, який повертається успадкованими методами, як тут:

class MyArray extends Array {
    static get [Symbol.species]() {
        return Array;
    }
}

let items = new MyArray(1, 2, 3, 4),
    subitems = items.slice(1, 3);

console.log(items instanceof MyArray);      // true
console.log(subitems instanceof Array);     // true
console.log(subitems instanceof MyArray);   // false

Цей код переписує Symbol.species в MyArray, який успадковується від Array. Усі успадковані методи, які повертають масив будуть завжди повертати екземпляр Array замість MyArray.

Взагалі, ви маєте використовувати властивість Symbol.species кожного разу, коли ви захочете використати this.constructor в методі класу. Це дозволить похідним класам легко переписувати тип, який повертається. Більш того, якщо ви створюєте похідний клас від класу, який має визначений Symbol.species, переконайтеся, що ви використовуєте це значення замість конструктора.

Використання new.target в конструкторах класів

У Главі 3 ви дізналися про new.target і те, як змінюється його значення в залежності від того, як було викликано функцію. Ви також можете використати new.target в конструкторі класу, щоб визначити як було викликано клас. В простому випадку, new.target відповідає функції-конструктора для класу, як в цьому прикладі:

class Rectangle {
    constructor(length, width) {
        console.log(new.target === Rectangle);
        this.length = length;
        this.width = width;
    }
}

// new.target є Rectangle
var obj = new Rectangle(3, 4);      // виводить true

Цей код показує, що new.target відповідає Rectangle, коли викликається new Rectangle(3, 4). Конструктори класу не можуть бути викликанні без new, тому властивість new.target завжди визначається в середині конструктора класу. Але її значення не завжди може буди однаковим. Погляньте на цей код:

class Rectangle {
    constructor(length, width) {
        console.log(new.target === Rectangle);
        this.length = length;
        this.width = width;
    }
}

class Square extends Rectangle {
    constructor(length) {
        super(length, length)
    }
}

// new.target є Square
var obj = new Square(3);      // виводить false

Square викликає конструктор класу Rectangle, тому new.target відповідає Square, коли викликається конструктор Rectangle. Це важливо, тому що це дає можливість кожному конструктору змінювати свою поведінку в залежності від того, як його було викликано. Наприклад, ви можете створити абстрактний базовий клас (якій не може бути реалізований безпосередньо), використовуючи new.target наступним чином:

// абстрактний базовий клас
class Shape {
    constructor() {
        if (new.target === Shape) {
            throw new Error("Цей клас не може бути ініційований безпосередньо.")
        }
    }
}

class Rectangle extends Shape {
    constructor(length, width) {
        super();
        this.length = length;
        this.width = width;
    }
}

var x = new Shape();                // викличе помилку

var y = new Rectangle(3, 4);        // немає помилки
console.log(y instanceof Shape);    // true

В цьому прикладі, конструктор класу Shape викличе помилку кожного разу, коли new.target є Shape, маючи на увазі, що new Shape() завжди буде викликати помилку. Але, ви все ще можете використовувати Shape як базовий клас, що й робить Rectangle. Виклик super() запускає конструктор Shape та new.target відповідає Rectangle, таким чином конструктор працює без помилок.

I> Оскільки класи не можуть викликатися без new, значення new.target всередині конструктора ніколи не буде undefined.

Підсумок

Класи в ECMAScript 6 роблять легшим використання успадкування в JavaScript, тому вам стане у нагоді те, що ви знали з інших мов. Класи ECMAScript 6 не тільки додають синтаксичного цукру в класичну модель успадкування ECMAScript 5, але також вводять багато поліпшень, щоб зменшити кількість помилок.

Класи ECMAScript 6 працюють з прототипним успадкуванням, визначаючи нестатичні методи в прототипі класу, в той час, як статичні методи визначаються безпосередньо в конструкторі. Всі методи класу є незчисленними, особливість, яка найкращим чином відповідає поведінці вбудованих об’єктів, для яких методи, зазвичай, є незчисленними за замовчуванням. Більш того, класи-конструктори не можуть бути викликані без new, це дає можливість переконатися, щоб ви випадково не викликали клас як функцію.

Успадкування, що базується на класах, дозволяє вам створити похідний клас від іншого класу, функції або виразу. Ця можливість дозволяє вам викликати функція як основу для успадкування, що дає використовувати домішки й інші різні шаблони для створення нового класу. Успадкування працює таким чином, що наслідування від вбудованих об’єктів, таких як Array, тепер можливе і працює як треба.

Ви можете використовувати new.target в конструкторах класів, щоб вони поводилися залежно від того, як були викликані. Найбільш поширеним випадком використання є створення абстрактного базового класу, який буде викликати помилку, якщо буде викликаний безпосередньо, але все ще буде коректно успадковуватися іншими класами.

В цілому, класи є важливим доповненням до JavaScript. Вони забезпечують більш лаконічний синтаксис і кращі функціональні можливості для визначення власних типів об'єктів в безпечній і в манері.

results matching ""

    No results matching ""