Menu
Русский

Прототипы в JavaScript: цепочка за каждым объектом

Разбираемся, что такое прототипы в JavaScript, как работает цепочка прототипов при поиске свойств и как синтаксис class ложится на этот же механизм под капотом.

У каждого объекта есть прототип

JavaScript — язык с прототипным наследованием. Звучит экзотично, но на деле всё просто: у каждого объекта есть скрытая ссылка на другой объект — его прототип. И когда вы обращаетесь к свойству, которого у объекта нет, JavaScript идёт по этой ссылке и ищет его там.

index.js
Output
Click Run to see the output here.

У объекта rabbit нет собственного свойства eats. JavaScript ищет его в rabbit, не находит, идёт по ссылке на прототип к animal, находит там eats: true и возвращает значение. Для flies он тоже проходит по цепочке, ничего не находит и возвращает undefined.

Вот и весь механизм — поиск и проход по цепочке. На этом держится всё: наследование, методы, class.

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

Цепочка прототипов javascript не ограничивается одним шагом. У прототипа может быть свой прототип, у того — ещё один, и так до тех пор, пока не упрёмся в null:

index.js
Output
Click Run to see the output here.

Запусти — и увидишь rabbit, затем Object.prototype, а после — null. Именно поэтому rabbit.toString() работает, хотя метод toString ты нигде не объявлял: он живёт в Object.prototype, на вершине почти любой цепочки прототипов.

Поиск свойства идёт по этой цепочке снизу вверх. А вот присваивание работает иначе — оно всегда пишет в сам объект и вверх по цепочке не поднимается. Эта асимметрия важна и регулярно ставит людей в тупик.

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

До появления class стандартным способом создавать кучу однотипных объектов была функция-конструктор, вызываемая через new:

index.js
Output
Click Run to see the output here.

Когда вы вызываете new User("Ada"), происходят две вещи:

  1. Создаётся новый объект, а его прототип устанавливается в User.prototype.
  2. Запускается функция User, где this привязан к этому новому объекту.

Метод greet не копируется в каждый экземпляр. Он живёт в единственном экземпляре на User.prototype, и оба объекта — ada и boris — находят его, поднимаясь по цепочке прототипов. Именно поэтому последняя строка выводит true: это буквально одна и та же функция.

Чем отличается __proto__ от prototype

Эти два названия сбивают с толку буквально всех. Они связаны между собой, но это не одно и то же.

  • User.prototype — это свойство самой функции-конструктора. Именно этот объект становится прототипом экземпляров, созданных через new User(...).
  • ada.__proto__ (или Object.getPrototypeOf(ada)) — это ссылка на экземпляре, которая ведёт наверх, к его прототипу.
index.js
Output
Click Run to see the output here.

В новом коде лучше использовать Object.getPrototypeOf(obj) вместо obj.__proto__. __proto__ — это легаси-аксессор, который оставили ради обратной совместимости, а вот функция — это уже официальный API.

Классы — это просто синтаксический сахар

Современный JavaScript позволяет писать class, но под капотом вы всё равно работаете с прототипами. Сравните две версии бок о бок:

index.js
Output
Click Run to see the output here.

greet оказался в User.prototype — ровно там же, где был бы, если бы вы прописали его руками. Ключевое слово class в основном даёт более опрятный синтаксис, более строгие правила (без new не обойтись) и удобный способ делать extends. Но модель исполнения при этом ровно та же.

Понимание этого пригодится, когда вы читаете сообщения об ошибках или отлаживаете this. Ошибка про «User.prototype.greet» — это не какое-то загадочное внутреннее имя, а буквально адрес, по которому живёт метод.

Наследование — это просто длиннее цепочка прототипов

extends связывает один прототип с другим: прототип родителя становится прототипом прототипа наследника:

index.js
Output
Click Run to see the output here.

Когда мы обращаемся к rex.eat, движок идёт по цепочке: rexDog.prototypeAnimal.prototype, находит там eat и вызывает его, при этом this по-прежнему указывает на rex. Собственно, это и есть вся работа extends — он просто выстраивает за вас цепочку прототипов.

Создание объектов напрямую с заданным прототипом

Конструктор вообще не обязателен. Object.create(proto) создаёт новый объект с тем прототипом, который вы ему передали:

index.js
Output
Click Run to see the output here.

Никаких class, никаких new, никаких функций-конструкторов. Просто два объекта, разделяющих один метод через общий прототип. Это самая «голая» форма прототипного наследования в JavaScript — всё остальное построено поверх неё.

hasOwnProperty: собственные и унаследованные свойства

Поскольку поиск идёт вверх по цепочке прототипов, выражение "foo" in obj вернёт true и для унаследованных свойств. Если нужно понять, принадлежит ли свойство именно этому объекту, используйте Object.hasOwn (или более старый hasOwnProperty):

index.js
Output
Click Run to see the output here.

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 — хрестоматийный пример того, как делать не надо.

Держите вспомогательные функции отдельно:

index.js
Output
Click Run to see the output here.

Меньше общих поверхностей — меньше шансов случайно что-то сломать.

Ментальная модель

Если убрать всю шелуху, прототипы в 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. Свои хелперы лучше держать в отдельных функциях или модулях.

Учитесь программировать с Coddy

НАЧАТЬ