Класи і прототипи
Класи в JavaScript, це зручна обгортка над функціями-конструкторами та прототипами: мова під ними не змінилася, змінився лише синтаксис. Клас створює функцію-конструктор, кладе методи в її prototype і, за наявності extends, будує ланцюжок прототипів.
Теорія
TL;DR
class, це функція:typeof User === 'function'.- Методи класу записуються в
User.prototype, а не в кожен екземпляр. new User()створює об'єкт, ставить йому прототипомUser.prototypeі викликає конструктор.extendsзв'язуєChild.prototypeзParent.prototype, аChildзParent(для статичних членів).- Виклик методу, це пошук угору по ланцюжку прототипів;
instanceofперевіряє той самий ланцюжок. - Цукор не повністю прозорий: клас строгий, його методи неперелічувані, і без
newвін не викликається.
Швидкий приклад
class User {
constructor(name) {
this.name = name;
}
sayHi() {
console.log(`Привіт, ${this.name}!`);
}
}
// Приблизний еквівалент до появи class:
function UserOld(name) {
this.name = name;
}
UserOld.prototype.sayHi = function () {
console.log(`Привіт, ${this.name}!`);
};Обидва варіанти дають однакову структуру: дані в екземплярі, методи в прототипі.
Що відбувається при new
Коли ви пишете const user = new User('Maria'), рушій виконує чотири кроки:
- Створює новий порожній об'єкт.
- Встановлює його внутрішній
[[Prototype]]рівнимUser.prototype. - Викликає конструктор із
this, що вказує на цей об'єкт. - Повертає об'єкт, якщо конструктор явно не повернув інший об'єкт.
const user = new User('Maria');
console.log(Object.getPrototypeOf(user) === User.prototype); // true
console.log(user.constructor === User); // true
console.log(Object.keys(user)); // ['name'], лише даніЕкземпляр зберігає тільки власні дані, а поведінку бере з прототипу.
Методи, це властивості prototype
Кожен метод, оголошений у тілі класу, автоматично потрапляє в Class.prototype, тому всі екземпляри поділяють одну функцію в пам'яті.
console.log(Object.getOwnPropertyNames(User.prototype));
// ['constructor', 'sayHi']
const u1 = new User('Maria');
const u2 = new User('Oleh');
console.log(u1.sayHi === u2.sayHi); // true, один метод на всіхЦе і є головна ідея прототипів: не дублювати функції в кожному об'єкті. Виняток, це поля класу (name = 'default') та методи, оголошені як стрілкові властивості: вони створюються для кожного екземпляра окремо.
extends, це прототипне успадкування
extends не робить нічого нового, він лише зв'язує прототипи.
class Animal {
speak() {
console.log('The animal makes a sound');
}
}
class Dog extends Animal {
bark() {
console.log('Woof woof!');
}
}
const d = new Dog();
d.speak(); // 'The animal makes a sound'Під капотом відбувається приблизно таке:
// зв'язок прототипів екземплярів: методи
Object.setPrototypeOf(Dog.prototype, Animal.prototype);
// зв'язок самих конструкторів: статичні члени
Object.setPrototypeOf(Dog, Animal);Звідси два паралельні ланцюжки. Для екземплярів:
d -> Dog.prototype -> Animal.prototype -> Object.prototype -> null
Для конструкторів:
Dog -> Animal -> Function.prototype -> Object.prototype -> null
Перевірити це можна прямо в коді:
console.log(Object.getPrototypeOf(d) === Dog.prototype); // true
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype); // true
console.log(Object.getPrototypeOf(Animal.prototype) === Object.prototype); // true
console.log(Object.getPrototypeOf(Dog) === Animal); // trueДругий ланцюжок пояснює, чому статичні методи батька доступні через дочірній клас: Dog.someStaticMethod() знаходиться на Animal.
Як шукається метод і що перевіряє instanceof
Виклик d.speak() виконується так:
- Чи є власна властивість
speakу самомуd? Немає. - Рушій дивиться в
Dog.prototype. Немає. - Далі в
Animal.prototype. Знайшов, викликає зthis === d. - Якби не знайшов, пішов би в
Object.prototype, а потім отримав биundefined.
Той самий ланцюжок обходить instanceof: він перевіряє, чи зустрічається Constructor.prototype серед прототипів об'єкта.
console.log(d instanceof Dog); // true
console.log(d instanceof Animal); // true, бо Animal.prototype є в ланцюжку
console.log(d instanceof Object); // trueКлючове слово super теж працює через прототипи: super.speak(), це виклик методу з Object.getPrototypeOf(Dog.prototype) із поточним this, а super(...) у конструкторі викликає батьківський конструктор, який і створює this для похідного класу.
Що клас робить під капотом і чим усе ж відрізняється
Еквівалент без class виглядає так:
function Animal() {}
Animal.prototype.move = function () {};
function Dog() {
Animal.call(this);
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
Dog.prototype.bark = function () {};
const dog = new Dog();
dog.bark();| Конструкція класу | Що відбувається під капотом |
|---|---|
constructor | тіло функції-конструктора |
| метод класу | властивість у Class.prototype |
| статичний метод | властивість на самій функції Class |
| поле класу | власна властивість екземпляра, створена в конструкторі |
extends | Child.prototype вказує на Parent.prototype, Child на Parent |
super() | виклик батьківського конструктора, який створює this |
instanceof | перевірка ланцюжка прототипів |
Але цукор не зовсім прозорий, і про ці відмінності питають на співбесідах:
- Код класу завжди виконується в strict mode.
- Методи класу неперелічувані, тому не з'являються у
for...in. - Клас не можна викликати без
new: будеTypeError. - Оголошення класу не піднімається як
function: до рядка оголошення воно в TDZ. - У похідному класі
thisнедоступний до викликуsuper().
Типові помилки
- Вважати клас окремою моделлю ООП. У JavaScript немає класів у розумінні Java чи C#: є лише об'єкти і посилання на прототипи.
- Плутати
User.prototypeіObject.getPrototypeOf(User). Перше, прототип майбутніх екземплярів, друге, прототип самої функції-класу, тобто звідки беруться статичні члени. - Думати, що методи копіюються в екземпляр. Вони лежать в одному прототипі, тому
u1.sayHi === u2.sayHi. - Викликати клас без
new. Функцію-конструктор старого стилю так викликати можна (і зіпсуватиthis), а клас кинеTypeError. - Присвоювати поля стрілковим функціям без потреби.
handle = () => {}створює нову функцію для кожного екземпляра і не потрапляє в прототип, тож пам'ять зростає. - Забути
super()у похідному конструкторі. Без нього звернення доthisдастьReferenceError, бо об'єкт ще не створено.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.