Пошук уроків, статей та іншого контенту
Порівняєте класи з прототипами та зрозумієте, як JavaScript реалізує успадкування під капотом.
У JavaScript об’єкти можуть мати внутрішній зв’язок з іншим об’єктом — прототипом. Під час пошуку властивості рушій:
перевіряє сам об’єкт;
якщо властивість не знайдено, переходить до його прототипу;
продовжує пошук угору ланцюжком;
повертає undefined, якщо властивість не знайдено ніде.
Прототип доступний через стандартний метод Object.getPrototypeOf():
const animal = {
eats: true,
};
const dog = Object.create(animal);
dog.name = "Rex";
console.log(dog.name); // Rex — власна властивість dog
console.log(dog.eats); // true — властивість прототипу animal
console.log(Object.getPrototypeOf(dog) === animal); // trueУ цьому прикладі dog не має власної властивості eats, але JavaScript знаходить її в animal.
Властивість можна перевірити без урахування прототипів:
console.log(Object.hasOwn(dog, "name")); // true
console.log(Object.hasOwn(dog, "eats")); // falseОператор in, навпаки, перевіряє весь прототипний ланцюжок:
console.log("name" in dog); // true
console.log("eats" in dog); // trueМетоди зазвичай зберігають у прототипі, а не в кожному екземплярі. Це дає змогу багатьом об’єктам використовувати одну функцію.
const userPrototype = {
greet() {
return `Привіт, ${this.name}!`;
},
};
const firstUser = Object.create(userPrototype);
firstUser.name = "Олена";
const secondUser = Object.create(userPrototype);
secondUser.name = "Андрій";
console.log(firstUser.greet()); // Привіт, Олена!
console.log(secondUser.greet()); // Привіт, Андрій!
console.log(firstUser.greet === secondUser.greet); // trueМетод є одним об’єктом у userPrototype, але під час виклику this посилається на конкретний екземпляр.
Якщо створити метод безпосередньо в кожному об’єкті, кожен об’єкт матиме окрему функцію:
const first = {
name: "Олена",
greet() {
return `Привіт, ${this.name}!`;
},
};
const second = {
name: "Андрій",
greet() {
return `Привіт, ${this.name}!`;
},
};
console.log(first.greet === second.greet); // falseprototypeДо появи синтаксису класів об’єкти часто створювали за допомогою функцій-конструкторів:
function User(name) {
this.name = name;
}
User.prototype.greet = function () {
return `Привіт, ${this.name}!`;
};
const user = new User("Олена");
console.log(user.name); // Олена
console.log(user.greet()); // Привіт, Олена!
console.log(Object.getPrototypeOf(user) === User.prototype); // trueОператор new виконує приблизно такі кроки:
створює новий порожній об’єкт;
встановлює його прототипом User.prototype;
викликає User з this, що посилається на новий об’єкт;
повертає цей об’єкт, якщо конструктор не повернув інший об’єкт явно.
Тому можна уявити результат так:
const user = Object.create(User.prototype);
User.call(user, "Олена");Це не повна реалізація оператора new, але вона демонструє основну ідею.
prototype не є прототипом самої функціїУ функції-конструктора є властивість prototype. Вона використовується як прототип для об’єктів, створених через new.
function User() {}
console.log(typeof User.prototype); // object
console.log(Object.getPrototypeOf(User) === Function.prototype); // trueТут:
User.prototype — об’єкт, який стане прототипом екземпляра;
Object.getPrototypeOf(User) — прототип самої функції User.
Це різні зв’язки.
Синтаксис класів не створює окрему модель успадкування. Класи використовують той самий прототипний механізм, але надають зручніший синтаксис.
class User {
constructor(name) {
this.name = name;
}
greet() {
return `Привіт, ${this.name}!`;
}
}
const user = new User("Олена");
console.log(user.greet()); // Привіт, Олена!
console.log(Object.getPrototypeOf(user) === User.prototype); // true
console.log(Object.hasOwn(user, "name")); // true
console.log(Object.hasOwn(user, "greet")); // falseПоле name створене безпосередньо в екземплярі, а метод greet зберігається в User.prototype.
Можна перевірити це явно:
console.log(User.prototype.greet === user.greet); // true
console.log(Object.hasOwn(User.prototype, "greet")); // trueМетоди класу мають спеціальні властивості:
вони не є власними властивостями екземпляра;
вони неекспліковані під час звичайного переліку властивостей;
за замовчуванням є неексплікованими, тобто enumerable: false;
виклик конструктора класу без new спричиняє помилку.
class User {
greet() {
return "Привіт";
}
}
const descriptor = Object.getOwnPropertyDescriptor(User.prototype, "greet");
console.log(descriptor.enumerable); // false
console.log(typeof User); // function
try {
User();
} catch (error) {
console.log(error instanceof TypeError); // true
}Клас є функцією з особливими правилами, але це не означає, що класи та функції-конструктори повністю взаємозамінні. Класи не можна викликати як звичайні функції.
extendsКлючове слово extends створює два прототипні зв’язки:
прототип екземпляра дочірнього класу успадковує від прототипу батьківського класу;
сам дочірній конструктор успадковує від батьківського конструктора.
class Animal {
constructor(name) {
this.name = name;
}
move() {
return `${this.name} рухається`;
}
}
class Dog extends Animal {
bark() {
return `${this.name} гавкає`;
}
}
const dog = new Dog("Рекс");
console.log(dog.move()); // Рекс рухається
console.log(dog.bark()); // Рекс гавкає
console.log(Object.getPrototypeOf(dog) === Dog.prototype); // true
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype); // true
console.log(Object.getPrototypeOf(Dog) === Animal); // trueЛанцюжок для екземпляра має такий вигляд:
dog
↓
Dog.prototype
↓
Animal.prototype
↓
Object.prototype
↓
nullЛанцюжок для самого конструктора:
Dog
↓
Animal
↓
Function.prototype
↓
Object.prototype
↓
nullСаме тому дочірній клас може успадковувати як методи екземпляра, так і статичні методи.
superЯкщо дочірній клас не має власного конструктора, JavaScript створює еквівалентний конструктор автоматично:
class Animal {
constructor(name) {
this.name = name;
}
}
class Dog extends Animal {
// Приблизно: constructor(...args) { super(...args); }
}
const dog = new Dog("Рекс");
console.log(dog.name); // РексЯкщо конструктор оголошено вручну, потрібно викликати super() до використання this:
class Animal {
constructor(name) {
this.name = name;
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name);
this.breed = breed;
}
}
const dog = new Dog("Рекс", "вівчарка");
console.log(dog.name); // Рекс
console.log(dog.breed); // вівчаркаУ дочірньому конструкторі this не доступний до виклику super(), оскільки спочатку батьківський клас має ініціалізувати екземпляр.
class Animal {
constructor(name) {
this.name = name;
}
}
class Dog extends Animal {
constructor(name) {
// Помилка: this використовується до super()
this.name = name;
super(name);
}
}Такий код спричинить ReferenceError.
supersuper.method() викликає метод батьківського прототипу, але this залишається поточним екземпляром.
class Animal {
describe() {
return "Це тварина";
}
}
class Dog extends Animal {
describe() {
return `${super.describe()}, а саме собака`;
}
}
const dog = new Dog();
console.log(dog.describe()); // Це тварина, а саме собакаВажливо, що super не є звичайною змінною і не означає безпосередньо Animal.prototype. Його поведінка визначається контекстом методу та ланцюжком наслідування.
super і властивостіЧерез super можна звертатися не лише до методів:
class Base {
get label() {
return "базовий об'єкт";
}
}
class Child extends Base {
get label() {
return `${super.label} та дочірній об'єкт`;
}
}
console.log(new Child().label);
// базовий об'єкт та дочірній об'єктЯкщо батьківська властивість є геттером, під час звернення до super.label він виконується з this, що посилається на екземпляр Child.
Статичні члени належать самому класу, а не його екземплярам:
class User {
static role = "user";
static createGuest() {
return new User("Гість");
}
constructor(name) {
this.name = name;
}
}
const guest = User.createGuest();
console.log(User.role); // user
console.log(guest.name); // Гість
console.log(guest.role); // undefinedСтатичний метод не доступний через екземпляр:
try {
guest.createGuest();
} catch (error) {
console.log(error instanceof TypeError); // true
}Статичні члени також успадковуються дочірнім класом:
class Animal {
static category() {
return "тварина";
}
}
class Dog extends Animal {}
console.log(Dog.category()); // тварина
console.log(Object.getPrototypeOf(Dog) === Animal); // trueЦе працює завдяки зв’язку між самими конструкторами Dog і Animal.
Дочірній клас може замінити метод батьківського класу:
class Logger {
log(message) {
return `[LOG] ${message}`;
}
}
class JsonLogger extends Logger {
log(message) {
return JSON.stringify({
level: "log",
message,
});
}
}
const logger = new JsonLogger();
console.log(logger.log("Система запущена"));
// {"level":"log","message":"Система запущена"}JavaScript не виконує перевірку типів або сигнатур методів. Дочірній метод може мати іншу кількість параметрів і повністю іншу реалізацію. Сумісність поведінки є відповідальністю розробника.
Поля, оголошені в тілі класу, створюються для кожного екземпляра окремо:
class Counter {
value = 0;
increment() {
this.value += 1;
}
}
const first = new Counter();
const second = new Counter();
first.increment();
console.log(first.value); // 1
console.log(second.value); // 0
console.log(Object.hasOwn(first, "value")); // truevalue не є спільним полем прототипу. Кожен екземпляр отримує власне значення.
Якщо потрібно визначити спільну властивість на прототипі, це можна зробити явно:
class User {}
User.prototype.type = "regular";
const user = new User();
console.log(user.type); // regular
console.log(Object.hasOwn(user, "type")); // falseОднак змінюваний стан зазвичай не варто зберігати на прототипі, оскільки його побачать усі екземпляри:
class Counter {}
Counter.prototype.value = 0;
const first = new Counter();
const second = new Counter();
first.value += 1;
console.log(first.value); // 1
console.log(second.value); // 1Під час операції first.value += 1 JavaScript не знаходить власного value, читає значення з прототипу, а потім створює власну властивість first.value. До цього моменту обидва екземпляри використовували одне прототипне значення.
Приватні поля з префіксом # належать конкретному класу та недоступні безпосередньо ззовні:
class BankAccount {
#balance = 0;
deposit(amount) {
if (amount <= 0) {
throw new RangeError("Сума має бути додатною");
}
this.#balance += amount;
}
getBalance() {
return this.#balance;
}
}
const account = new BankAccount();
account.deposit(100);
console.log(account.getBalance()); // 100
try {
console.log(account.#balance);
} catch (error) {
console.log(error instanceof SyntaxError); // true
}Приватне поле не є звичайною властивістю об’єкта:
його не можна прочитати через account["#balance"];
воно не з’являється в Object.keys();
воно не доступне через прототип;
дочірній клас не може звертатися до приватного поля батьківського класу за тим самим іменем.
Дочірній клас може мати власне приватне поле з таким самим іменем, але це буде інше поле:
class Parent {
#value = "parent";
getParentValue() {
return this.#value;
}
}
class Child extends Parent {
#value = "child";
getChildValue() {
return this.#value;
}
}
const child = new Child();
console.log(child.getParentValue()); // parent
console.log(child.getChildValue()); // childГеттери й сеттери класу також розміщуються на прототипі:
class Temperature {
constructor(celsius) {
this.celsius = celsius;
}
get fahrenheit() {
return this.celsius * 9 / 5 + 32;
}
set fahrenheit(value) {
this.celsius = (value - 32) * 5 / 9;
}
}
const temperature = new Temperature(20);
console.log(temperature.fahrenheit); // 68
temperature.fahrenheit = 86;
console.log(temperature.celsius); // 30Звернення до temperature.fahrenheit виглядає як читання властивості, але насправді запускає геттер.
instanceof і прототипний ланцюжокОператор instanceof перевіряє, чи є prototype правої частини одним із прототипів лівого об’єкта:
class Animal {}
class Dog extends Animal {}
const dog = new Dog();
console.log(dog instanceof Dog); // true
console.log(dog instanceof Animal); // true
console.log(dog instanceof Object); // trueЦе не перевірка фактичного походження об’єкта та не перевірка його структури. Результат залежить від поточного прототипного ланцюжка:
function Device() {}
const device = {};
Object.setPrototypeOf(device, Device.prototype);
console.log(device instanceof Device); // trueОб’єкт не був створений через new Device(), але після зміни прототипу оператор instanceof повертає true.
Через це instanceof може бути непридатним для перевірки об’єктів, отриманих з іншого контексту виконання, наприклад з іншого вікна браузера. Для перевірки можливостей об’єкта часто надійніше використовувати перевірку властивостей або окремий ідентифікатор типу.
Прототип можна змінити за допомогою Object.setPrototypeOf(), але робити це під час роботи програми зазвичай не варто. Така операція може погіршити оптимізацію об’єктів рушієм JavaScript.
const firstPrototype = {
kind: "first",
};
const secondPrototype = {
kind: "second",
};
const object = Object.create(firstPrototype);
console.log(object.kind); // first
Object.setPrototypeOf(object, secondPrototype);
console.log(object.kind); // secondДля створення об’єкта з потрібним прототипом краще одразу використовувати Object.create():
const prototype = {
describe() {
return "Об'єкт описано";
},
};
const object = Object.create(prototype);
console.log(object.describe()); // Об'єкт описаноВластивість __proto__ історично використовується для доступу до прототипу, але для нового коду краще застосовувати:
Object.getPrototypeOf();
Object.setPrototypeOf();
Object.create().
class EventEmitter {
#listeners = new Map();
on(eventName, listener) {
if (!this.#listeners.has(eventName)) {
this.#listeners.set(eventName, new Set());
}
this.#listeners.get(eventName).add(listener);
return this;
}
emit(eventName, payload) {
const listeners = this.#listeners.get(eventName);
if (!listeners) {
return;
}
for (const listener of listeners) {
listener(payload);
}
}
}
class TaskManager extends EventEmitter {
static type = "task-manager";
#tasks = [];
addTask(title) {
const task = {
id: this.#tasks.length + 1,
title,
completed: false,
};
this.#tasks.push(task);
this.emit("taskAdded", task);
return task;
}
completeTask(id) {
const task = this.#tasks.find((item) => item.id === id);
if (!task) {
throw new Error(`Завдання з ідентифікатором ${id} не знайдено`);
}
task.completed = true;
this.emit("taskCompleted", task);
return task;
}
get tasks() {
return this.#tasks.map((task) => ({ ...task }));
}
}
const manager = new TaskManager();
manager
.on("taskAdded", (task) => {
console.log(`Додано завдання: ${task.title}`);
})
.on("taskCompleted", (task) => {
console.log(`Завершено завдання: ${task.title}`);
});
manager.addTask("Вивчити прототипи");
manager.addTask("Перевірити роботу super");
manager.completeTask(1);
console.log(TaskManager.type); // task-manager
console.log(manager.tasks);
/*
[
{ id: 1, title: 'Вивчити прототипи', completed: true },
{ id: 2, title: 'Перевірити роботу super', completed: false }
]
*/
console.log(manager instanceof TaskManager); // true
console.log(manager instanceof EventEmitter); // true
console.log(Object.hasOwn(manager, "emit")); // false
console.log(Object.getPrototypeOf(TaskManager.prototype) === EventEmitter.prototype); // trueУ цьому прикладі:
EventEmitter зберігає приватне поле #listeners;
TaskManager успадковує його методи;
TaskManager перевизначає стан, додаючи власне приватне поле #tasks;
emit знаходиться в прототипі EventEmitter;
статичне поле type належить класу TaskManager;
manager є екземпляром обох класів у ланцюжку успадкування.
Прототипне успадкування корисне, коли між типами є стабільне відношення «є різновидом». Наприклад, собака є твариною.
Але не кожну повторно використовувану поведінку потрібно оформлювати через extends. Часто композиція робить залежності явнішими:
function createLogger(prefix) {
return {
log(message) {
console.log(`[${prefix}] ${message}`);
},
};
}
function createService(logger) {
return {
run() {
logger.log("Сервіс запущено");
},
};
}
const logger = createLogger("APP");
const service = createService(logger);
service.run(); // [APP] Сервіс запущеноКомпозиція може бути кращою, якщо:
об’єкт має кілька незалежних можливостей;
ієрархія класів починає ставати глибокою;
поведінку потрібно змінювати під час створення об’єкта;
залежності зручніше передавати явно.
Успадкування пов’язує класи через прототипи, а композиція передає готові можливості або залежності.
function User(name) {
this.name = name;
this.greet = function () {
return `Привіт, ${this.name}`;
};
}У кожного екземпляра буде власна функція greet. Для спільної поведінки краще використати прототип або класовий метод.
prototype і прототипом об’єктаfunction User() {}
const user = new User();
console.log(User.prototype === Object.getPrototypeOf(user)); // trueUser.prototype належить функції-конструктору, а Object.getPrototypeOf(user) повертає прототип конкретного екземпляра. У цьому випадку об’єкти збігаються, але самі поняття різні.
in замість перевірки власної властивостіconst object = Object.create({ inherited: true });
console.log("inherited" in object); // true
console.log(Object.hasOwn(object, "inherited")); // falseЯкщо потрібно перевірити дані, що належать саме об’єкту, використовуйте Object.hasOwn().
super() у дочірньому конструкторіclass Parent {
constructor(value) {
this.value = value;
}
}
class Child extends Parent {
constructor(value) {
// Помилка: super() не викликано
this.extra = true;
}
}У похідному класі потрібно викликати super() до звернення до this.
class Parent {
#value = 10;
}
class Child extends Parent {
getValue() {
// Помилка: приватне поле Parent недоступне тут
return this.#value;
}
}Для спільного доступу використовуйте захищений за домовленістю метод або геттер, оскільки окремого синтаксису protected у JavaScript немає.
Часте використання Object.setPrototypeOf() може погіршити продуктивність. Якщо прототип відомий заздалегідь, створюйте об’єкти з правильним прототипом одразу або використовуйте класи.
thisМетод класу залежить від контексту виклику:
class User {
constructor(name) {
this.name = name;
}
greet() {
return this.name;
}
}
const user = new User("Олена");
const greet = user.greet;
try {
greet();
} catch (error) {
console.log(error instanceof TypeError); // true
}Якщо метод потрібно передати як callback, його можна прив’язати:
const boundGreet = user.greet.bind(user);
console.log(boundGreet()); // ОленаJavaScript реалізує успадкування через прототипний ланцюжок.
Під час пошуку властивості рушій переходить від об’єкта до його прототипів.
Методи класу зберігаються в ClassName.prototype, а не в кожному екземплярі.
class надає зручний синтаксис над прототипним механізмом, але не замінює його іншою моделлю.
extends формує зв’язок між прототипами екземплярів і між самими конструкторами.
super() потрібен для ініціалізації екземпляра дочірнього класу.
super.method() дає змогу викликати реалізацію батьківського методу.
Статичні члени належать класу, а не його екземплярам.
instanceof перевіряє прототипний ланцюжок, а не «справжній тип» об’єкта.
Приватні поля #field не є доступними через прототип і не успадковуються напряму.
Прототипи варто використовувати для спільної поведінки, а композицію — коли потрібно комбінувати незалежні можливості.