ООП в JavaScript: прототипы, конструкторы и классы

ООП в JavaScript: прототипы, конструкторы и классы Полезное

ООП в JavaScript — это способ строить программу из объектов, у которых есть данные (свойства) и поведение (методы). В отличие от Java или C#, наследование в JS прототипное: объект ссылается на другой объект-прототип и берет у него недостающие свойства. Ключевое слово class появилось в ES2015 и дает привычный синтаксис, но экземпляры класса все равно связаны через прототипы.

Ниже — как работает цепочка прототипов, четыре способа создавать объекты (функция-конструктор, class, Object.create, фабричная функция), наследование через extends и частые ошибки с this и new. Все примеры проверены в Node.js 24.21 (24 LTS), вывод взят из реального запуска.

Мини-словарь: три разных «прототипа»

Слово «прототип» в JS обозначает три разные вещи. Их путают чаще всего, поэтому сразу разведем:

Термин Что это Как получить
скрытая ссылка Prototype внутренняя ссылка объекта на его прототип (в спецификации — слот Prototype в двойных квадратных скобках) Object.getPrototypeOf(obj)
F.prototype обычное свойство функции-конструктора или класса: объект, который станет прототипом экземпляров, созданных через new F() F.prototype
__proto__ устаревший аксессор к скрытой ссылке, оставлен для совместимости в новом коде не использовать

Цепочка на одном примере: rex -> Dog.prototype -> Animal.prototype -> Object.prototype -> null.

Минимальный пример: класс, наследование и цепочка

class Animal {
  constructor(name) {
    this.name = name;            // собственное свойство экземпляра
  }
  speak() {                      // метод попадает в Animal.prototype
    return `${this.name} издает звук`;
  }
}

class Dog extends Animal {
  speak() {                      // переопределение: Dog.prototype.speak
    return `${super.speak()} и лает`;
  }
}

const rex = new Dog("Рекс");
console.log(rex.speak());
console.log(Object.hasOwn(rex, "name"), Object.hasOwn(rex, "speak"));
console.log(Object.getPrototypeOf(rex) === Dog.prototype);
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype);
console.log(Object.getPrototypeOf(Animal.prototype) === Object.prototype);
console.log(Object.getPrototypeOf(Object.prototype));
console.log(typeof Animal, rex instanceof Animal);

Вывод:

Рекс издает звук и лает
true false
true
true
true
null
function true

Что здесь видно. Свойство name лежит в самом объекте, а метода speak в нем нет: он хранится в Dog.prototype и один на все экземпляры. typeof Animal дает function — класс в JS по-прежнему функция-конструктор. Цепочка заканчивается на null: у Object.prototype прототипа нет.

Как работает цепочка прототипов

Когда вы читаете obj.prop, движок ищет свойство так:

  1. Есть ли собственное свойство prop у obj? Если да — вернуть его.
  2. Нет — перейти к Object.getPrototypeOf(obj) и повторить поиск там.
  3. Дошли до null и не нашли — результат undefined (ошибки нет).

Это правило для чтения. Запись работает иначе: obj.prop = value обычно создает собственное свойство у obj и прототип не меняет (исключения — сеттер или свойство только для чтения в цепочке).

const base = {
  kind: "base",
  greet() { return `привет, я ${this.kind}`; },
};
const child = Object.create(base);   // прототип child - это base

console.log(child.greet());          // метод найден в base, this = child
child.kind = "child";                // запись создает СОБСТВЕННОЕ свойство
console.log(child.greet(), "|", base.kind);
delete child.kind;                   // удалили свое - снова виден прототип
console.log(child.kind);
console.log(child.missing);          // не нашли до конца цепочки
console.log(Object.keys(child));     // только собственные ключи
for (const key in child) console.log("for...in:", key); // и унаследованные

Вывод:

привет, я base
привет, я child | base
base
undefined
[]
for...in: kind
for...in: greet

Важная деталь: метод берется из прототипа, но this внутри него — тот объект, у которого метод вызвали (child). Поэтому один метод обслуживает все экземпляры. И второе: for...in обходит и унаследованные перечисляемые свойства, а Object.keys — только собственные.

Четыре способа создать объект

Одна задача — пользователь с именем и методом greet — решенная четырьмя способами:

// 1. Функция-конструктор + prototype
function UserF(name) {
  this.name = name;
}
UserF.prototype.greet = function () {
  return `Я ${this.name}`;
};

// 2. Класс
class UserC {
  constructor(name) {
    this.name = name;
  }
  greet() {
    return `Я ${this.name}`;
  }
}

// 3. Связывание объектов: Object.create + init
const UserProto = {
  init(name) {
    this.name = name;
    return this;
  },
  greet() {
    return `Я ${this.name}`;
  },
};

// 4. Фабричная функция
function createUser(name) {
  return {
    greet() {
      return `Я ${name}`;          // name из замыкания, не из this
    },
  };
}

const users = [
  new UserF("Аня"),
  new UserC("Боря"),
  Object.create(UserProto).init("Вика"),
  createUser("Гоша"),
];
for (const u of users) {
  console.log(u.greet(), "| greet в прототипе:", !Object.hasOwn(u, "greet"));
}
console.log(new UserF("a").greet === new UserF("b").greet);
console.log(createUser("a").greet === createUser("b").greet);

Вывод:

Я Аня | greet в прототипе: true
Я Боря | greet в прототипе: true
Я Вика | greet в прототипе: true
Я Гоша | greet в прототипе: false
true
false

Первые три способа кладут greet в прототип — одна функция на все объекты. Фабрика в таком виде создает новую функцию для каждого объекта: это цена приватности через замыкание (имя нельзя изменить снаружи). Для сотен объектов разница в памяти незаметна, для миллионов — уже может быть заметна.

Способ Нужен new Методы в прототипе Приватность Когда брать
Функция-конструктор да через F.prototype только замыкания чтение старого кода и библиотек до ES2015
class да, без него ошибка автоматически поля #name основной выбор для нового кода
Object.create + init нет объект-прототип задается явно нет встроенной когда нужен явный прототип без классов
Фабричная функция нет нет, если не вернуть объект с прототипом замыкание небольшие объекты, композиция, скрытое состояние

Функция-конструктор: что делает new

Вызов new UserF("Аня") упрощенно выполняет четыре шага: создает пустой объект, назначает ему прототип UserF.prototype, вызывает UserF с this, указывающим на новый объект, и возвращает этот объект (если функция сама не вернула другой объект).

Если new забыть, шаги 1, 2 и 4 не происходят, и this указывает не туда:

function UserF(name) {
  this.name = name;
}
const u = UserF("Аня");           // забыли new
console.log(u);                   // функция ничего не вернула
console.log(globalThis.name);     // свойство ушло в глобальный объект

class UserC {
  constructor(name) {
    this.name = name;
  }
}
try {
  UserC("Боря");
} catch (e) {
  console.log(`${e.name}: ${e.message}`);
}

Вывод при запуске как CommonJS-скрипта (node file.js, нестрогий режим):

undefined
Аня
TypeError: Class constructor UserC cannot be invoked without 'new'

Поведение зависит от режима. В нестрогом коде this при обычном вызове — глобальный объект, и функция молча портит его. В строгом режиме ("use strict", ES-модули .mjs, код внутри классов) this равен undefined, и уже первая строка падает: TypeError: Cannot set properties of undefined (setting 'name'). Класс защищен всегда. Функцию-конструктор можно защитить проверкой new.target:

function UserF(name) {
  if (!new.target) {
    throw new TypeError("UserF нужно вызывать через new");
  }
  this.name = name;
}
try {
  UserF("Аня");
} catch (e) {
  console.log(`${e.name}: ${e.message}`);
}
console.log(new UserF("Аня").name);

Вывод:

TypeError: UserF нужно вызывать через new
Аня

Классы: extends, super, приватные и статические поля

Класс часто называют синтаксическим сахаром над функцией-конструктором. Это упрощение: цепочка прототипов действительно та же, но у классов есть отличия, которых нет у обычной функции. Тело класса всегда в строгом режиме, методы неперечисляемые (не видны в for...in), класс нельзя вызвать без new, до объявления к нему нельзя обратиться, а приватные поля # доступны только синтаксисом класса.

class Account {
  #balance = 0;                    // приватное поле, суммы - в копейках
  static count = 0;                // статическое свойство класса

