У каждого объекта есть прототип
JavaScript — язык с прототипным наследованием. Звучит экзотично, но на деле всё просто: у каждого объекта есть скрытая ссылка на другой объект — его прототип. И когда вы обращаетесь к свойству, которого у объекта нет, JavaScript идёт по этой ссылке и ищет его там.
У объекта rabbit нет собственного свойства eats. JavaScript ищет его в rabbit, не находит, идёт по ссылке на прототип к animal, находит там eats: true и возвращает значение. Для flies он тоже проходит по цепочке, ничего не находит и возвращает undefined.
Вот и весь механизм — поиск и проход по цепочке. На этом держится всё: наследование, методы, class.
Цепочка прототипов в JavaScript
Цепочка прототипов javascript не ограничивается одним шагом. У прототипа может быть свой прототип, у того — ещё один, и так до тех пор, пока не упрёмся в null:
Запусти — и увидишь rabbit, затем Object.prototype, а после — null. Именно поэтому rabbit.toString() работает, хотя метод toString ты нигде не объявлял: он живёт в Object.prototype, на вершине почти любой цепочки прототипов.
Поиск свойства идёт по этой цепочке снизу вверх. А вот присваивание работает иначе — оно всегда пишет в сам объект и вверх по цепочке не поднимается. Эта асимметрия важна и регулярно ставит людей в тупик.
Функции-конструкторы и .prototype
До появления class стандартным способом создавать кучу однотипных объектов была функция-конструктор, вызываемая через new:
Когда вы вызываете new User("Ada"), происходят две вещи:
- Создаётся новый объект, а его прототип устанавливается в
User.prototype. - Запускается функция
User, гдеthisпривязан к этому новому объекту.
Метод greet не копируется в каждый экземпляр. Он живёт в единственном экземпляре на User.prototype, и оба объекта — ada и boris — находят его, поднимаясь по цепочке прототипов. Именно поэтому последняя строка выводит true: это буквально одна и та же функция.
Чем отличается __proto__ от prototype
Эти два названия сбивают с толку буквально всех. Они связаны между собой, но это не одно и то же.
User.prototype— это свойство самой функции-конструктора. Именно этот объект становится прототипом экземпляров, созданных черезnew User(...).ada.__proto__(илиObject.getPrototypeOf(ada)) — это ссылка на экземпляре, которая ведёт наверх, к его прототипу.
В новом коде лучше использовать Object.getPrototypeOf(obj) вместо obj.__proto__. __proto__ — это легаси-аксессор, который оставили ради обратной совместимости, а вот функция — это уже официальный API.
Классы — это просто синтаксический сахар
Современный JavaScript позволяет писать class, но под капотом вы всё равно работаете с прототипами. Сравните две версии бок о бок:
greet оказался в User.prototype — ровно там же, где был бы, если бы вы прописали его руками. Ключевое слово class в основном даёт более опрятный синтаксис, более строгие правила (без new не обойтись) и удобный способ делать extends. Но модель исполнения при этом ровно та же.
Понимание этого пригодится, когда вы читаете сообщения об ошибках или отлаживаете this. Ошибка про «User.prototype.greet» — это не какое-то загадочное внутреннее имя, а буквально адрес, по которому живёт метод.
Наследование — это просто длиннее цепочка прототипов
extends связывает один прототип с другим: прототип родителя становится прототипом прототипа наследника:
Когда мы обращаемся к rex.eat, движок идёт по цепочке: rex → Dog.prototype → Animal.prototype, находит там eat и вызывает его, при этом this по-прежнему указывает на rex. Собственно, это и есть вся работа extends — он просто выстраивает за вас цепочку прототипов.
Создание объектов напрямую с заданным прототипом
Конструктор вообще не обязателен. Object.create(proto) создаёт новый объект с тем прототипом, который вы ему передали:
Никаких class, никаких new, никаких функций-конструкторов. Просто два объекта, разделяющих один метод через общий прототип. Это самая «голая» форма прототипного наследования в JavaScript — всё остальное построено поверх неё.
hasOwnProperty: собственные и унаследованные свойства
Поскольку поиск идёт вверх по цепочке прототипов, выражение "foo" in obj вернёт true и для унаследованных свойств. Если нужно понять, принадлежит ли свойство именно этому объекту, используйте Object.hasOwn (или более старый hasOwnProperty):
name живёт на самом экземпляре, а greet — на прототипе. Оператор in находит оба, а Object.hasOwn — только первый. Это важно, когда вы перебираете объект через for...in или сериализуете его: как правило, нужны именно собственные свойства.
Не патчите встроенные прототипы
Поскольку Array.prototype общий для всех массивов в программе, технически вы можете добавить туда свои методы:
// Пожалуйста, не делайте так.
Array.prototype.last = function () {
return this[this.length - 1];
};
[1, 2, 3].last(); // 3
Проблема не в том, что это не работает — работает прекрасно. Проблема в том, что каждая библиотека, каждая зависимость и каждая будущая версия JavaScript делят с вами это пространство имён. Когда Array.prototype.last однажды появится как настоящий метод — пусть даже с чуть-чуть другой семантикой, — ваш код (или чужой) сломается самым незаметным образом. История с Array.prototype.flatten и Array.prototype.flat — хрестоматийный пример того, как делать не надо.
Держите вспомогательные функции отдельно:
Меньше общих поверхностей — меньше шансов случайно что-то сломать.
Ментальная модель
Если убрать всю шелуху, прототипы в JavaScript сводятся к трём правилам:
- У каждого объекта есть ссылка на прототип (иногда она равна
null). - Чтение свойств идёт вверх по цепочке, запись — нет.
class,newиextends— это просто удобные способы выстроить эти цепочки, не дёргая рукамиObject.create.
Держите эти три пункта в голове — и поведение this, instanceof, разрешение методов и наследование сразу встанут на свои места.
Дальше: событийный цикл
На этом объектная модель закрыта. В следующей главе нас ждёт совсем другая тема — как JavaScript на самом деле выполняет ваш код во времени. Event loop — именно то, благодаря чему таймеры, промисы и async/await работают так, как работают. Это фундамент всей асинхронности.
Часто задаваемые вопросы
Что такое прототип в JavaScript?
У каждого объекта в JavaScript есть внутренняя ссылка на другой объект — его прототип. Когда вы обращаетесь к свойству, которого у самого объекта нет, движок идёт по этой ссылке вверх — по так называемой цепочке прототипов — и ищет свойство там. Именно благодаря этому механизму методы, описанные один раз, доступны всем экземплярам.
В чём разница между __proto__ и prototype?
prototype — это свойство функций-конструкторов (и классов). Тот самый объект, который станет прототипом для экземпляров, созданных через new. А __proto__ (или Object.getPrototypeOf(obj)) — это уже реальная ссылка на прототип у конкретного экземпляра. Отсюда и равенство: instance.__proto__ === Constructor.prototype.
Классы в JavaScript — это просто синтаксический сахар над прототипами?
По большей части да. Запись class Foo { bar() {} } кладёт bar в Foo.prototype — ровно так же, как если бы вы написали function Foo(){} и Foo.prototype.bar = function(){}. Классы добавляют приватные поля, более строгую семантику и удобный синтаксис для extends и super, но под капотом всё равно работают прототипы.
Стоит ли добавлять методы во встроенные прототипы вроде Array.prototype?
Практически никогда. Любое изменение Array.prototype или Object.prototype затрагивает вообще все массивы и объекты в программе — включая те, что пришли из сторонних библиотек. Это может конфликтовать с будущими возможностями языка и ломать циклы for...in. Свои хелперы лучше держать в отдельных функциях или модулях.