Разбор прототипного наследования, функций-конструкторов, классов ES6 и связи между ними в JavaScript.

Прототипы и классы в JavaScript

JavaScript — это язык с прототипным наследованием, в котором ключевое слово class появилось лишь как синтаксический сахар над уже существовавшим механизмом.

В отличие от языков с классическим наследованием (Java, C++, C#), где объекты создаются по описанию класса, JavaScript изначально строился на другом принципе: один объект может служить образцом для другого. Этот образец называется прототипом (prototype). Понимание прототипов важно не только для чтения чужого кода, но и для осознанного выбора между функциями-конструкторами, Object.create и классами ES6.

Синтаксис class, добавленный в стандарте ES2015, не отменил прототипы — он лишь сделал работу с ними привычнее для разработчиков, пришедших из других языков. Внутри класса по-прежнему лежит цепочка прототипов, а методы живут в свойстве prototype. Поэтому разговор о классах без разговора о прототипах был бы неполным.

Ниже последовательно разбираются: что такое прототип, как работает цепочка прототипов, чем отличаются разные способы создания объектов и где синтаксис классов расходится с обычными функциями.

Что такое прототип

Прототип — это обычный объект, на который ссылается другой объект через внутреннее свойство [[Prototype]]. Когда у объекта запрашивают свойство, которого у него нет, движок ищет его в прототипе, затем в прототипе прототипа и так далее, пока цепочка не оборвётся на null.

Ссылку на прототип можно получить через Object.getPrototypeOf и установить через Object.setPrototypeOf. Исторически для этого использовалось свойство __proto__, но оно считается устаревшим и не рекомендуется в новом коде.

const animal = { eats: true };
const rabbit = { jumps: true };

Object.setPrototypeOf(rabbit, animal);

console.log(rabbit.jumps); // true — своё свойство
console.log(rabbit.eats);  // true — найдено в прототипе
console.log(rabbit.flies); // undefined — нет нигде в цепочке

Важно различать свойство prototype у функций и внутреннее [[Prototype]] у объектов. Это разные вещи, и их часто путают.

Цепочка прототипов

Поиск свойства по цепочке — основной механизм повторного использования кода в JavaScript. Цепочка заканчивается на Object.prototype, чей прототип равен null. Отсюда у любого объекта доступны методы вроде toString и hasOwnProperty.

Проверить, лежит ли свойство в самом объекте, а не в прототипе, помогает hasOwnProperty или Object.hasOwn.

const base = { greet() { return "hi"; } };
const obj = Object.create(base);
obj.name = "test";

console.log(obj.greet());                 // "hi" — из прототипа
console.log(Object.hasOwn(obj, "name"));  // true
console.log(Object.hasOwn(obj, "greet")); // false
Обращение к свойству по цепочке — операция чтения. Запись всегда создаёт или изменяет свойство в самом объекте, даже если такое же имя есть в прототипе.

Функции-конструкторы

До появления классов объекты одного «типа» создавали через функции-конструкторы, вызываемые с оператором new. У каждой такой функции есть свойство prototype — объект, который станет прототипом для создаваемых экземпляров.

Оператор new выполняет четыре действия: создаёт новый объект, связывает его с prototype функции, вызывает функцию с этим объектом в роли this и возвращает результат, если функция не вернула объект явно.

function User(name) {
  this.name = name;
}

User.prototype.sayHi = function () {
  return "Hi, " + this.name;
};

const u = new User("Anna");
console.log(u.sayHi());        // "Hi, Anna"
console.log(u instanceof User); // true

Методы намеренно размещают на prototype, а не внутри конструктора: тогда все экземпляры делят одну функцию, а не получают копию при каждом создании.

Классы ES6

Синтаксис class даёт более компактную запись того же механизма. Методы, объявленные в теле класса, попадают в prototype автоматически, а конструктор вызывается при new.

class User {
  constructor(name) {
    this.name = name;
  }
  sayHi() {
    return "Hi, " + this.name;
  }
}

const u = new User("Anna");
console.log(u.sayHi());   // "Hi, Anna"
console.log(typeof User); // "function"

Несмотря на внешнее сходство с классами других языков, здесь нет статической типизации и нет отдельной сущности «класс» во время выполнения. Класс — это функция, а его методы — свойства prototype.

Наследование через extends и super

Ключевое слово extends связывает прототипы двух классов, а super даёт доступ к родительскому конструктору и методам. Это та же цепочка прототипов, только записанная декларативно.

class Animal {
  constructor(name) { this.name = name; }
  speak() { return this.name + " makes a sound"; }
}

class Dog extends Animal {
  speak() { return this.name + " barks"; }
  parentSpeak() { return super.speak(); }
}

const d = new Dog("Rex");
console.log(d.speak());       // "Rex barks"
console.log(d.parentSpeak()); // "Rex makes a sound"

При extends прототип Dog.prototype ссылается на Animal.prototype. Это позволяет переопределять методы и при необходимости вызывать родительскую версию через super.

Различия классов и функций-конструкторов

Синтаксис классов не полностью эквивалентен функциям-конструкторам. Есть несколько заметных отличий, которые важно учитывать.

  • Классы нельзя вызывать без new — это выбросит ошибку.
  • Тело класса всегда выполняется в строгом режиме.
  • Методы класса неперечисляемы (non-enumerable), в отличие от присвоенных вручную.
  • Классы не поднимаются (no hoisting) так, как функции: обратиться к ним до объявления нельзя.

Эти ограничения делают поведение классов более предсказуемым, но одновременно менее гибким по сравнению с ручным управлением прототипами.

Когда что использовать

Выбор между классами и прототипами — предмет споров. Одни считают, что классы снижают порог входа и делают код единообразным. Другие указывают, что они скрывают реальную модель языка и провоцируют перенос привычек из классических ООП-языков.

Практически: для создания множества однотипных объектов с общими методами удобны классы. Для делегирования поведения между отдельными объектами и композиции без иерархий лучше подходит Object.create и прямое связывание прототипов. Оба подхода опираются на один и тот же механизм.

Что запомнить

  • В основе JavaScript лежит прототипное наследование, а не классическое.
  • Свойство prototype у функции и внутреннее [[Prototype]] у объекта — разные вещи.
  • Поиск свойства идёт по цепочке прототипов до null.
  • Оператор new связывает создаваемый объект с prototype функции.
  • Классы ES6 — синтаксический сахар над прототипами, а не новая модель.
  • extends и super работают через ту же цепочку прототипов.
  • Классы нельзя вызывать без new и они выполняются в строгом режиме.
  • Методы на prototype общие для всех экземпляров, что экономит память.
  • Для композиции и делегирования уместны Object.create и ручное связывание прототипов.