  constructor(owner) {
    this.owner = owner;
    Account.count++;
  }
  deposit(kopecks) {
    if (!Number.isSafeInteger(kopecks) || kopecks <= 0) {
      throw new RangeError(`некорректная сумма: ${kopecks}`);
    }
    this.#balance += kopecks;
    return this;
  }
  get balance() {                  // только чтение снаружи
    return this.#balance;
  }
}

class SavingsAccount extends Account {
  constructor(owner, rate) {
    super(owner);                  // до super() this недоступен
    this.rate = rate;
  }
  addInterest() {
    const interest = Math.round(this.balance * this.rate);
    return interest > 0 ? this.deposit(interest) : this; // 0 копеек - не ошибка
  }
}

const acc = new SavingsAccount("Аня", 0.1);
acc.deposit(100000).addInterest();
console.log(acc.balance, Account.count);
console.log(Object.keys(acc), acc["#balance"]);
for (const bad of [-5, 0.5, NaN, "100"]) {
  try {
    acc.deposit(bad);
  } catch (e) {
    console.log(`${e.name}: ${e.message}`);
  }
}

Вывод:

110000 1
[ 'owner', 'rate' ] undefined
RangeError: некорректная сумма: -5
RangeError: некорректная сумма: 0.5
RangeError: некорректная сумма: NaN
RangeError: некорректная сумма: 100

Разбор. Приватное поле #balance не видно ни в Object.keys, ни через acc["#balance"], а прямое acc.#balance вне класса — синтаксическая ошибка еще до запуска. Геттер balance дает только чтение: в строгом режиме (ES-модуль) попытка acc.balance = 1 бросает TypeError, в нестрогом скрипте запись молча игнорируется. Суммы хранятся целыми копейками, чтобы не ловить ошибки округления 0.1 + 0.2, а метод отклоняет не только отрицательные числа, но и дроби, NaN и строку. Проверка в addInterest нужна из-за того же валидатора: на нулевом балансе или при сумме меньше копейки процент округляется до 0, и прямой вызов deposit(0) бросил бы RangeError: некорректная сумма: 0 — начисление «ничего» не должно ронять программу.

Частая ошибка в наследнике — обратиться к this до super():

class Base {}
class Broken extends Base {
  constructor() {
    this.x = 1;                    // обращение к this до super()
    super();
  }
}
try {
  new Broken();
} catch (e) {
  console.log(`${e.name}: ${e.message}`);
}

Вывод (текст сообщения — движка V8 в Node.js 24, в других браузерах формулировка иная):

ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor

Исправление — первой строкой конструктора вызвать super(...), как в SavingsAccount выше. Если конструктор наследника не нужен, его можно не писать: по умолчанию он передаст все аргументы в super.

Как extends выглядит без классов

Чтобы понимать старый код и то, что делает extends, полезно один раз собрать наследование вручную:

function Animal(name) {
  this.name = name;
}
Animal.prototype.speak = function () {
  return `${this.name} издает звук`;
};

function Dog(name) {
  Animal.call(this, name);                         // аналог super(name)
}
Dog.prototype = Object.create(Animal.prototype);   // связываем цепочку
Dog.prototype.constructor = Dog;                   // восстанавливаем ссылку
Dog.prototype.speak = function () {
  return `${Animal.prototype.speak.call(this)} и лает`;
};

const rex = new Dog("Рекс");
console.log(rex.speak(), rex instanceof Animal, rex.constructor.name);

Вывод: Рекс издает звук и лает true Dog. Если пропустить строку с constructor, rex.constructor.name вернет Animal — ссылка унаследуется от Animal.prototype. В новом коде этот шаблон заменяет class Dog extends Animal.

Типичные ошибки

Потеря this при передаче метода. Метод, сохраненный в переменную или переданный как колбэк (setTimeout(obj.method)), вызывается без объекта:

class Counter {
  count = 0;
  inc() {
    return ++this.count;
  }
}
const c = new Counter();
const inc = c.inc;                 // метод оторван от объекта
try {
  inc();
} catch (e) {
  console.log(`${e.name}: ${e.message}`);
}
const bound = c.inc.bind(c);       // исправление 1: bind
console.log(bound(), bound());

class Counter2 {
  count = 0;
  inc = () => ++this.count;        // исправление 2: стрелка в поле класса
}
const c2 = new Counter2();
const inc2 = c2.inc;
console.log(inc2(), Object.hasOwn(c2, "inc"));

Вывод:

TypeError: Cannot read properties of undefined (reading 'count')
1 2
1 true

Стрелочная функция в поле удобна для обработчиков событий, но она собственное свойство каждого экземпляра, а не метод прототипа. Отсюда ловушка при наследовании: если наследник объявит обычный метод inc(), он лежит в прототипе и перекрывается полем родителя — new Child().inc() вызовет родительскую стрелку. А super.inc() из наследника не найдет ее вовсе, потому что в Counter2.prototype метода нет.

Изменяемые данные в прототипе. Прототип общий для всех экземпляров, поэтому массив или объект в нем тоже общий:

function Cart() {}
Cart.prototype.items = [];         // ошибка: один массив на все корзины

const a = new Cart();
const b = new Cart();
a.items.push("книга");
console.log(b.items);

function FixedCart() {
  this.items = [];                 // исправление: свое состояние - в конструкторе
}
const x = new FixedCart();
const y = new FixedCart();
x.items.push("книга");
console.log(y.items);

Вывод: сначала [ 'книга' ] (товар появился в чужой корзине), затем []. Правило: в прототипе — методы и константы, изменяемое состояние — в конструкторе или полях класса.

Расширение встроенных прототипов. Добавить метод всем объектам через Object.prototype технически можно, но он появится в каждом for...in:

Object.prototype.sayHello = function () {
  return "Hello";
};
const user = { name: "Аня" };
console.log(user.sayHello());
for (const key in user) console.log("for...in:", key);
delete Object.prototype.sayHello;

Вывод: Hello, затем for...in: name и for...in: sayHello. Кроме лишних ключей, такое расширение конфликтует с будущими методами стандарта и чужими библиотеками. Вместо этого пишите обычную функцию или свой класс.

Выводы

  • ООП в JavaScript построено на прототипах: объект ищет недостающее свойство по цепочке прототипов до null, а запись создает собственное свойство.
  • скрытая ссылка Prototype (у объекта), F.prototype (свойство конструктора) и __proto__ (устаревший аксессор) — разные вещи; в коде используйте Object.getPrototypeOf и Object.hasOwn.
  • Для нового кода основной выбор — class с extends, super и приватными полями #; функции-конструкторы нужно уметь читать, Object.create и фабрики — брать под конкретную задачу.
  • Частые ошибки: вызов конструктора без new, this до super(), потеря this у оторванного метода, изменяемый массив в прототипе, расширение Object.prototype.

Где применяется / связь с практикой

Прототипы и классы встречаются в любом JS-проекте: модели данных и сервисы на Node.js, компоненты и обработчики событий во фронтенде, собственные классы ошибок (class ValidationError extends Error), обертки над API. Понимание цепочки прототипов помогает читать код библиотек, отлаживать this и решать задачи на собеседованиях.

Освойте тему на практике

Если хотите системно разобрать ООП, асинхронность и архитектуру приложений на JavaScript с практикой и ревью кода, посмотрите курс «JavaScript-разработчик. Продвинутый уровень». Попробовать формат можно на бесплатных открытых уроках Otus.

FAQ

Можно ли поменять прототип уже созданного объекта?
Да, через Object.setPrototypeOf(obj, proto), но движки оптимизируют объекты под их форму, и смена прототипа после создания может замедлить код. Лучше задавать прототип сразу: через class, new или Object.create(proto).

Что такое Object.create(null) и зачем он нужен?
Это объект без прототипа: у него нет toString, hasOwnProperty и других унаследованных свойств. Его используют как «чистый» словарь, где ключ вроде "constructor" не пересечется с унаследованным свойством; для той же задачи часто удобнее Map.

Чем instanceof отличается от проверки через Object.getPrototypeOf?
obj instanceof F проверяет, есть ли F.prototype где-либо в цепочке объекта, а Object.getPrototypeOf(obj) === F.prototype — только ближайшее звено. Поэтому rex instanceof Animal дает true, хотя прямой прототип rex — Dog.prototype.

OTUS Журнал
Бесплатные открытые уроки (поп-ап)