1. Прототип объекта
ООП в JavaScript построено на прототипном наследовании. Объекты можно
организовать в цепочки так, чтобы свойство, не найденное в одном объекте,
автоматически искалось бы в другом. Связующим звеном выступает специальное
скрытое свойство [[Prototype]]
, в консоли оно отображается как __proto__
.
1.1. Прототип
Если один объект имеет специальную ссылку в поле __proto__
на другой объект,
то при чтении свойства из него, если искомое свойство отсутствует в самом
объекте, оно ищется в объекте на который ссылается __proto__
.
const animal = { eats: true };
const dog = { barks: true };
dog.__proto__ = animal;
// В dog можно найти оба свойства
console.log(dog.barks); // true
console.log(dog.eats); // true
Первый лог работает очевидным образом — он выводит свойство barks
объекта
dog
. Второй лог хочет вывести dog.eats
, ищет его в самом объекте dog
, не
находит и продолжает поиск в объекте по ссылке из dog.__proto__
, то есть, в
данном случае, в animal
.
Объект, на который указывает ссылка в __proto__
,
называется прототипом. В данном примере animal
- это прототип для
dog
.
Если мы добавим объекту dog
свойство eats
и присвоим ему false
, то
результат будет следующим.
const animal = { eats: true };
const dog = { barks: true, eats: false };
dog.__proto__ = animal;
console.log(dog.barks); // true
console.log(dog.eats); // false, свойство взято из dog
Другими словами, прототип — это резервное хранилище свойств и методов объекта, автоматически используемое при поиске. У объекта, который выступает прототипом может также быть свой прототип, у того свой, и так далее. При этом свойства будут искаться по цепочке наследования.
В спецификации свойство __proto__
обозначено как [[Prototype]]
. Двойные
квадратные скобки здесь важны, они указывают на то, что это внутреннее,
служебное свойство.
Несмотря на то, что свойство __proto__
на сегодняшний
день стандартизовано и проще для объяснения материала, его изменение напрямую
является грубым нарушением. На практике используются методы для манипуляции с
прототипами, такие как Object.create()
, Object.getPrototypeOf()
,
Object.setPrototypeOf()
и другие.
1.2. Object.create()
Для того чтобы правильно задать прототип объекта, можно использовать метод
Object.create(obj)
, передав параметром obj
ссылку на объект, который мы
хотим сделать прототипом для создаваемого объекта.
const animal = { eats: true };
const dog = Object.create(animal);
dog.barks = true;
console.log(dog.barks); // true
console.log(dog.eats); // true
На рисунке ниже видно то, что называется цепочкой прототипов (prototype
chain). В свойство __proto__
объекта dog
записана ссылка на объект
animal
, в свойство __proto__
которого, в свою очередь, записана ссылка на
родителя всех объектов Object
. Именно поэтому мы можем вызывать методы вроде
hasOwnProperty
или toString
, хотя мы не определяли их для dog
или
animal
.
Механизм поиска свойства работает до первого совпадения. Интерпретатор ищет
свойство по имени в объекте, если не находит, то обращается к свойству
[[Prototype]]
, т.е. переходит по ссылке к объекту-прототипу, а затем и
прототипу прототипа.
В конце этой цепочки находится null
. В случае первого совпадения будет
возвращено значение свойства. Если интерпретатор доберется до конца цепочки и не
найдет свойства с таким ключом, то вернет undefined
.
Данный механизм называется динамическая диспетчеризация (dynamic dispatch) или делегация (delegation). В отличие от статической диспетчеризации, когда ссылки разрешаются во время компиляции, динамическая диспетчеризация всегда разрешает ссылки во время исполнения программы.
1.3. Object.prototype.hasOwnProperty()
После того как мы узнали о том, как происходит поиск свойств объекта, должно
стать понятно, почему цикл for...in
не делает различия между свойствами
объекта и его прототипа.
const animal = { eats: true };
const dog = Object.create(animal);
dog.barks = true;
for (const key in dog) {
console.log(key); // barks, eats
}
Именно поэтому мы используем метод obj.hasOwnProperty(prop)
, который
возвращает true
, если свойство prop
принадлежит самому объекту obj
, а не
его прототипу, иначе false
.
const animal = { eats: true };
const dog = Object.create(animal);
dog.barks = true;
for (const key in dog) {
if (!dog.hasOwnProperty(key)) continue;
console.log(key); // barks
}
Метод Object.keys(obj)
вернет массив только собственных ключей объекта obj
,
поэтому рекомендуется использовать именно его.
const animal = { eats: true };
const dog = Object.create(animal);
dog.barks = true;
const dogKeys = Object.keys(dog);
console.log(dogKeys); // ['barks']
2. Свойство Function.prototype
Мы уже знаем, что такое прототип объекта, свойство __proto__
, как происходит
поиск отсутствующих свойств объекта по цепочке и методы указания прототипа для
одного объекта. Но в реальных задачах объекты создаются динамически, то есть
функцией-конструктором, через new
. Давайте разберемся как происходит указание
прототипа в этом случае.
Создадим функцию-конструктор Guest
, которая будет создавать нам экземпляры
объектов гостя отеля.
const Guest = function (name, room) {
this.name = name;
this.room = room;
};
Так как функция - это тоже объект, у каждой функции, кроме стрелочных, есть
свойство prototype
в котором изначально хранится объект с единственным полем
constructor
, указывающим на саму функцию-конструктор.
У стрелочных функций нет свойства prototype
, потому что
их нельзя вызвать через new
, и, соответственно, в нем нет
необходимости.
Свойство Function.prototype
:
- Является объектом
- В него можно записывать свойства и методы
- Свойства и методы
prototype
будут доступны по ссылке__proto__
объекта - У свойства
prototype
изначально есть методconstructor
const Guest = function (name, room) {
this.name = name;
this.room = room;
};
console.log(Guest.prototype); // {constructor: ƒ}
При создании объекта через new
в его поле __proto__
записывается ссылка на
объект, хранящийся в свойстве prototype
функции-конструктора.
const Guest = function (name, room) {
this.name = name;
this.room = room;
};
const mango = new Guest('Mango', 28);
console.log(mango);
Эту особенность мы можем использовать для того, чтобы добавлять в объект
prototype
методы, которые будут доступны по ссылке абсолютно всем объектам,
созданным через new Guest(...)
.
Причем если мы создадим миллион экземпляров гостя, набор методов будет не у
каждого свой, а всего один, общий, хранящийся в свойстве Guest.prototype
и
доступный всем потомкам по ссылке, которая записывается в поле __proto__
объекта при создании из-за прототипного наследования и цепочки прототипов.
const Guest = function (name, room) {
this.name = name;
this.room = room;
};
Guest.prototype.showGuestInfo = function () {
console.log(`name: ${this.name}, room: ${this.room}`);
};
const mango = new Guest('Mango', 28);
const poly = new Guest('Poly', 36);
mango.showGuestInfo(); // name: Mango, room: 28
poly.showGuestInfo(); // name: Poly, room: 36
Так как в свойстве prototype
лежит объект, то при прототипном наследование
происходит присвоение по ссылке, поэтому если мы изменим значение у свойства
prototype
, то это новое значение получат и все свойства, имеющие ссылку на
объект prototype
.
2.1. Свойство constructor
Было упоминание того, что по умолчанию, объект в свойстве prototype
уже
содержит поле constructor
. Запишем это поле явно:
const Guest = function (name, room) {
this.name = name;
this.room = room;
};
Guest.prototype = {
constructor: Guest,
};
В коде выше мы создали свойство Guest.prototype
вручную, но абсолютно такой же
объект генерируется автоматически. А свойство constructor
содержит ссылку на
саму функцию-конструктор.
Свойство [[Prototype]]
объекта называют скрытым свойством, потому что прямой
доступ к нему ограничен средствами самого языка. Метод, который делает запись
ссылки в объект в момент создания имеет такой доступ. Находится этот метод в
объекте prototype
функции и называется constructor
🙂.
Understanding Prototypes in JavaScript
3. Наследование и конструкторы
Конструктор - это функция для создания объектов по шаблону. Оператор new
создает объект и вызывает функцию-конструктор в контексте этого объекта. На
выходе получаем объект с полями указанными в функции-конструкторе через this
и
полем [[Prototype]]
, которое содержит ссылку на поле prototype
функции-конструктора.
Иногда случаются задачи, когда объекты, созданные функцией-конструктором, должны также иметь доступ к полям и методам прототипа объявленным в другой функции-конструкторе.
Например мы пишем игру в стиле RPG, и нам необходимо подготовить логику для
классовой системы персонажей где есть общий конструктор Hero
с дефолтными
полями общими для всех классов, вроде имени, здоровья, количества опыта и т. п.
После чего нам необходимо сделать конструкторы для Warrior
и Wizard
,
экземпляры которых также должны иметь доступ к полям Hero
, но в тоже время
иметь свои собственные.
Давайте реализуем это используя прототипное наследование.
const Hero = function (name, xp) {
this.name = name;
this.xp = xp;
};
/*
* Теперь у нас есть конструктор базового класса героя,
* добавим в prototype какой-то метод.
*/
Hero.prototype.gainXp = function (amount) {
console.log(`${this.name} gained ${amount} experience points`);
this.xp += amount;
};
const mango = new Hero('Mango', 1000);
console.log(mango); // Hero { name: 'Mango', xp: 1000 }
// Так как mango это экземпляр Hero, то ему доступны методы из Hero.prototype
mango.gainXp(500); // Mango gained 500 experience points
console.log(mango); // Hero { name: 'Mango', xp: 1500 }
Далее необходимо создать класс Warrior
, так как нет смысла добавлять в Hero
абсолютно все поля всех классов. Поэтому нам необходимо создать еще
функцию-конструктор, но при этом она должна быть как-то связана с Hero
.
Для решения этой задачи мы можем использовать метод call()
, вызвав
функцию-конструктор Hero
и передав ей объект, создающийся в Warrior
как
контекст.
const Warrior = function (name, xp, weapon) {
/*
* Во время выполнения функции Warrior вызываем функцию Hero
* в контексте объекта создающегося в Warrior, а так же передаем
* аргументы для инициализации полей this.name и this.xp
*
* this это будущий экземпляр Warrior
*/
Hero.call(this, name, xp);
// Тут добавляем новое свойство - оружие
this.weapon = weapon;
};
// Сразу добавим метод для атаки в prototype воина
Warrior.prototype.attack = function () {
console.log(`${this.name} attacks with ${this.weapon}`);
};
const poly = new Warrior('Poly', 200, 'sword');
console.log(poly); // Warrior {name: "Poly", xp: 200, weapon: "sword"}
poly.attack(); // Poly attacks with sword
Вроде все хорошо, но что произойдет если мы попробуем вызвать у poly
метод
gainXp()
, который объявлен на Hero.prototype
? — будет ошибка
poly.gainXp(); // Uncaught TypeError: poly.gainXp is not a function
Дело в том, что поля из Hero.prototype
не добавляются в цепочку прототипов по
умолчанию. Необходимо явно указать связь поля Warrior.prototype
и
Hero.prototype
. Сделать это очень легко, но важно понимать как и почему это
работает.
/*
* Используем Object.create() для того чтобы изначально записать
* в Warrior.prototype пустой объект, в __proto__ которого будет
* ссылка на Hero.prototype. Это необходимо сделать до того
* как добавлять методы
*/
Warrior.prototype = Object.create(Hero.prototype);
// Не забываем добавить в Warrior.prototype свойство constructor
Warrior.prototype.constructor = Warrior;
// Добавим метод для атаки
Warrior.prototype.attack = function () {
console.log(`${this.name} attacks with ${this.weapon}`);
};
// Попробуем теперь
poly.gainXp(300); // Poly gained 300 experience points
В результате мы получили цепочку прототипов. При вызове poly.gainXp()
, идет
поиск поля gainXp
в самом объекте poly
, если такового нет, тогда идет поиск
в том объекте, который указан в поле poly.__proto__
- это ссылка на
Warrior.prototype
.
Если же его нету и там, то поиск идет в поле __proto__
того объекта, что
указан в poly.__proto__
, то есть в poly.__proto__.__proto__
, а это ссылка на
Hero.prototype
, где есть метод gainXp
.
Полный код примера.
const Hero = function (name, xp) {
this.name = name;
this.xp = xp;
};
Hero.prototype.gainXp = function (amount) {
console.log(`${this.name} gained ${amount} experience points`);
this.xp += amount;
};
const Warrior = function (name, xp, weapon) {
Hero.call(this, name, xp);
this.weapon = weapon;
};
Warrior.prototype = Object.create(Hero.prototype);
Warrior.prototype.constructor = Warrior;
Warrior.prototype.attack = function () {
console.log(`${this.name} attacks with ${this.weapon}`);
};
const poly = new Warrior('Poly', 200, 'sword');