Прототипы и наследование в JavaScript

JavaScript – это язык, основанный на прототипах. Это значит, что свойства и методы объектов можно повторно использовать посредством общих объектов, которые можно клонировать и расширять. Это называется наследованием прототипов и отличается от наследования классов. Среди популярных объектно-ориентированных языков программирования JavaScript относительно уникален, поскольку другие известные языки (PHP, Python и Java) являются языками на основе классов, которые в качестве макетов для объектов используют классы вместо прототипов.

В этом мануале вы узнаете, что такое прототипы объектов, наследование и цепочки прототипов и как использовать функцию-конструктор для расширения прототипов в новых объектах.

Прототипы в JavaScript

В мануале Объекты в JavaScript вы уже ознакомились с объектами и научились создавать их, изменять и извлекать их свойства. Теперь вы научитесь использовать прототипы для расширения объектов.

Каждый объект в JavaScript имеет внутреннее свойство, называемое [[Prototype]]. Для примера попробуйте создать новый пустой объект.

let x = {};

Так создается объект обычно, но есть и другой способ сделать это – с помощью конструктора объекта: let x = new Object().

Примечание: Двойные квадратные скобки в [[Prototype]] означают, что свойство является внутренним и не может быть доступно непосредственно в коде.

Чтобы найти свойство [[Prototype]] этого нового объекта, нужно использовать метод getPrototypeOf ().

Object.getPrototypeOf(x);

Вывод будет состоять из нескольких встроенных свойств и методов.

{constructor: ƒ, __defineGetter__: ƒ, __defineSetter__: ƒ, …}

Еще один способ найти [[Prototype]] – это свойство __proto__, которое предоставляет внутренний [[Prototype]] объекта.

Примечание: Важно отметить, что .__proto__ является устаревшей функцией, которую не следует использоваться в производственном коде, и ее нет в современных браузерах. Однако в мануале она используется для демонстрации.

x.__proto__;

Это вернет такой же результат, что и getPrototypeOf().

{constructor: ƒ, __defineGetter__: ƒ, __defineSetter__: ƒ, …}

Важно, чтобы каждый объект JavaScript имел [[Prototype]], поскольку он позволяет связать два и более объекта.

Созданные вами объекты имеют [[Prototype]] так же, как и встроенные объекты, такие как Date и Array. Сослаться на это внутреннее свойство можно с помощью свойства prototype.

Наследование прототипов

Когда вы пытаетесь получить доступ к свойству или методу объекта, JavaScript сначала выполняет поиск по самому объекту, и если искомое не найдено, он будет искать объект [[Prototype]]. Если после поиска по объекту и его [[Prototype]] совпадения не найдено, JavaScript проверит прототип связанного объекта и продолжит поиск до тех пор, пока не достигнет конца цепочки прототипов.

В конце цепочки прототипов находится Object.prototype. Все объекты наследуют свойства и методы Object. Любая попытка поиска за пределами цепочки приводит к null.

В нашем примере x – пустой объект, который наследуется от Object. x может использовать любое свойство или метод, которые имеет Object, например toString().

x.toString();
[object Object]

Эта цепочка прототипов состоит из всего одной ссылки (x -> Object). Это понятно потому, что если вы попытаетесь связать два свойства [[Prototype]], получится null.

x.__proto__.__proto__;
null

Давайте рассмотрим другой тип объекта. Если у вас есть опыт работы с массивами JavaScript, вы знаете, что у них много встроенных методов (таких как pop() и push()). У вас есть доступ к этим методам при создании нового массива потому, что любой массив, который вы создаете, имеет доступ к свойствам и методам Array.prototype.

Читайте также: Работа с массивами в JavaScript

Создайте новый массив:

let y = [];

Помните, что создать его можно также с помощью конструктора массива: let y = new Array().

Если посмотреть на [[Prototype]] нового массива y, вы увидите, что он имеет больше свойств и методов, чем объект x. Он унаследовал все это от Array.prototype.

y.__proto__;
[constructor: ƒ, concat: ƒ, pop: ƒ, push: ƒ, …]

Вы увидите свойство constructor в прототипе, для которого задано значение Array(). Свойство constructor возвращает функцию-конструктор объекта, которая является механизмом для построения объектов из функций.

Теперь можно объединить два прототипа, так как в этом случае цепочка прототипов будет длиннее. Он выглядит так: y-> Array -> Object.

y.__proto__.__proto__;
{constructor: ƒ, __defineGetter__: ƒ, __defineSetter__: ƒ, …}

Эта цепочка теперь относится к Object.prototype. Можно проверить внутренний [[Prototype]] на свойство prototype функции конструктора, чтобы увидеть, что они ссылаются на одно и то же.

y.__proto__ === Array.prototype;            // true
y.__proto__.__proto__ === Object.prototype; // true

Также для этого можно использовать свойство isPrototypeOf():

Array.prototype.isPrototypeOf(y);      // true
Object.prototype.isPrototypeOf(Array); // true

Можно использовать оператор instanceof, чтобы проверить, появляется ли свойство prototype конструктора в пределах цепочки прототипов объекта.

y instanceof Array; // true

Итак, все объекты JavaScript имеют скрытое внутреннее свойство [[Prototype]] (которое можно определить с помощью __proto__ в некоторых браузерах). Объекты могут быть расширены и наследуют свойства и методы от [[Prototype]] их конструктора.

Прототипы складываются в цепочки, и каждый дополнительный объект наследует все по этой цепочке. Цепочка заканчивается на Object.prototype.

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

Функции-конструкторы – это функции, которые используются для построения новых объектов. Оператор new используется для создания новых экземпляров на основе функции конструктора. Вы уже знаете некоторые встроенные конструкторы JavaScript (new Array() и new Date(), например); вы также можете создавать собственные пользовательские шаблоны для построения объектов.

Предположим, что вы создаете очень простую текстовую ролевую игру. Пользователь может выбрать персонажа, а затем класс персонажа (например, воин, целитель, вор и т. д.).

Поскольку каждый персонаж будет иметь множество характеристик – имя, уровень, количество набранных баллов —  имеет смысл создать конструктор. Однако, поскольку каждый класс персонажа может иметь совершенно разные способности, нужно, чтобы каждый персонаж имел доступ только к своим способностям. Давайте попробуем добиться этого с помощью наследования прототипов и конструкторов.

Функция-конструктор изначально является обычной функцией. Она становится конструктором, когда экземпляр вызывает ее с ключевым словом new. По соглашению JavaScript функция-конструктор записывается с большой буквы.

// Initialize a constructor function for a new Hero
function Hero(name, level) {
this.name = name;
this.level = level;
}

Теперь у вас есть функция-конструктор Hero с двумя параметрами: name и level. Поскольку у каждого персонажа будет имя и уровень, для них имеет смысл наследовать эти свойства. Ключевое слово this будет ссылаться на новый созданный экземпляр; this.name в параметре name гарантирует, что новый объект будет иметь свойство name.

Создайте новый экземпляр с помощью new.

let hero1 = new Hero('Bjorn', 1);

Если запросить в консоли hero1, вы увидите новый объект с правильно установленными свойствами:

Hero {name: "Bjorn", level: 1}

Теперь, если запросить [[Prototype]] объекта hero1, вы увидите constructor Hero().

Object.getPrototypeOf(hero1);
constructor: ƒ Hero(name, level)

Как видите, пока что в конструкторе определены только свойства, а не методы. В JavaScript методы прототипов обычно определяются для повышения эффективности и удобочитаемости кода.

Мы можем добавить помощью prototype. Создайте метод greet().

// Add greet method to the Hero prototype
Hero.prototype.greet = function () {
return `${this.name} says hello.`;
}

Поскольку greet() – это prototype в Hero, а hero1 является экземпляром Hero, метод будет доступен и для hero1:

hero1.greet();
"Bjorn says hello."

Если вы проверите [[Prototype]] в Hero, вы увидите доступную опцию greet().

Теперь нужно создать классы персонажей. Вкладывать все способности для каждого класса в конструктор Hero не имеет смысла, потому что разные классы будут иметь разные способности. Нужно создать новые функции-конструкторы и связать их с оригинальным Hero.

С помощью метода call() скопируйте свойства одного конструктора в другой. Создайте конструкторы Warrior и Healer.

...
// Initialize Warrior constructor
function Warrior(name, level, weapon) {
// Chain constructor with call
Hero.call(this, name, level);
// Add a new property
this.weapon = weapon;
}
// Initialize Healer constructor
function Healer(name, level, spell) {
Hero.call(this, name, level);
this.spell = spell;
}

Оба новых конструктора теперь обладают свойствами Hero и несколькими уникальными свойствами. Добавьте метод attack() в Warrior и метод heal() в Healer.

[label characterSelect.js
...
Warrior.prototype.attack = function () {
return `${this.name} attacks with the ${this.weapon}.`;
}
Healer.prototype.heal = function () {
return `${this.name} casts ${this.spell}.`;
}

Теперь можно создать персонажей с двумя новыми доступными классами:

const hero1 = new Warrior('Bjorn', 1, 'axe');
const hero2 = new Healer('Kanin', 1, 'cure');

Теперь hero1 распознается как Warrior с новыми свойствами.

Warrior {name: "Bjorn", level: 1, weapon: "axe"}

Можно использовать новые методы, установленные в прототипе Warrior.

hero1.attack();
"Bjorn attacks with the axe."

Но что произойдет, если попробовать использовать следующие методы в цепочке прототипов?

hero1.greet();
Uncaught TypeError: hero1.greet is not a function

Свойства и методы прототипа не связываются автоматически, когда вы используете call() для создания цепочек. Используйте Object.create(), чтобы связать прототипы, прежде чем создавать и добавлять какие-либо дополнительные методы к прототипу.

...
Warrior.prototype = Object.create(Hero.prototype);
Healer.prototype = Object.create(Hero.prototype);
// All other prototype methods added below
...

Теперь можно использовать методы прототипа из Hero в экземплярах Warrior или Healer.

Вот полный код страницы создания персонажа.

// Initialize constructor functions
function Hero(name, level) {
this.name = name;
this.level = level;
}
function Warrior(name, level, weapon) {
Hero.call(this, name, level);
this.weapon = weapon;
}
function Healer(name, level, spell) {
Hero.call(this, name, level);
this.spell = spell;
}
// Link prototypes and add prototype methods
Warrior.prototype = Object.create(Hero.prototype);
Healer.prototype = Object.create(Hero.prototype);
Hero.prototype.greet = function () {
return `${this.name} says hello.`;
}
Warrior.prototype.attack = function () {
return `${this.name} attacks with the ${this.weapon}.`;
}
Healer.prototype.heal = function () {
return `${this.name} casts ${this.spell}.`;
}
// Initialize individual character instances
const hero1 = new Warrior('Bjorn', 1, 'axe');
const hero2 = new Healer('Kanin', 1, 'cure');

В этом файле вы создали класс Hero с базовыми свойствами, два класса персонажей – Warrior и Healer – из исходного конструктора, добавили методы в прототипы и создали отдельные экземпляры персонажей.

Заключение

JavaScript — это язык, основанный на прототипах, и он функционирует иначе, чем традиционная парадигма на основе классов, используемая многими другими объектно-ориентированными языками.

В этом мануале вы узнали, как работают прототипы JavaScript и как связать свойства и методы объекта с помощью скрытого свойства [[Prototype]], которым обладают все объекты. Также вы теперь умеете создавать пользовательские функции-конструкторы и использовать наследование прототипов для передачи значений свойств и методов.

Tags: