Как работает цепочка прототипов в JavaScript
Разберитесь в поиске по цепочке прототипов JavaScript, различиях между prototype и [[Prototype]], перекрытии унаследованных свойств и безопасной проверке связей.
При чтении свойства JavaScript сначала проверяет собственные свойства объекта, затем переходит по внутренней ссылке [[Prototype]] к следующему объекту и повторяет этот шаг, пока не найдёт свойство или не дойдёт до null. Во втором случае чтение возвращает undefined.
Правило простое, но разобраться в нём становится сложнее, когда prototype, __proto__ и [[Prototype]] встречаются рядом на панели DevTools или в вопросе на собеседовании. Firefox и вовсе обозначает эту ссылку как <prototype>, когда вы раскрываете объект в консоли.
Эта статья посвящена только тому, как поиск свойства проходит по цепочке: какой объект отвечает на чтение, куда попадает запись и какие API использовать для просмотра прототипа. О функциях-конструкторах, классах и паттернах наследования через Object.create читайте в статьях A Beginner’s Guide to OOP in JavaScript и Inheritance in JavaScript Object-Oriented Programming.
Ключевые выводы
- При чтении свойства сначала проверяются собственные свойства, затем по очереди каждый
[[Prototype]]. Если цепочка заканчивается наnull, возвращаетсяundefined. - Свойство
prototypeфункции становится[[Prototype]]экземпляров, созданных черезnew. Это не собственный прототип функции. __proto__— устаревший аксессор, унаследованный отObject.prototype. Вместо него используйтеObject.getPrototypeOf()иObject.setPrototypeOf().- Присваивание унаследованному свойству обычно создаёт собственное свойство, которое затеняет унаследованное и оставляет прототип без изменений.
Object.create(null)создаёт объект без цепочки прототипов, поэтому он подходит в качестве безопасного словаря для произвольных строковых ключей.
Как работает правило поиска по цепочке прототипов в JavaScript?
Цепочка прототипов в JavaScript — это последовательность объектов, связанных через [[Prototype]], по которой проходит чтение свойства, пока не найдёт совпадение или не дойдёт до null. У каждого обычного объекта есть внутренний слот [[Prototype]], который указывает на другой объект или на null. Порядок чтения определяет алгоритм OrdinaryGet из спецификации. Он ищет собственное свойство, а если его нет, повторяет поиск в прототипе. У большинства объектов цепочка заканчивается на Object.prototype, прототип которого равен null.
const animal = { eats: true };
const rabbit = Object.create(animal);
rabbit.jumps = true;
console.log(rabbit.jumps); // true (own property)
console.log(rabbit.eats); // true (found on animal)
console.log(rabbit.toString === Object.prototype.toString); // true
console.log(rabbit.flies); // undefined (not found anywhere)
console.log(Object.getPrototypeOf(Object.prototype)); // null
// rabbit -> animal -> Object.prototype -> null
Чтение отсутствующего свойства не вызывает ошибку. TypeError выбрасывает именно вызов rabbit.flies(), поскольку вызывается undefined. В руководстве MDN Inheritance and the prototype chain та же модель рассмотрена подробнее.
Чем свойство prototype функции отличается от [[Prototype]] объекта?
Свойство prototype функции — это объект, который становится [[Prototype]] экземпляров, созданных через new. Это не собственный прототип функции: им является Function.prototype. Чтобы получить реальную ссылку у любого объекта, используйте Object.getPrototypeOf().
class Greeter { hello() { return 'hi'; } }
const g = new Greeter();
console.log(Object.getPrototypeOf(g) === Greeter.prototype); // true
console.log(Object.getPrototypeOf(Greeter) === Function.prototype); // true
console.log(Object.getPrototypeOf(Greeter) === Greeter.prototype); // false
const arrow = () => {};
console.log(arrow.prototype); // undefined
console.log(Greeter.prototype.hello.prototype); // undefined
У стрелочных функций и обычных методов класса свойства prototype нет вовсе, поэтому чтение .prototype у них возвращает undefined.
| Название | Что это | Где находится | Как прочитать |
|---|---|---|---|
[[Prototype]] | Внутренняя ссылка, по которой идёт поиск | У каждого объекта (внутренний слот) | Object.getPrototypeOf(obj) |
obj.__proto__ | Устаревший геттер/сеттер для этой ссылки | Object.prototype | Не используйте; применяйте getPrototypeOf |
Fn.prototype | Объект, назначаемый экземплярам, созданным через new Fn() | Функции-конструкторы и классы | Fn.prototype |
Что такое proto?
__proto__ — это свойство-аксессор, определённое в Object.prototype. Оно не хранится в каждом объекте, поэтому у объекта, который не наследуется от Object.prototype, его нет. MDN помечает Object.prototype.__proto__ как Deprecated и поясняет, что оно сохраняется в стандарте только ради работоспособности старых сайтов. Вместо него MDN рекомендует Object.getPrototypeOf() и Object.setPrototypeOf().
const proto = { greet() { return 'hello'; } };
const obj = Object.create(proto);
console.log(Object.getPrototypeOf(obj) === proto); // true
console.log(Object.hasOwn(obj, '__proto__')); // false
console.log(Object.hasOwn(Object.prototype, '__proto__')); // true
const bare = Object.create(null);
console.log(bare.__proto__); // undefined
const lit = { __proto__: proto };
console.log(Object.getPrototypeOf(lit) === proto); // true
Ключ { __proto__: value } в объектном литерале — это стандартный синтаксис, не связанный с аксессором, и он не считается устаревшим. Прототип задаёт только форма с двоеточием. Вычисляемый ключ ["__proto__"] создаёт обычное собственное свойство.
Даже при использовании правильного API старайтесь не менять прототип после создания объекта. MDN предупреждает, что это «в настоящее время очень медленная операция в любом браузере и движке JavaScript». Задавайте прототип при создании объекта.
Затенение: собственные свойства скрывают унаследованные
Поиск останавливается на первом совпадении, поэтому собственное свойство скрывает любое одноимённое свойство выше по цепочке. Присваивание работает так же. Если этому не мешает унаследованный сеттер или унаследованное свойство, недоступное для записи, алгоритм OrdinarySet из спецификации создаёт или обновляет собственное свойство целевого объекта. Унаследованное свойство затеняется, а не изменяется.
const defaults = { theme: 'light' };
const settings = Object.create(defaults);
console.log(settings.theme); // 'light' (inherited)
settings.theme = 'dark'; // creates an own property
console.log(defaults.theme); // 'light' (prototype untouched)
console.log(Object.hasOwn(settings, 'theme')); // true
delete settings.theme;
console.log(settings.theme); // 'light' (inherited again)
// settings -> defaults -> Object.prototype -> null
Object.hasOwn() показывает, какой уровень цепочки ответил на чтение. MDN рекомендует использовать его вместо hasOwnProperty().
Как методы Array.prototype становятся доступны каждому массиву?
У каждого массива, созданного литералом массива, [[Prototype]] равен Array.prototype. Поэтому map, filter и push работают с любым массивом без копирования в него. Встроенные объекты тоже используют затенение: Array.prototype.toString скрывает Object.prototype.toString.
const nums = [1, 2, 3];
console.log(Object.getPrototypeOf(nums) === Array.prototype); // true
console.log(Object.hasOwn(nums, 'map')); // false
console.log(nums.map === Array.prototype.map); // true
console.log(Object.getPrototypeOf(Array.prototype) === Object.prototype); // true
console.log(nums.toString()); // '1,2,3'
console.log(Object.prototype.toString.call(nums)); // '[object Array]'
// nums -> Array.prototype -> Object.prototype -> null
Object.create(null): объекты без цепочки прототипов
Object.create(null) создаёт объект, у которого [[Prototype]] равен null. Он ничего не наследует: ни toString, ни hasOwnProperty, ни аксессора __proto__. Поэтому объект с null-прототипом — безопасный контейнер для ключей, полученных из пользовательского ввода. У обычного {} унаследованные имена вроде constructor возвращают значение ещё до того, как вы что-либо сохранили.
const words = ['a', 'constructor', 'a'];
const plain = {};
for (const w of words) plain[w] = (plain[w] ?? 0) + 1;
console.log(plain.constructor); // a string like "function Object() { [native code] }1"
const counts = Object.create(null);
for (const w of words) counts[w] = (counts[w] ?? 0) + 1;
console.log(counts.constructor); // 1
console.log(counts.a); // 2
console.log('toString' in counts); // false
// counts -> null
У объекта с null-прототипом нет унаследованных методов, поэтому вызывайте Object.hasOwn(counts, key), а не counts.hasOwnProperty(key). Подстановка такого объекта в шаблонную строку выбрасывает TypeError, так как нет toString для преобразования в строку. Если ключи не являются строками, используйте Map.
Используют ли классы ту же цепочку прототипов?
Да. Синтаксис классов не вводит новую модель наследования. Методы, объявленные в теле класса, находятся в ClassName.prototype, и экземпляры получают к ним доступ через тот же поиск по [[Prototype]], что и любые другие объекты. extends связывает объект prototype одного класса с объектом prototype родительского.
class Animal { speak() { return 'sound'; } }
class Dog extends Animal { bark() { return 'woof'; } }
const d = new Dog();
console.log(typeof Dog); // 'function'
console.log(Object.hasOwn(d, 'speak')); // false
console.log(Object.getPrototypeOf(d) === Dog.prototype); // true
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype); // true
console.log(d.speak()); // 'sound'
// d -> Dog.prototype -> Animal.prototype -> Object.prototype -> null
Заключение
Каждое чтение свойства идёт по одному и тому же пути. JavaScript проверяет сам объект, затем по очереди каждый [[Prototype]], пока не найдёт свойство или не дойдёт до null. Запись остаётся в том объекте, которому вы присваиваете значение. Когда в следующий раз значение возьмётся словно ниоткуда, вызовите Object.hasOwn() для объекта, затем Object.getPrototypeOf() для каждого звена и выпишите цепочку, пока не найдёте объект, в котором на самом деле хранится свойство.
Часто задаваемые вопросы
Чем оператор in отличается от Object.hasOwn()?
Оператор in проверяет всю цепочку прототипов, поэтому 'toString' in {} возвращает true: toString наследуется от Object.prototype. Object.hasOwn(obj, key) проверяет только собственные свойства объекта и возвращает false для всего унаследованного. Используйте in, когда унаследованное свойство тоже должно учитываться, и Object.hasOwn(), когда нужно узнать, содержит ли ключ сам объект.
Как instanceof использует цепочку прототипов?
По умолчанию obj instanceof C возвращает true, если C.prototype встречается где-либо в цепочке прототипов obj. Это не доказывает, что объект создан с помощью C: проверку пройдёт любой объект, в цепочке которого есть C.prototype. Класс может заменить эту проверку, определив статический метод Symbol.hasInstance. Для примитивных значений слева проверка по умолчанию всегда возвращает false.
Включают ли циклы for...in и Object.keys() унаследованные свойства?
Цикл for...in перебирает перечисляемые свойства со строковыми ключами как у самого объекта, так и у всех объектов выше по его цепочке прототипов. Object.keys(), Object.entries() и spread-оператор для объектов учитывают только собственные перечисляемые свойства объекта. Методы встроенных прототипов, таких как Object.prototype и Array.prototype, неперечисляемые, поэтому в for...in они не появляются. А вот перечисляемые свойства ваших собственных объектов-прототипов — появляются.
Может ли цепочка прототипов в JavaScript содержать цикл?
Для обычных объектов — нет. Object.setPrototypeOf() выбрасывает TypeError, если новый прототип создаст цикл, например при попытке сделать b прототипом a, когда b уже наследуется от a. Присваивание через сеттер __proto__ выбрасывает ту же ошибку. Поскольку циклы запрещены, цепочка обычных объектов всегда конечна и заканчивается на null, поэтому поиск свойства всегда завершается.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k