Вивчай

Прототипи та класи

JavaScript прототипи та класи — родинне дерево вирізьблене на дерев'яній дошці з трьома поколіннямиJavaScript прототипи та класи — родинне дерево вирізьблене на дерев'яній дошці з трьома поколіннями

JavaScript -- унікальна мова: наслідування в ній працює через прототипи, а не через класи, як у Java чи C#. ES6 додав синтаксис class, але під капотом -- все ті ж прототипи. У цьому уроці розберемось, як це працює, і навчимось писати ООП-код на JavaScript.


Прототипний ланцюжок (Prototype Chain)

Кожен об'єкт у JavaScript має прихований зв'язок з іншим об'єктом -- своїм прототипом. Коли ти звертаєшся до властивості, якої немає в самому об'єкті, JavaScript шукає її у прототипі:

const user = { name: "Олексій" };

// toString() не визначений в user, але працює!
console.log(user.toString()); // "[object Object]"

Звідки toString()? З прототипу Object.prototype, який є у кожного об'єкта.

// Подивитись на прототип
console.log(Object.getPrototypeOf(user)); // Object.prototype
console.log(Object.getPrototypeOf(user) === Object.prototype); // true

Ланцюжок пошуку

user → Object.prototype → null

JavaScript шукає властивість по ланцюжку, поки не знайде або не дійде до null.

Увага

Властивість __proto__ -- застарілий спосіб доступу до прототипу. Використовуй Object.getPrototypeOf(obj) для читання та Object.create(proto) для створення об'єкта з певним прототипом.


Створення об'єктів з прототипом

const animal = {
  eat() {
    console.log(`${this.name} їсть`);
  },
  sleep() {
    console.log(`${this.name} спить`);
  },
};

// Створюємо об'єкт з animal як прототипом
const cat = Object.create(animal);
cat.name = "Мурчик";
cat.meow = function() {
  console.log(`${this.name}: Мяу!`);
};

cat.eat();   // "Мурчик їсть" (метод з прототипу)
cat.meow();  // "Мурчик: Мяу!" (власний метод)
cat.sleep(); // "Мурчик спить" (метод з прототипу)

Це основа наслідування в JavaScript. Але писати так щоразу незручно. Саме тому з'явились класи.


ES6 Classes -- синтаксичний цукор

Класи -- зручний синтаксис для створення об'єктів з прототипами:

class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }

  greet() {
    return `Привіт, я ${this.name}!`;
  }

  getInfo() {
    return `${this.name} (${this.email})`;
  }
}

const user = new User("Олексій", "alex@test.com");
console.log(user.greet());   // "Привіт, я Олексій!"
console.log(user.getInfo()); // "Олексій (alex@test.com)"

Що робить new?

Коли ти пишеш new User(...), JavaScript:

  1. Створює порожній об'єкт {}
  2. Встановлює йому прототип User.prototype
  3. Викликає constructor з this = новий об'єкт
  4. Повертає цей об'єкт
// Перевірка
console.log(user instanceof User); // true
console.log(typeof User);          // "function" (класи -- це функції!)
Інфо

Під капотом class User { ... } створює функцію-конструктор і додає методи до User.prototype. Тобто class -- це буквально "синтаксичний цукор" над прототипами. Але синтаксис класів набагато зрозуміліший, тому використовуй саме його.


Getters та Setters

Дозволяють контролювати доступ до властивостей:

class Product {
  constructor(name, price) {
    this.name = name;
    this._price = price; // конвенція: _ означає "приватне"
  }

  get price() {
    return `${this._price} грн`;
  }

  set price(value) {
    if (value < 0) {
      throw new Error("Ціна не може бути від'ємною");
    }
    this._price = value;
  }
}

const item = new Product("Книга", 250);
console.log(item.price);     // "250 грн" (getter)
item.price = 300;             // setter
console.log(item.price);     // "300 грн"
// item.price = -10;          // Error: Ціна не може бути від'ємною

Наслідування: extends та super

extends дозволяє створити клас на основі іншого:

class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }

  greet() {
    return `Привіт, я ${this.name}!`;
  }

  getRole() {
    return "user";
  }
}

class Admin extends User {
  constructor(name, email, permissions) {
    super(name, email); // виклик конструктора батьківського класу
    this.permissions = permissions;
  }

  getRole() {
    return "admin"; // перевизначення (override) методу
  }

  hasPermission(action) {
    return this.permissions.includes(action);
  }
}

const admin = new Admin("Марія", "maria@test.com", ["delete", "edit"]);
console.log(admin.greet());                // "Привіт, я Марія!" (від User)
console.log(admin.getRole());              // "admin" (перевизначений)
console.log(admin.hasPermission("delete")); // true
console.log(admin instanceof Admin);        // true
console.log(admin instanceof User);         // true
Увага

У конструкторі дочірнього класу super() обов'язково викликається перед використанням this. Інакше буде помилка ReferenceError.

Виклик методів батька через super

class Admin extends User {
  greet() {
    const baseGreeting = super.greet(); // виклик greet() з User
    return `${baseGreeting} Я адміністратор.`;
  }
}

const admin = new Admin("Марія", "maria@test.com", []);
console.log(admin.greet());
// "Привіт, я Марія! Я адміністратор."

Статичні методи та властивості

static -- методи і властивості, що належать класу, а не екземплярам:

class MathHelper {
  static PI = 3.14159;

  static square(x) {
    return x * x;
  }

  static clamp(value, min, max) {
    return Math.min(Math.max(value, min), max);
  }
}

// Викликаються через клас, НЕ через екземпляр
console.log(MathHelper.PI);          // 3.14159
console.log(MathHelper.square(5));   // 25
console.log(MathHelper.clamp(15, 0, 10)); // 10

Типовий приклад -- фабричний метод:

class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }

  static fromJSON(json) {
    const data = JSON.parse(json);
    return new User(data.name, data.email);
  }
}

const user = User.fromJSON('{"name":"Олексій","email":"alex@test.com"}');
console.log(user.name); // "Олексій"

Приватні поля (#)

ES2022 додав справжні приватні поля -- вони недоступні ззовні класу:

class BankAccount {
  #balance; // приватне поле

  constructor(initialBalance) {
    this.#balance = initialBalance;
  }

  deposit(amount) {
    this.#balance += amount;
  }

  getBalance() {
    return this.#balance;
  }
}

const account = new BankAccount(1000);
account.deposit(500);
console.log(account.getBalance()); // 1500
// console.log(account.#balance);  // SyntaxError!
Порада

Приватні поля # -- справжня приватність, на відміну від конвенції з _ (underscore). Використовуй # для чутливих даних. Конвенцію _ -- для "не чіпай це" підказки іншим розробникам.


instanceof -- перевірка типу

class Animal {}
class Dog extends Animal {}
class Cat extends Animal {}

const dog = new Dog();
const cat = new Cat();

console.log(dog instanceof Dog);    // true
console.log(dog instanceof Animal); // true
console.log(dog instanceof Cat);    // false

// Працює з вбудованими типами
console.log([] instanceof Array);   // true
console.log({} instanceof Object);  // true

Практика: система користувачів

class User {
  #password;

  constructor(name, email, password) {
    this.name = name;
    this.email = email;
    this.#password = password;
    this.createdAt = new Date();
  }

  checkPassword(input) {
    return input === this.#password;
  }

  getInfo() {
    return `${this.name} (${this.email})`;
  }

  static createGuest() {
    return new User("Гість", "guest@example.com", "");
  }
}

class Admin extends User {
  #permissions;

  constructor(name, email, password, permissions = []) {
    super(name, email, password);
    this.#permissions = permissions;
  }

  hasPermission(action) {
    return this.#permissions.includes(action);
  }

  getInfo() {
    return `[Admin] ${super.getInfo()}`;
  }
}

// Використання
const user = new User("Олексій", "alex@test.com", "secret123");
const admin = new Admin("Марія", "maria@test.com", "admin456", ["delete", "ban"]);
const guest = User.createGuest();

console.log(user.getInfo());                // "Олексій (alex@test.com)"
console.log(admin.getInfo());               // "[Admin] Марія (maria@test.com)"
console.log(admin.hasPermission("delete")); // true
console.log(guest.getInfo());               // "Гість (guest@example.com)"
console.log(user.checkPassword("secret123")); // true

Класи vs прості об'єкти: коли що?

СитуаціяЩо використати
Прості дані (config, options)Об'єкт { }
Одноразова структураОб'єкт { }
Багато екземплярів одного типуclass
Потрібне наслідуванняclass з extends
Потрібна інкапсуляція (приватні поля)class з #
Набір утиліт без стануМодуль з функціями (урок 6.6)
Порада

У React (Block 8) компоненти пишуть як функції, а не класи. Але класи залишаються важливими для моделей даних, сервісів, бібліотек та патернів на кшталт Singleton. У TypeScript (Block 7) класи стануть ще потужнішими завдяки типізації.


Підсумок

  • Прототип -- об'єкт, в якому JavaScript шукає властивості, якщо їх немає в поточному об'єкті
  • class -- зручний синтаксис для створення об'єктів з прототипами
  • constructor -- метод, що викликається при new
  • extends -- наслідування, super -- виклик батьківського конструктора/методу
  • static -- методи та властивості, що належать класу
  • #поле -- справжні приватні поля (ES2022)
  • instanceof -- перевірка, чи є об'єкт екземпляром класу
  • Класи -- "синтаксичний цукор" над прототипами

Блок JavaScript Advanced завершено! Далі -- Block 7: TypeScript.

Інфо

Корисні посилання: