OOP в JavaScript (об'єктно-орієнтоване програмування)
OOP в JavaScript групує дані та поведінку в об'єкти і використовує ланцюжок прототипів (prototype chain) для спільних методів, а не копіює стан у кожен екземпляр.
Теорія
TL;DR
- Ланцюжок прототипів:
dog -> Dog.prototype -> Animal.prototype -> Object.prototype. Перший збіг виграє. - Синтаксис
classв ES6 - це синтаксичний цукор над прототипами. Під капотом нічого не змінилось. - Чотири принципи: інкапсуляція (ховаємо внутрішнє), наслідування (ділимося поведінкою), поліморфізм (один інтерфейс, різні реалізації), абстракція (ховаємо складність)
- Класи - для ієрархій;
Object.create()- для динамічного розширення; фабричні функції - коли наслідування не потрібне
Швидкий приклад
class Animal {
constructor(name) { this.name = name; }
speak() { return `${this.name} видає звук`; }
}
class Dog extends Animal {
speak() { return `${this.name} гавкає`; } // перевизначає метод батьківського класу
}
const dog = new Dog('Рекс');
console.log(dog.speak()); // "Рекс гавкає"
// Під капотом:
// dog.__proto__ === Dog.prototype
// Dog.prototype.__proto__ === Animal.prototypeDog перевизначає speak() з Animal. Коли викликаєш dog.speak(), рушій знаходить метод на Dog.prototype і зупиняється. Це і є ланцюжок прототипів.
Як працює ланцюжок прототипів
Коли ти звертаєшся до dog.speak(), V8 проходить: сам об'єкт dog, потім Dog.prototype, потім Animal.prototype, потім Object.prototype, потім null. Перший збіг виграє.
Це делегування, а не копіювання. Всі екземпляри Dog використовують один метод speak з Dog.prototype. Java копіює стан у кожен екземпляр. JavaScript - ні. class просто дає зручніший спосіб налаштувати цей ланцюжок, але сам ланцюжок існував завжди.
Чотири принципи OOP
Інкапсуляція ховає внутрішнє і надає контрольований інтерфейс. Приватні поля з # роблять це на рівні мови починаючи з ES2022:
class BankAccount {
#balance = 0;
constructor(initial) { this.#balance = initial; }
deposit(amount) { if (amount > 0) this.#balance += amount; }
getBalance() { return this.#balance; }
}
const account = new BankAccount(1000);
account.deposit(500);
console.log(account.getBalance()); // 1500
// account.#balance -> SyntaxError: private field outside classНаслідування дозволяє дочірньому класу перевикористати і розширити батьківський. Перед зверненням до this треба викликати super():
class Animal {
constructor(name) { this.name = name; }
move() { return `${this.name} рухається`; }
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // обов'язково перед this.breed
this.breed = breed;
}
fetch() { return `${this.name} приносить м'яч`; }
}Поліморфізм означає, що один і той самий метод поводиться по-різному залежно від того, який об'єкт його викликає:
class Shape {
area() { throw new Error('area() не реалізовано'); }
}
class Circle extends Shape {
constructor(r) { super(); this.r = r; }
area() { return Math.PI * this.r ** 2; }
}
class Square extends Shape {
constructor(s) { super(); this.s = s; }
area() { return this.s ** 2; }
}
[new Circle(5), new Square(4)].forEach(s => console.log(s.area().toFixed(2)));
// 78.54
// 16.00Абстракція ховає складність за простим інтерфейсом. Код, що викликає query(), не знає і не має знати, чи це MySQL чи PostgreSQL.
Композиція проти наслідування
Наслідування підходить для справжніх відносин "є" (is-a). Dog є Animal. Але коли потрібно поєднати поведінку з кількох різних джерел, наслідування стає незручним. Композиція будує об'єкти з маленьких шматків:
const canSwim = (state) => ({ swim: () => `${state.name} плаває` });
const canFly = (state) => ({ fly: () => `${state.name} літає` });
const Duck = (name) => {
const state = { name };
return Object.assign({}, canSwim(state), canFly(state));
};
const duck = Duck('Дональд');
console.log(duck.swim()); // "Дональд плаває"
console.log(duck.fly()); // "Дональд літає"Ієрархії глибше 3-4 рівнів зазвичай стають крихкими. Якщо виникає бажання написати super.super, це сигнал перейти на композицію.
Прототипи до ES6
ES6 нічого нового не вигадав. Ось той самий патерн на ES5:
function Person(name) { this.name = name; }
Person.prototype.greet = function() { return `Привіт, я ${this.name}`; };
function Developer(name, lang) {
Person.call(this, name);
this.lang = lang;
}
Developer.prototype = Object.create(Person.prototype);
Developer.prototype.constructor = Developer;
Developer.prototype.code = function() { return `${this.name} пише ${this.lang}`; };
const dev = new Developer('Анна', 'JavaScript');
console.log(dev.greet()); // "Привіт, я Анна"
console.log(dev.code()); // "Анна пише JavaScript"Цей код ще зустрічається в старих проектах. class-версія компілюється приблизно в це.
Коли використовувати OOP
- Спільний стан між методами і чіткий зв'язок "є": класи з наслідуванням
- Поведінка з кількох незв'язаних джерел: композиція
- Нема спільного стану, просто згрупована логіка: фабричні функції або прості об'єкти
- Більше 4-5 рівнів наслідування: переробити на композицію
Типові помилки
Забути super() перед this в дочірньому конструкторі:
// Неправильно - кине ReferenceError
class Child extends Parent {
constructor() { this.prop = 'child'; }
}
// Правильно
class Child extends Parent {
constructor() { super(); this.prop = 'child'; }
}Коли використовуєш extends, батьківський конструктор відповідає за створення об'єкта this. До виклику super() this ще не існує.
Мутація спільного прототипу:
const proto = { greet() { return `Привіт, ${this.name}`; } };
const a = Object.create(proto);
const b = Object.create(proto);
proto.sayBye = () => 'Бувай'; // тепер і a, і b мають sayBye
console.log(a.sayBye()); // "Бувай"
console.log(b.sayBye()); // "Бувай"Це prototype pollution. Саме через такий механізм спрацював lodash CVE-2018-3721. Заморожуй спільні об'єкти через Object.freeze(proto) або уникай мутабельних спільних прототипів.
Приватні поля і старі збирачі:
class Secure { #secret = 42; }
// Webpack нижче v5 не може розібрати цей синтаксисЯкщо не можеш оновити інструменти, використовуй WeakMap:
const _secrets = new WeakMap();
class Secure {
constructor() { _secrets.set(this, 42); }
getSecret() { return _secrets.get(this); }
}Стрілкові функції як поля класу проти методів прототипу:
class Counter {
count = 0;
increment = () => { this.count++; }; // окрема функція для кожного екземпляра
decrement() { this.count--; } // спільна на прототипі, дешевша
}Обидва варіанти коректні. Стрілки вирішують проблему прив'язки this при передачі як колбек, але коштують більше пам'яті. Звичайні методи дешевші, але потребують .bind() на місці виклику.
Глибокі ланцюжки прототипів і продуктивність:
V8 добре кешує пошук у плоских структурах. Ланцюжки довші 5 рівнів призводять до промахів кешу. Це помітно в гарячих циклах; у звичайному коді програми різниця мінімальна.
Як V8 обробляє це всередині
V8 зберігає [[Prototype]] кожного об'єкта як прихований вказівник. Звернення до властивості запускає обхід OrdinaryGetPrototypeOf по ланцюжку. V8 кешує ці пошуки в прихованих класах (shapes) для повторного доступу. new Dog() встановлює [[Prototype]] на Dog.prototype. Ось і все, що робить class.
Де зустрічається в реальних проектах
- React:
class Component extends React.Componentдля методів lifecycle.PureComponent- це простоComponentз іншимshouldComponentUpdateна прототипі. - Node.js streams:
Readable,Transform,Duplexланцюжком через прототипи.Transform extends Readable. - Express: middleware як ланцюжки прототипів;
app.use()делегує до прототипу роутера. - Lodash: утилітарні міксини через
Object.assign(proto, mixins)для спільних методів.
Питання на співбесіді
Q: Яка різниця між полями класу і методами прототипу?
A: Поля класу створюють власні властивості для кожного екземпляра: кожен об'єкт отримує свою копію в пам'яті. Методи прототипу спільні: одна функція на прототипі для всіх екземплярів.
Q: Чому super() треба викликати перед this в дочірньому конструкторі?
A: Коли використовуєш extends, батьківський конструктор відповідає за створення об'єкта this. До виклику super() this ще не існує, звернення до нього кидає ReferenceError.
Q: Чим Object.create(null) відрізняється від {}?
A: {} має Object.prototype в ланцюжку, тому успадковує toString, hasOwnProperty тощо. Object.create(null) не має прототипу взагалі. Корисно для чистих словників, де успадковані ключі можуть спричинити баги.
Q: Чому instanceof іноді дає неправильні результати з Object.create()?
A: instanceof перевіряє чи є prototype конструктора десь у ланцюжку прототипів об'єкта. Якщо ланцюжок зібраний вручну без прив'язки до конструктора, instanceof його не знайде.
Q: (Senior) Як реалізувати приватний стан без полів #, і який компроміс?
A: Використовуй WeakMap з екземпляром як ключем. Запис автоматично видаляється збирачем сміття, коли екземпляр знищується - витоку пам'яті немає. Symbol як ключ теж працює, але видимий через Object.getOwnPropertySymbols(). Поля # справді приватні: V8 хешує імена, щоб уникнути конфліктів між бандлами.
Приклади
Базовий: ланцюжок прототипів у дії
class Vehicle {
constructor(make) { this.make = make; }
describe() { return `Це ${this.make}`; }
}
class Car extends Vehicle {
constructor(make, model) {
super(make);
this.model = model;
}
describe() {
return `${super.describe()} ${this.model}`; // викликає Vehicle.describe()
}
}
const car = new Car('Toyota', 'Camry');
console.log(car.describe()); // "Це Toyota Camry"
// Перевірка ланцюжка вручну
console.log(Object.getPrototypeOf(car) === Car.prototype); // true
console.log(Object.getPrototypeOf(Car.prototype) === Vehicle.prototype); // truesuper.describe() піднімається до Vehicle.prototype і викликає батьківський метод. Перевірки ланцюжка підтверджують те, що ES6 class налаштовує автоматично.
Середній рівень: логер через композицію
class Logger {
constructor(prefix) {
this.prefix = prefix;
this.logs = [];
}
log(message) {
const entry = `[${this.prefix}] ${message}`;
this.logs.push(entry);
console.log(entry);
return entry;
}
getLogs() { return [...this.logs]; } // повертаємо копію, а не сам масив
}
class UserService {
constructor() {
this.logger = new Logger('UserService'); // композиція: has-a Logger
}
login(user) { return this.logger.log(`Login: ${user.name}`); }
logout(user) { return this.logger.log(`Logout: ${user.name}`); }
}
const service = new UserService();
service.login({ name: 'Аліса' });
service.logout({ name: 'Аліса' });
console.log(service.logger.getLogs());
// ["[UserService] Login: Аліса", "[UserService] Logout: Аліса"]UserService використовує Logger через композицію, не наслідування. Якщо вимоги до логування зміняться, міняємо або розширюємо Logger, не чіпаючи UserService. Цей патерн я бачив як заміну цілих дерев наслідування в продакшені - там, де хтось спочатку запхав логер у базовий клас через extends.
Просунутий рівень: prototype pollution
const sharedProto = { greet() { return `Привіт, ${this.name}`; } };
const user1 = Object.create(sharedProto);
user1.name = 'Боб';
const user2 = Object.create(sharedProto);
user2.name = 'Кароль';
// Мутація спільного прототипу впливає на всі об'єкти в ланцюжку
sharedProto.isAdmin = () => true;
console.log(user1.isAdmin()); // true - очікувано
console.log(user2.isAdmin()); // true - user2 цього не просивСаме так спрацював lodash CVE-2018-3721. JSON-пейлоад від атакувальника дістався до __proto__ і ввів властивості в Object.prototype, вплинувши на всі об'єкти процесу Node.js. Заморожуй спільні прототипи через Object.freeze(sharedProto), або використовуй Object.create(null) для контейнерів даних, які не мають нічого успадковувати.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.