Prototype в JavaScript
При создании объектов, каждый из них будет содержать специальное внутреннее свойство Prototype
, указывающее на его прототип. В JavaScript прототипы используются для организации наследования и расширения.
Установка прототипа методу
Допустим у нас имеется конструктор Box
:
function Box(width, height) {
this.width = width;
this.height = height;
}
При объявлении конструктор или класса, у него автоматически появится свойство prototype
. Оно содержит прототип, прототип – это объект. В данном случае им будет являться Box.prototype
. Это очень важный момент.
Прототип будет автоматически назначаться всем объектам, которые будут создаваться с помощью этого конструктора:
// создание объекта с помощью конструктора Box
const box1 = new Box(25, 30);
При создании объекта, в данном случае box1
, он автоматически будет иметь ссылку на прототип, то есть на свойство Box.prototype
.
Это очень легко проверить, получить доступ к прототипу можно двумя способами:
- С помощью статического метода
Object.setPrototypeOf()
, правильный вариант - С помощью свойства
__proto__
, лучше не использовать
Проверяем:
// true
Object.getPrototypeOf(box1) === Box.prototype
// true
box1.__proto__ === Box.prototype
Свойство prototype
имеется у каждой функции за исключением стрелочных. Это свойство как мы уже отмечали выше в качестве значения имеет объект. По умолчанию в нём находится только одно свойство constructor
, которое содержит ссылку на саму эту функцию:
// true
Box.prototype.constructor == Box
Если создать ещё один объект класса Box
, то он тоже будет иметь точно такой же прототип. Как мы уже отмечали выше прототипы в JavaScript используются для организации наследования. То есть, если мы сейчас в Box.prototype
добавим какой-нибудь метод, он будет доступен для всех экземпляров класса Box
:
// создадим конструктор Box
function Box(width, height) {
this.width = width;
this.height = height;
}
// добавим метод print в прототип Box
Box.prototype.print = function () {
return `Box Size: ${this.width} x ${this.height}`
}
// создадим объекты
const box1 = new Box(25, 30);
const box2 = new Box(50, 70);
// выведем размеры ящиков в консоль
// Box Size: 25 x 30
console.log(box1.print());
// Box Size: 50 x 70
console.log(box2.print());
Обратите внимание, что метода print
нет у объектов box1
и box2
. Но если раскрыть значение свойства [[Prototype]]
в консоли веб-браузере, то увидите его. То есть этот метод находится на уровне класса Box
и наследуется всеми его экземплярами.
Соответственно получается, что мы можем вызвать print
как метод объектов box1
и box2
. Таким образом нам доступны не только собственные свойства и методы, но также наследуемые. А наследование, как вы уже понимаете, осуществляется в JavaScript на основе прототипов.
Так что же такое прототип? Прототип в JavaScript – это просто ссылка на объект, который используется для наследования.
Наследование
Если после переменной, содержащей некоторый объект поставить точку, то вы увидите все доступные для него свойства и методы:
Здесь width
и height
– это его собственные свойства. Далее на уровне родительского класса находятся методы constructor
и print
. Т.е. вы можете вызвать метод print
, потому что он наследуется всеми экземплярами класса Box
. Кроме этого, здесь имеются методы класса Object
, такие как hasOwnProperty
, isPrototypeOf
, toString
и так далее. Эти методы тоже доступны, потому что Box.prototype
наследует все свойства и методы Object.prototype
.
Таким образом, кроме собственных свойств и методов объекту также доступны свойства и методы из прототипов. Такие свойства и методы называются наследованными.
В этом примере, объект box1
имеет свои собственные свойства width
и height
, а также наследует все свойства и методы Box.prototype
и Object.prototype
.
Цепочка прототипов
В JavaScript наследование осуществляется только на уровне объектов через прототипы. То есть один объект имеет ссылку на другой через специальное внутреннее свойство [[Prototype]]
. Тот в свою очередь тоже имеет ссылку и т.д. В результате получается цепочка прототипов.
Таким образом наследование, которые мы рассмотрели выше на примере объекта box1
происходит благодаря существованию следующей цепочки прототипов:
box1 -> Box.prototype -> Object.prototype
Заканчивается цепочка на прототипе глобального класса Object
, потому что он не имеет прототипа, то есть его значение __proto__
равно null
.
При этом, когда мы пытаемся получить доступ к некоторому свойству или методу этого объекта, поиск всегда начинается с самого объекта. Если данного свойства или метода у него нет, то поиск перемещается в прототип, потом в прототип прототипа и так далее.
Если указанное свойство или метод не найден, то возвращается undefined
.
Например, если метод print
мы добавим в сам объект box1
, то будет использоваться уже он, а не тот, который находится в прототипе Box.prototype
:
box1.print = function() {
return `Размеры коробки: ${this.width} x ${this.height}`;
}
Поиск сразу прекращается, как только указанный метод будет найден. А в данном случае он будет найден сразу в объекте, поэтому переход в прототип не осуществится.
Значение this внутри методов
Значение this
внутри методов определяется только тем для какого объекта мы его вызываем.
Рассмотрим следующий пример:
function Counter() {
this.value = 0;
}
Counter.prototype.up = function() {
this.value++;
return this.value;
}
const counter1 = new Counter();
const counter2 = new Counter();
// 1
console.log(counter1.up());
// 2
console.log(counter1.up());
// 1
console.log(counter2.up());
Здесь мы вызываем up
как метод объектов counter1
и counter2
. Данный метод не является собственным для этих объектов, он наследуется и находится на уровне класса Counter
. Но на самом деле это не имеет значения. Единственное, что важно для this
– это только то, для какого объекта мы вызываем этот метод, то есть что стоит перед точкой. Это и будет this
.
При вызове counter1.up()
, this
внутри этого метода будет указывать на counter1
:
counter1.up();
На строчке перед точкой стоит counter2
, значит this
внутри up
будет указывать на него
:
counter2.up();
Таким образом, не важно, где находится метод, this
внутри него всегда будет указывать на объект, для которого он вызывается, и, следовательно, с помощью this
мы всегда сначала будем получать доступ к собственным свойствам и методам этого объекта.
Установка прототипа объекту
Установить прототип объекту можно двумя способами:
- С помощью статического метода
Object.setPrototypeOf()
, правильный вариант - С помощью свойства
__proto__
, лучше не использовать
Object.setPrototypeOf()
Чтобы было более понятно как работает метод Object.setPrototypeOf
, рассмотрим его синтаксис:
Object.setPrototypeOf(obj, prototype)
obj
– объект, для которого необходимо установить прототипprototype
– объект, который будет использоваться в качестве прототипа дляobj
, илиnull
, если уobj
не должно быть прототипа
Пример установки прототипа с помощью Object.setPrototypeOf()
:
const message = {
text: 'Сообщение 1...',
color: 'black',
getText() {
return `${this.text}`;
}
}
const errorMessage = {
text: 'Сообщение 2...',
color: 'red'
}
Object.setPrototypeOf(errorMessage, message);
В этом примере мы в качестве прототипа для errorMessage
установили message
.
Очень важный момент заключается в том, что мы не можем указать в качестве прототипа объект, который уже имеется в цепочке, то есть замкнуть её.
Следовательно, мы получим ошибку, если попытаемся для message
установить в качестве прототипа errorMessage
:
Object.setPrototypeOf(message, errorMessage);
Кроме этого, в JavaScript нет множественного наследования, то есть нельзя одному объекту назначить несколько прототипов.
__proto__
Например, создадим два объекта и установим для второго объекта в качестве прототипа первый объект с помощью __proto__
:
const person1 = {
name: 'Tom',
printName() {
return `Name: ${this.name}`
}
}
const person2 = {
name: 'Bob',
__proto__: person1
}
Проверить что прототипом для person2
является person1
очень просто:
// true
person2.__proto__ === person1
// true
Object.getPrototypeOf(person2) === person1
При этом метод printName
становится наследуемым, то есть доступным для объекта person2
:
// "Name: Bob"
person2.printName();
Наследование
Допустим у нас имеется конструктор Person
:
function Person(name, age) {
this.name = name;
this.age = age;
}
person.prototype.getName = function() {
return this.name;
}
Создадим конструктор Student
, который будет расширять класс Person
:
function Student(name, age, schoolName) {
// вызываем функцию, передавая ей в качестве this текущий объект
Person.call(this, name, age);
this.schoolName = schoolName;
}
Student.prototype.getSchoolName = function() {
return this.schoolName;
}
Но на данном этапе, они сейчас полностью независимы. Но для того чтобы класс Student
расширял Person
нужно указать, что прототипом для Student.prototype
является Person.prototype
. Например, выполним это с помощью свойства __proto__
:
Student.prototype.__proto__ = Person.prototype;
Создадим новый экземпляр класса Student
:
const student = new Student('Bob', 15, 'ABC School');
Для этого объекта будет доступен как метод getSchoolName
, так и getName
.
Получение данных из prototype
Прототип объекта хранится в свойстве __proto__
, которое реализованно как псевдоним внутреннего свойства [[Prototype]]
. Кроме того, получить прототип объекта можно с помощью метода getPrototypeOf()
:
const message = {
text: 'Сообщение 1...',
color: 'black',
getText() {
return `${this.text}`;
}
}
const errorMessage = {
text: 'Сообщение 2...',
color: 'red'
}
Object.setPrototypeOf(errorMessage, message);
// black
console.log(errorMessage.__proto__.color);
// black
console.log(Object.getPrototypeOf(errorMessage).color);
Свойство constructor
У каждой функции, кроме стрелочных, по умолчанию имеется свойство prototype
. Этот прототип автоматически будут иметь все объекты, если мы эту функцию будем использовать как конструктор, то есть для создания объектов.
По умолчанию свойство prototype
функции содержит следующий объект:
function Article(title) {
this.title = title;
}
Article.prototype = {
constructor: Article
}
Здесь мы свойству prototype
присвоили объект вручную, но точно такой же генерируется автоматически. Этот объект изначально содержит только свойство constructor
, которое указывает на сам конструктор, то есть на функцию.
Свойство constructor
можно использовать для создания объектов:
const article1 = new Article('Про витамины');
const article2 = new article1.constructor('Про мёд');
Это может пригодиться, когда, например, объект был сделан вне вашего кода и вам необходимо создать новый подобный ему, то есть с использованием этого же конструктора.
Не рекомендуется полностью перезаписывать значение свойства prototype
, потому что в этом случае вы потеряете constructor
и его придётся добавлять вручную:
// так делать не нужно
Article.prototype = {
getTitle() {
return this.title;
}
}
Если нужно что-то добавить в prototype
, то делайте это как в примерах выше, то есть посредством простого добавления ему нужных свойств и методов:
Article.prototype.getTitle = function() {
return this.title;
}
Встроенные прототипы
В JavaScript практически всё является объектами. То есть функции, массивы, даты и так далее. Исключением являются только примитивные типы данных: строка, число и так далее.
Например, при создании объекта { name: 'Tom' }
внутренне используется конструктор Object
:
const person = { name: 'Tom' }
Прототипом такого объекта соответственно становится Object.prototype
и в этом легко убедиться:
// true
person.__proto__ === Object.prototype
Поэтому нам на уровне этого объекта доступны различные методы, они берутся из Object.prototype
. Например, метод hasOwnProperty
:
// true
person.hasOwnProperty('name');
Этот метод возвращает true
, когда указанное свойство является для этого объекта родным, в противном случае false
.
При этом Object.prototype
является корнем иерархии других встроенных прототипов. Но при этом он сам не имеет прототипа.
На рисунке видно, что конструктор Object
имеет по умолчанию свойство prototype
. Это значение будет автоматически записываться в свойство [[Prototype]]
объектов, которые будет создаваться с помощью этого конструктора. В Object.prototype
имеется свойство constructor
, которые указывает на сам конструктор. Эти связи между Object
и Object.prototype
показаны на схеме. Кроме этого Object.prototype
не имеет прототипа. То есть его значение [[Prototype]]
содержит null
.
Теперь давайте рассмотрим, как выполняется создание даты в JavaScript. Осуществляется это очень просто посредством конструктора Date
:
const now = new Date();
Следовательно, прототипом даты является Date.prototype
:
// true
now.__proto__ === Date.prototype
Этот прототип содержит большое количество методов для работы с датой, например, такие как getDate
, getHours
и так далее. Их нет в now
, но они доступны нам посредством наследования.
Объект Date.prototype
имеет в качестве прототипа Object.prototype
:
// true
Date.prototype.__proto__ === Object.prototype
// true
now.__proto__.__proto__ === Object.prototype
Следовательно, методы Object.prototype
, которых нет в Date.prototype
также доступны для now
. Например, hasOwnProperty
:
// false
now.hasOwnProperty('getYear')
Таким образом можно нарисовать следующую схему:
Другие встроенные объекты устроены подобным образом.
Метод Object.create
Object.create
предназначен для создания нового объекта, который будет иметь в качестве прототипа объект, переданный в этот метод в качестве аргумента:
const rect1 = {
a: 8,
b: 5,
calcArea() {
return this.a * this.b
}
}
// создали новый объект, который будет иметь в качестве прототипа rect1
const rect2 = Object.create(rect1);
rect2.a = 10;
rect2.b = 5;
// 50
rect2.calcArea();
Создание объекта с прототипом Object.prototype
:
const obj = Object.create(Object.prototype);
Данный пример аналогичен этому:
const obj = {};
Создание объекта без прототипа:
const obj = Object.create(null);
Во 2 аргументе мы можем объекту сразу передать необходимые свойства. Описываются они в полном формате с использованием специальных атрибутов как в Object.defineProperties
:
const person = Object.create(null, {
name: {
value: 'John'
},
age: {
value: '18',
writable: true
}
});
Здесь мы описали два свойства: name
и age
. С помощью value
мы устанавливаем значение свойству, а посредством аргумента writable
задаем доступно ли свойство для изменения.
Пример, в котором мы с помощью Object.create
установим для Book.prototype
в качестве прототипа объект Product.prototype
:
// конструктор Product
function Product(params) {
this.name = params.name;
this.price = params.price;
this.discount = params.discount;
}
// конструктор Book
function Book(params) {
Product.call(this, params);
this.isbn = params.isbn;
this.author = params.author;
this.totalPages = params.totalPages;
}
// устанавливаем для Book.prototype в качестве прототипа объект Product.prototype
Book.prototype = Object.create(Product.prototype, {
constructor: {
value: Book
},
// геттер
fullName: {
get() {
return `${this.author}: ${this.name}`;
}
}
});
Product.prototype.calcDiscountedPrice = function() {
return (this.price * (1 - this.discount / 100)).toFixed(2);
}
// создадим новый объект
const book = new Book({
name: 'Краткая история времени',
author: 'Стивен Хокинг',
isbn: '978-5-17102-284-6',
totalPages: 232,
price: 611.00,
discount: 5
});
// цена книги со скидкой, 580.45
console.log(book.calcDiscountedPrice());
Здесь мы Object.create
используем для создания нового объекта, который будет использоваться в качестве прототипа для Book.prototype
. При этом свою очередь этот новый объект будет иметь в качестве прототипа Product.prototype
. Для этого мы передаем его в качестве аргумента методу Object.create
. Кроме этого к этому объекту мы сразу же добавили два свойства: constructor
и fullName
. С помощью constructor
мы восстановили конструктор, который был в прототипе пока мы ему не присвоили новое значение. А динамическое свойство fullName
, представляющее собой геттер, будем использовать для получения имени книги с автором.