Skip to main content

OOP в JavaScript (об'єктно-орієнтоване програмування)

OOP в JavaScript групує дані та поведінку в об'єкти і використовує ланцюжок прототипів (prototype chain) для спільних методів, а не копіює стан у кожен екземпляр.

Теорія

TL;DR

  • Ланцюжок прототипів: dog -> Dog.prototype -> Animal.prototype -> Object.prototype. Перший збіг виграє.
  • Синтаксис class в ES6 - це синтаксичний цукор над прототипами. Під капотом нічого не змінилось.
  • Чотири принципи: інкапсуляція (ховаємо внутрішнє), наслідування (ділимося поведінкою), поліморфізм (один інтерфейс, різні реалізації), абстракція (ховаємо складність)
  • Класи - для ієрархій; Object.create() - для динамічного розширення; фабричні функції - коли наслідування не потрібне

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

javascript
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.prototype

Dog перевизначає 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:

javascript
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():

javascript
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} приносить м'яч`; } }

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

javascript
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. Але коли потрібно поєднати поведінку з кількох різних джерел, наслідування стає незручним. Композиція будує об'єкти з маленьких шматків:

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

javascript
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 в дочірньому конструкторі:

javascript
// Неправильно - кине ReferenceError class Child extends Parent { constructor() { this.prop = 'child'; } } // Правильно class Child extends Parent { constructor() { super(); this.prop = 'child'; } }

Коли використовуєш extends, батьківський конструктор відповідає за створення об'єкта this. До виклику super() this ще не існує.

Мутація спільного прототипу:

javascript
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) або уникай мутабельних спільних прототипів.

Приватні поля і старі збирачі:

javascript
class Secure { #secret = 42; } // Webpack нижче v5 не може розібрати цей синтаксис

Якщо не можеш оновити інструменти, використовуй WeakMap:

javascript
const _secrets = new WeakMap(); class Secure { constructor() { _secrets.set(this, 42); } getSecret() { return _secrets.get(this); } }

Стрілкові функції як поля класу проти методів прототипу:

javascript
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 хешує імена, щоб уникнути конфліктів між бандлами.

Приклади

Базовий: ланцюжок прототипів у дії

javascript
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); // true

super.describe() піднімається до Vehicle.prototype і викликає батьківський метод. Перевірки ланцюжка підтверджують те, що ES6 class налаштовує автоматично.

Середній рівень: логер через композицію

javascript
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

javascript
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) для контейнерів даних, які не мають нічого успадковувати.

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

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

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

Дочитали статтю?