ООП в JavaScript — это способ строить программу из объектов, у которых есть данные (свойства) и поведение (методы). В отличие от Java или C#, наследование в JS прототипное: объект ссылается на другой объект-прототип и берет у него недостающие свойства. Ключевое слово class появилось в ES2015 и дает привычный синтаксис, но экземпляры класса все равно связаны через прототипы.
Содержание
- Мини-словарь: три разных «прототипа»
- Минимальный пример: класс, наследование и цепочка
- Как работает цепочка прототипов
- Четыре способа создать объект
- Функция-конструктор: что делает
new - Классы: extends, super, приватные и статические поля
- Как
extendsвыглядит без классов - Типичные ошибки
- Выводы
- Где применяется / связь с практикой
- FAQ
Ниже — как работает цепочка прототипов, четыре способа создавать объекты (функция-конструктор, 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, движок ищет свойство так:
- Есть ли собственное свойство
propуobj? Если да — вернуть его. - Нет — перейти к
Object.getPrototypeOf(obj)и повторить поиск там. - Дошли до
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.



