Skip to main content

Класи і прототипи

Класи в JavaScript, це зручна обгортка над функціями-конструкторами та прототипами: мова під ними не змінилася, змінився лише синтаксис. Клас створює функцію-конструктор, кладе методи в її prototype і, за наявності extends, будує ланцюжок прототипів.

Теорія

TL;DR

  • class, це функція: typeof User === 'function'.
  • Методи класу записуються в User.prototype, а не в кожен екземпляр.
  • new User() створює об'єкт, ставить йому прототипом User.prototype і викликає конструктор.
  • extends зв'язує Child.prototype з Parent.prototype, а Child з Parent (для статичних членів).
  • Виклик методу, це пошук угору по ланцюжку прототипів; instanceof перевіряє той самий ланцюжок.
  • Цукор не повністю прозорий: клас строгий, його методи неперелічувані, і без new він не викликається.

Швидкий приклад

javascript
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'), рушій виконує чотири кроки:

  1. Створює новий порожній об'єкт.
  2. Встановлює його внутрішній [[Prototype]] рівним User.prototype.
  3. Викликає конструктор із this, що вказує на цей об'єкт.
  4. Повертає об'єкт, якщо конструктор явно не повернув інший об'єкт.
javascript
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, тому всі екземпляри поділяють одну функцію в пам'яті.

javascript
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 не робить нічого нового, він лише зв'язує прототипи.

javascript
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'

Під капотом відбувається приблизно таке:

javascript
// зв'язок прототипів екземплярів: методи 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

Перевірити це можна прямо в коді:

javascript
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() виконується так:

  1. Чи є власна властивість speak у самому d? Немає.
  2. Рушій дивиться в Dog.prototype. Немає.
  3. Далі в Animal.prototype. Знайшов, викликає з this === d.
  4. Якби не знайшов, пішов би в Object.prototype, а потім отримав би undefined.

Той самий ланцюжок обходить instanceof: він перевіряє, чи зустрічається Constructor.prototype серед прототипів об'єкта.

javascript
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 виглядає так:

javascript
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
поле класувласна властивість екземпляра, створена в конструкторі
extendsChild.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, бо об'єкт ще не створено.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.