Прототипы и классы
До ES6 в JavaScript не было классов вообще — и при этом язык всегда поддерживал наследование. Секрет в прототипах: у каждого объекта есть скрытая ссылка на другой объект, и чтение свойства «проваливается» по цепочке прототипов вниз. Классы из ES6 — не новый механизм, а синтаксический сахар, который настраивает те же прототипы за вас.
Понимание прототипов критично: во-первых, ты будешь читать чужой код (и старый, и новый) годами. Во-вторых, многие «магические» свойства — hasOwnProperty, toString, поведение this в методах — это следствия цепочки. В-третьих, инструменты вроде примесей и фреймворочных base-классов строятся прямо на этом механизме.
Прототипная цепочка
Заголовок раздела «Прототипная цепочка»У каждого объекта есть внутреннее свойство [[Prototype]] (доступ к нему — через Object.getPrototypeOf или устаревшее __proto__). Когда ты читаешь свойство obj.prop, движок:
- Ищет
propсреди собственных свойствobj. - Не нашёл — переходит к
[[Prototype]]и ищет там. - И так далее, пока не найдёт свойство или не дойдёт до
null(обычноObject.prototype→null).
const animal = { eats: true, walk() { console.log(`${this.name} передвигается`); },};
const dog = Object.create(animal); // [[Prototype]] dog = animaldog.name = 'Шарик';dog.barks = true;
console.log(dog.barks); // true — собственное свойствоconsole.log(dog.eats); // true — из прототипа animaldog.walk(); // метод найден в animal, this === dog (!)
console.log(Object.getPrototypeOf(dog) === animal); // trueconsole.log(dog.hasOwnProperty('eats')); // false — не собственноеconsole.log('eats' in dog); // true — in проверяет ВСЮ цепочкуОбрати внимание на this в методе прототипа: когда dog.walk() вызывается, метод найден в animal, но this — это dog (правило «точка» из прошлой главы). Это и есть механизм «наследования» поведения: один метод — для многих объектов.
const cat = Object.create(animal);cat.name = 'Мурка';cat.walk(); // «Мурка передвигается» — тот же метод, другой thisМетод не копируется в каждый объект — он хранится один раз в прототипе. Экономия памяти и возможность обновить поведение всех «наследников» правкой одного прототипа.
Как свойства создаются: присваивание vs определение
Заголовок раздела «Как свойства создаются: присваивание vs определение»Чтение идёт по цепочке, а присваивание (obj.x = 1) всегда пишет в сам объект, даже если свойство есть в прототипе:
const base = { count: 0 };const derived = Object.create(base);
derived.count = 5;console.log(derived.count); // 5 — собственное свойствоconsole.log(base.count); // 0 — прототип не тронутconsole.log(derived.hasOwnProperty('count')); // trueЕсли в прототипе есть геттер без сеттера, присваивание бросит ошибку в строгом режиме — типичный сюрприз:
const ro = Object.create({ get x() { return 42; } });ro.x = 1; // TypeError (strict): setter undefinedГеттеры и сеттеры
Заголовок раздела «Геттеры и сеттеры»Геттеры/сеттеры — это не поля, а функции доступа, оформленные как свойства. Они живут в прототипе так же, как методы:
const temperature = { _celsius: 0,
get fahrenheit() { return this._celsius * 9 / 5 + 32; }, set fahrenheit(value) { this._celsius = (value - 32) * 5 / 9; },};
temperature.fahrenheit = 212;console.log(temperature._celsius); // 100console.log(temperature.fahrenheit); // 212Геттеры — мощный инструмент для вычисляемых свойств и «только для чтения» API (сеттер не объявляем). Но помни: они прячут вызов функции за синтаксисом поля, что усложняет отладку и профилирование.
class в ES6: что под капотом
Заголовок раздела «class в ES6: что под капотом»Синтаксис class — надстройка над прототипами. Разберём эквивалентность:
class Animal { constructor(name) { this.name = name; } speak() { console.log(`${this.name} издаёт звук`); } static compare(a, b) { return a.name.localeCompare(b.name); }}
// То же самое «руками»:function AnimalManual(name) { this.name = name;}AnimalManual.prototype.speak = function () { console.log(`${this.name} издаёт звук`);};AnimalManual.compare = function (a, b) { return a.name.localeCompare(b.name);};Ключевые факты о классах:
constructor— обычная функция-конструктор;new Animal()вызывает её сthis = {}.- Методы класса попадают в
Animal.prototype, а не в экземпляры. - Строгий режим внутри тела класса включён всегда.
- Класс нельзя вызвать без
new(TypeError) — функции можно. - Объявление класса не поднимается как function declaration (находится в TDZ).
Наследование: extends и super
Заголовок раздела «Наследование: extends и super»class Dog extends Animal { constructor(name, breed) { super(name); // ОБЯЗАТЕЛЬНО до this — иначе ReferenceError this.breed = breed; } speak() { // super в методе: вызов метода прототипа (Animal.prototype) super.speak(); console.log(`${this.name} (порода ${this.breed}) лает!`); }}
const rex = new Dog('Рекс', 'овчарка');rex.speak();// «Рекс издаёт звук» ← из Animal.prototype через super// «Рекс (порода овчарка) лает!»
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype); // trueconsole.log(Object.getPrototypeOf(rex) === Dog.prototype); // trueЧто настроил extends под капотом:
Dog.prototype.[[Prototype]] = Animal.prototype— методы Animal доступны через цепочку.Dog.[[Prototype]] = Animal— статические методы наследуются:Dog.compare(...)работает.- Внутри конструктора
super(...)— вызов конструктора родителя с текущимthis.
Метод с super — не стрелка: стрелки не имеют [[HomeObject]], который нужен для поиска super-метода в прототипе.
Псевдо-защищённые члены: соглашения и символы
Заголовок раздела «Псевдо-защищённые члены: соглашения и символы»Между публичным и приватным (#) есть промежуточная зона — «защищённые» члены, доступные наследникам, но не внешнему коду. JS не имеет синтаксиса для этого, поэтому используют соглашения или символы.
Соглашение с подчёркиванием — самое распространённое: _internal. Это договор, а не защита; код снаружи может обратиться, но линтеры (eslint no-underscore-dangle) и ревью отбивают руки:
class BaseRepository { _connect() { /* ... */ } // «защищено»: для наследников findAll() { this._connect(); // внутри иерархии — нормально // ... }}class UserRepository extends BaseRepository { findActive() { this._connect(); // наследник имеет доступ по соглашению // ... }}Символы дают более жёсткую защиту: свойство с ключом-символом не видно в for...in, Object.keys и JSON, хотя формально доступно по ссылке на символ:
const SECRET = Symbol('secret');
class Vault { [SECRET] = 'скрытое значение';
getSecret() { return this[SECRET]; }}
const v = new Vault();console.log(v.getSecret()); // «скрытое значение»console.log(Object.keys(v)); // [] — символьные ключи не перечисляютсяconsole.log(JSON.stringify(v)); // {} — не сериализуютсяconsole.log(v[SECRET]); // доступ есть, если символ утёк наружуДля публичного API библиотеки # + публичные методы — правильный выбор; _-соглашение — для внутренней иерархии классов одного приложения; символы — для служебных метаданных, которые не должны светиться в сериализации.
Встроенные объекты как прототипная иерархия
Заголовок раздела «Встроенные объекты как прототипная иерархия»Всё, что ты ежедневно используешь — массивы, строки, промисы — построено на тех же прототипах. Полезно один раз увидеть цепочку целиком:
const arr = [1, 2, 3];
// Цепочка прототипов массива:// arr → Array.prototype → Object.prototype → null
console.log(Object.getPrototypeOf(arr) === Array.prototype); // trueconsole.log(Object.getPrototypeOf(Array.prototype) === Object.prototype); // true
// Поэтому массив умеет И map, И toString:console.log(arr.hasOwnProperty('map')); // false — map из Array.prototypeconsole.log(arr.toString()); // «1,2,3» — toString из Object.prototypeconsole.log(arr.toReversed?.()); // новые методы — тоже в Array.prototypeОтсюда практическое следствие: полифилы — это правка прототипа встроенных классов. Если нужен метод, которого нет в старых браузерах, его добавляют в Array.prototype/String.prototype с проверкой:
// Полифил: добавляем метод, только если его нетif (!Array.prototype.toSorted) { Array.prototype.toSorted = function (compareFn) { return [...this].sort(compareFn); // не мутируем исходный };}Это легально именно потому, что методы живут в прототипе: одна правка — и все массивы в приложении получают возможность. Но помни граблю из раздела ошибок: расширять встроенные прототипы «для удобства» (не полифилы) — плохая практика. А вот читать их исходники — отличный способ понять язык: Array.prototype.map в спецификации описан обычным алгоритмом с this, length и HasProperty.
Цепочки наследования встроенных классов объясняют и экзотику: почему typeof [] === 'object', почему [] instanceof Object — true, почему у функции есть call/apply (они в Function.prototype), а у функций-генераторов — next (в GeneratorFunction.prototype).
instanceof и constructor: как устроены проверки
Заголовок раздела «instanceof и constructor: как устроены проверки»instanceof проверяет, есть ли Constructor.prototype в прототипной цепочке объекта — не более:
class Animal {}class Dog extends Animal {}
const rex = new Dog();
console.log(rex instanceof Dog); // true — Dog.prototype в цепочкеconsole.log(rex instanceof Animal); // true — Animal.prototype тоже в цепочкеconsole.log(rex instanceof Object); // true — Object.prototype в конце
console.log(Object.getPrototypeOf(rex) === Dog.prototype); // trueconsole.log(Dog.prototype.isPrototypeOf(rex)); // true — тот же тестПод капотом: rex instanceof Dog ≈ Dog.prototype.isPrototypeOf(rex). Отсюда ограничения:
instanceofломается при смене прототипа (Object.setPrototypeOf) и при работе с объектами из других iframe/Realm (у них свойArray.prototype—[] instanceof Arrayвернёт false для чужого массива).- Проверка «планого объекта» через
obj.constructor === Objectненадёжна:constructor— обычное свойство прототипа, которое легко перезаписать или потерять приObject.create(null).
const fake = Object.create(Dog.prototype);console.log(fake instanceof Dog); // true! При этом fake — не настоящий Dog:console.log(fake instanceof Dog && !(fake instanceof Animal)); // false — цепочка та жеДля надёжных проверок типов используй Array.isArray, typeof, Object.prototype.toString.call или флаг-поле, а не instanceof через границы реалмов.
Ещё одна ловушка — Object.setPrototypeOf: он меняет скрытый класс объекта, что в V8 приводит к деоптимизации всего кода, работающего с этим объектом. Правило: задавай прототип при создании (Object.create, class extends), а не переставляй его в горячем коде.
Приватные поля # и статика
Заголовок раздела «Приватные поля # и статика»Приватные поля — настоящая приватность на уровне языка, а не соглашения:
class Wallet { #balance = 0; // приватное поле, недоступно вне класса #transactions = [];
static #maxTransaction = 10_000; // статика тоже бывает приватной static currency = 'RUB'; // публичная статика
deposit(amount) { this.#validate(amount); this.#balance += amount; this.#transactions.push({ type: 'in', amount }); }
#validate(amount) { // приватный метод if (amount <= 0) throw new Error('Сумма должна быть положительной'); if (amount > Wallet.#maxTransaction) throw new Error('Слишком крупная операция'); }
get balance() { return this.#balance; }}
const w = new Wallet();w.deposit(500);console.log(w.balance); // 500 — через геттер// w.#balance // SyntaxError: Private field must be declared// w.#validate(1) // SyntaxErrorВажно: #-поля не участвуют в наследовании напрямую — приватность внутри класса, дочерний класс не видит #-поля родителя (но может пользоваться публичными/защищёнными методами). Если дочернему нужен доступ — выноси логику в protected-подобные методы без #.
Статика: принадлежит самому классу, не экземплярам. Применение — фабрики, константы, утилиты по классу:
class HttpError extends Error { constructor(status, message) { super(message); this.status = status; } static notFound(what = 'Ресурс') { return new HttpError(404, `${what} не найден`); }}
throw HttpError.notFound('Пользователь');Приватность: замыкание против
Заголовок раздела «Приватность: замыкание против»Две модели приватности, которые ты уже встречал:
| Критерий | Замыкание | #-поля |
|---|---|---|
| Уровень | Переменные функции-фабрики | Синтаксис класса |
| Видимость в DevTools | Скрыто (только при отладке) | Видно, но недоступно из кода |
| Наследование | Ребёнок не получает автоматически | Не наследуется, нужны методы-доступы |
| Производительность | Окружение на каждый экземпляр | Поля объекта, оптимизировано V8 |
В современном коде на классах — #. В функциональном стиле и модулях — замыкания.
Примеси (mixins)
Заголовок раздела «Примеси (mixins)»JS не поддерживает множественное наследование классов, но прототипная природа позволяет «подмешивать» поведение. Примесь — объект/фабрика с методами, которые копируются в прототип класса или в сам класс:
// Примесь: объект с методамиconst Serializable = { toJSON() { return { ...this, type: this.constructor.name }; },};
const Timestamped = (Base) => class extends Base { constructor(...args) { super(...args); this.createdAt = new Date(); }};
// Применение: Object.assign в прототипclass Event { constructor(name) { this.name = name; } }Object.assign(Event.prototype, Serializable);
console.log(JSON.stringify(new Event('deploy'))); // {"name":"deploy","type":"Event"}
// Функциональная примесь (mixin-функция): композиция через extendsclass Job extends Timestamped(Event) {}console.log(new Job('backup').createdAt instanceof Date); // trueПримеси полезны для переиспользования поведения без глубоких иерархий, но легко приводят к конфликтам имён — применяй осознанно.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»-
Итерация
for...inпо объекту с прототипом.for...inидёт по всей цепочке. ЛечитсяhasOwnProperty-фильтром илиObject.keys/Object.entries(собственные только). -
Путать
inиhasOwnProperty.'x' in obj— вся цепочка;obj.hasOwnProperty('x')— только собственные. -
Ожидать копирования методов в экземпляры. Методы в прототипе; перебор
for (const m in obj)их увидит,Object.keys— нет. Это часто сбивает при сериализации. -
thisв методе прототипа — не объект-прототип.animal.walk()изdog.walk()даётthis === dog. Если нужен именно прототип — сохраняй его отдельно. -
Изменять встроенные прототипы (
Array.prototype.myMethod = ...). Полифилы — ок, «удобные» расширения — источник конфликтов с будущими версиями стандарта и чужими библиотеками. Вместо этого — утилиты или наследование (class MyArray extends Array). -
superв стрелочном методе или в конструкторе до инициализации. В методах — стрелка ломаетsuper(нет[[HomeObject]]), в конструкторе — раннийthisдоsuper()бросает ReferenceError.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Что такое прототипная цепочка?
Механизм поиска свойств: при чтении движок идёт по цепочке
[[Prototype]]от объекта кnull. Присваивание всегда пишет в самый объект. - Чем
inотличается отhasOwnProperty?inпроверяет всю цепочку прототипов,hasOwnProperty— только собственные свойства. - Что делает
classпод капотом? Создаёт функцию-конструктор, кладёт методы в еёprototype, настраивает цепочки прототипов для наследования и статики. Синтаксический сахар над существующим механизмом. - Зачем
super()доthisв наследуемом конструкторе? До инициализации родительской части экземпляр не готов; ранний доступ кthis— ReferenceError по спецификации. - Наследуются ли приватные поля
#? Нет.#-поля приватны в рамках своего класса; наследники получают только публичные/защищённые-конвенционные интерфейсы. - Как работают геттеры/сеттеры и где они живут при наследовании?
Это функции доступа, определённые через
get/set; хранятся в прототипе, наследуются как обычные методы; присваивание без сеттера — ошибка в strict mode.
Практика
Заголовок раздела «Практика»- Реализуй
inherit(proto)— аналогObject.createчерез конструктор иprototype(без использованияObject.create). Критерий: созданные объекты проходятisPrototypeOfи видят методы прототипа. - Напиши класс
Observable: методыon(event, fn),off(event, fn),emit(event, payload); сделай его примесью — перепиши как функциюwithObservable(Base), возвращающую класс-наследника с этим поведением. Критерий:class Store extends withObservable(Object) {}работает. - Создай
Temperatureс приватным#kelvin, публичными геттерамиcelsius/fahrenheitи сеттерами с валидацией (не ниже 0 K). Критерий: некорректное значение бросает RangeError, прямой доступ к#kelvin— SyntaxError. - Продемонстрируй разницу
Object.keysиfor...inна объекте с прототипом: напиши код, где оба подхода дают разные результаты, и объясни почему. Добавь безопасный вариант итерации собственных + унаследованных свойств с метками. - Реализуй
deepFreeze(obj): рекурсивно замораживает объект и вложенные объекты (Object.freeze + обход значений). Критерий: попытка изменить вложенный объект бросает TypeError в strict mode; массивы иnullобрабатываются корректно.