В программировании мы часто хотим взять что-то и расширить.
Например, у нас есть объект user
со своими свойствами и методами, и мы хотим создать объекты admin
и guest
как его слегка изменённые варианты. Мы хотели бы повторно использовать то, что есть у объекта user
, не копировать/переопределять его методы, а просто создать новый объект на его основе.
Прототипное наследование — это возможность языка, которая помогает в этом.
[[Prototype]]
В JavaScript объекты имеют специальное скрытое свойство [[Prototype]]
(так оно названо в спецификации), которое либо равно null
, либо ссылается на другой объект. Этот объект называется «прототип»:
Прототип даёт нам немного «магии». Когда мы хотим прочитать свойство из object
, а оно отсутствует, JavaScript автоматически берёт его из прототипа. В программировании такой механизм называется «прототипным наследованием». Многие интересные возможности языка и техники программирования основываются на нём.
Свойство [[Prototype]]
является внутренним и скрытым, но есть много способов задать его.
Одним из них является использование __proto__
, например так:
let animal = {
eats: true
};
let rabbit = {
jumps: true
};
rabbit.__proto__ = animal;
Если мы ищем свойство в rabbit
, а оно отсутствует, JavaScript автоматически берёт его из animal
.
Например:
let animal = {
eats: true
};
let rabbit = {
jumps: true
};
rabbit.__proto__ = animal; // (*)
// теперь мы можем найти оба свойства в rabbit:
alert( rabbit.eats ); // true (**)
alert( rabbit.jumps ); // true
Здесь строка (*)
устанавливает animal
как прототип для rabbit
.
Затем, когда alert
пытается прочитать свойство rabbit.eats
(**)
, его нет в rabbit
, поэтому JavaScript следует по ссылке [[Prototype]]
и находит его в animal
(смотрите снизу вверх):
Здесь мы можем сказать, что "animal
является прототипом rabbit
" или "rabbit
прототипно наследует от animal
".
Так что если у animal
много полезных свойств и методов, то они автоматически становятся доступными у rabbit
. Такие свойства называются «унаследованными».
Если у нас есть метод в animal
, он может быть вызван на rabbit
:
let animal = {
eats: true,
walk() {
alert("Animal walk");
}
};
let rabbit = {
jumps: true,
__proto__: animal
};
// walk взят из прототипа
rabbit.walk(); // Animal walk
Метод автоматически берётся из прототипа:
Цепочка прототипов может быть длиннее:
let animal = {
eats: true,
walk() {
alert("Animal walk");
}
};
let rabbit = {
jumps: true,
__proto__: animal
};
let longEar = {
earLength: 10,
__proto__: rabbit
};
// walk взят из цепочки прототипов
longEar.walk(); // Animal walk
alert(longEar.jumps); // true (из rabbit)
Теперь, если мы прочтём что-нибудь из longEar
, и оно будет отсутствовать, JavaScript будет искать его в rabbit
, а затем в animal
.
Есть только два ограничения:
- Ссылки не могут идти по кругу. JavaScript выдаст ошибку, если мы попытаемся назначить
__proto__
по кругу. - Значение
__proto__
может быть объектом илиnull
. Другие типы игнорируются.
Это вполне очевидно, но всё же: может быть только один [[Prototype]]
. Объект не может наследоваться от двух других объектов.
__proto__
— исторически обусловленный геттер/сеттер для [[Prototype]]
Это распространённая ошибка начинающих разработчиков – не знать разницы между этими двумя понятиями.
Обратите внимание, что __proto__
— не то же самое, что внутреннее свойство [[Prototype]]
. Это геттер/сеттер для [[Prototype]]
. Позже мы увидим ситуации, когда это имеет значение, а пока давайте просто будем иметь это в виду, поскольку мы строим наше понимание языка JavaScript.
Свойство __proto__
немного устарело, оно существует по историческим причинам. Современный JavaScript предполагает, что мы должны использовать функции Object.getPrototypeOf/Object.setPrototypeOf
вместо того, чтобы получать/устанавливать прототип. Мы также рассмотрим эти функции позже.
По спецификации __proto__
должен поддерживаться только браузерами, но по факту все среды, включая серверную, поддерживают его. Так что мы вполне безопасно его используем.
Далее мы будем в примерах использовать __proto__
, так как это самый короткий и интуитивно понятный способ установки и чтения прототипа.
Операция записи не использует прототип
Прототип используется только для чтения свойств.
Операции записи/удаления работают напрямую с объектом.
В приведённом ниже примере мы присваиваем rabbit
собственный метод walk
:
let animal = {
eats: true,
walk() {
/* этот метод не будет использоваться в rabbit */
}
};
let rabbit = {
__proto__: animal
};
rabbit.walk = function() {
alert("Rabbit! Bounce-bounce!");
};
rabbit.walk(); // Rabbit! Bounce-bounce!
Теперь вызов rabbit.walk()
находит метод непосредственно в объекте и выполняет его, не используя прототип:
Свойства-аксессоры – исключение, так как запись в него обрабатывается функцией-сеттером. То есть это фактически вызов функции.
По этой причине admin.fullName
работает корректно в приведённом ниже коде:
let user = {
name: "John",
surname: "Smith",
set fullName(value) {
[this.name, this.surname] = value.split(" ");
},
get fullName() {
return `${this.name} ${this.surname}`;
}
};
let admin = {
__proto__: user,
isAdmin: true
};
alert(admin.fullName); // John Smith (*)
// срабатывает сеттер!
admin.fullName = "Alice Cooper"; // (**)
alert(admin.name); // Alice
alert(admin.surname); // Cooper
Здесь в строке (*)
свойство admin.fullName
имеет геттер в прототипе user
, поэтому вызывается он. В строке (**)
свойство также имеет сеттер в прототипе, который и будет вызван.
Значение «this»
В приведённом выше примере может возникнуть интересный вопрос: каково значение this
внутри set fullName(value)
? Куда записаны свойства this.name
и this.surname
: в user
или в admin
?
Ответ прост: прототипы никак не влияют на this
.
Неважно, где находится метод: в объекте или его прототипе. При вызове метода this
— всегда объект перед точкой.
Таким образом, вызов сеттера admin.fullName=
в качестве this
использует admin
, а не user
.
Это на самом деле очень важная деталь, потому что у нас может быть большой объект со множеством методов, от которого можно наследовать. Затем наследующие объекты могут вызывать его методы, но они будут изменять своё состояние, а не состояние объекта-родителя.
Например, здесь animal
представляет собой «хранилище методов», и rabbit
использует его.
Вызов rabbit.sleep()
устанавливает this.isSleeping
для объекта rabbit
:
// методы animal
let animal = {
walk() {
if (!this.isSleeping) {
alert(`I walk`);
}
},
sleep() {
this.isSleeping = true;
}
};
let rabbit = {
name: "White Rabbit",
__proto__: animal
};
// модифицирует rabbit.isSleeping
rabbit.sleep();
alert(rabbit.isSleeping); // true
alert(animal.isSleeping); // undefined (нет такого свойства в прототипе)
Картинка с результатом:
Если бы у нас были другие объекты, такие как bird
, snake
и т.д., унаследованные от animal
, они также получили бы доступ к методам animal
. Но this
при вызове каждого метода будет соответствовать объекту (перед точкой), на котором происходит вызов, а не animal
. Поэтому, когда мы записываем данные в this
, они сохраняются в этих объектах.
В результате методы являются общими, а состояние объекта — нет.
Цикл for…in
Цикл for..in
проходит не только по собственным, но и по унаследованным свойствам объекта.
Например:
let animal = {
eats: true
};
let rabbit = {
jumps: true,
__proto__: animal
};
// Object.keys возвращает только собственные ключи
alert(Object.keys(rabbit)); // jumps
// for..in проходит и по своим, и по унаследованным ключам
for(let prop in rabbit) alert(prop); // jumps, затем eats
Если унаследованные свойства нам не нужны, то мы можем отфильтровать их при помощи встроенного метода obj.hasOwnProperty(key): он возвращает true
, если у obj
есть собственное, не унаследованное, свойство с именем key
.
Пример такой фильтрации:
let animal = {
eats: true
};
let rabbit = {
jumps: true,
__proto__: animal
};
for(let prop in rabbit) {
let isOwn = rabbit.hasOwnProperty(prop);
if (isOwn) {
alert(`Our: ${prop}`); // Our: jumps
} else {
alert(`Inherited: ${prop}`); // Inherited: eats
}
}
В этом примере цепочка наследования выглядит так: rabbit
наследует от animal
, который наследует от Object.prototype
(так как animal
– литеральный объект {...}
, то это по умолчанию), а затем null
на самом верху:
Заметим ещё одну деталь. Откуда взялся метод rabbit.hasOwnProperty
? Мы его явно не определяли. Если посмотреть на цепочку прототипов, то видно, что он берётся из Object.prototype.hasOwnProperty
. То есть он унаследован.
…Но почему hasOwnProperty
не появляется в цикле for..in
в отличие от eats
и jumps
? Он ведь перечисляет все унаследованные свойства.
Ответ простой: оно не перечислимо. То есть у него внутренний флаг enumerable
стоит false
, как и у других свойств Object.prototype
. Поэтому оно и не появляется в цикле.
Почти все остальные методы, получающие ключи/значения, такие как Object.keys
, Object.values
и другие – игнорируют унаследованные свойства.
Они учитывают только свойства самого объекта, не его прототипа.
Итого
- В JavaScript все объекты имеют скрытое свойство
[[Prototype]]
, которое является либо другим объектом, либоnull
. - Мы можем использовать
obj.__proto__
для доступа к нему (исторически обусловленный геттер/сеттер, есть другие способы, которые скоро будут рассмотрены). - Объект, на который ссылается
[[Prototype]]
, называется «прототипом». - Если мы хотим прочитать свойство
obj
или вызвать метод, которого не существует уobj
, тогда JavaScript попытается найти его в прототипе. - Операции записи/удаления работают непосредственно с объектом, они не используют прототип (если это обычное свойство, а не сеттер).
- Если мы вызываем
obj.method()
, а метод при этом взят из прототипа, тоthis
всё равно ссылается наobj
. Таким образом, методы всегда работают с текущим объектом, даже если они наследуются. - Цикл
for..in
перебирает как свои, так и унаследованные свойства. Остальные методы получения ключей/значений работают только с собственными свойствами объекта.