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

Прототипы и наследование в JavaScript
Содержание:
  1. Что такое прототипы?
  2. Наследование
  3. Цепочка прототипов
  4. Значение this внутри методов
  5. Установка прототипа объекту
  6. Наследование классов
  7. Свойство constructor
  8. Встроенные прототипы
  9. Метод Object.create
  10. Комментарии

В этой статье мы изучим всё что касается прототипов в JavaScript. Разберём зачем они нужны, что такое наследование и цепочка прототипов, как работает this внутри методов, рассмотрим пример расширения классов и многое другое.

Что такое прототипы?

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

Допустим у нас имеется конструктор Box:

JavaScript
// конструктор Box
function Box(width, height) {
  this.width = width;
  this.height = height;
}

При объявлении конструктора или класса у него автоматически появится свойство prototype. Оно содержит прототип. Прототип – это объект. В данном случае им будет являться Box.prototype. Это очень важный момент.

Этот прототип будет автоматически назначаться всем объектам, которые будут создаваться с помощью этого конструктора:

JavaScript
// создание объекта с помощью конструктора Box
const box1 = new Box(25, 30);

Таким образом при создании объекта, в данном случае, box1, он автоматически будет иметь ссылку на прототип, то есть на свойство Box.prototype.

Это очень легко проверить:

JavaScript
Object.getPrototypeOf(box1) === Box.prototype // true
box1.__proto__ === Box.prototype // true
Прототип объекта в JavaScript

Получить прототип объекта в JavaScript можно с помощью статического метода Object.getPrototypeOf или специального свойства __proto__. В этом примере показаны два этих способа. Кстати, свойство __proto__ не является стандартным, но оно поддерживается всеми браузерами.

Свойство prototype имеется у каждой функции за исключением стрелочных. Это свойство как мы уже отмечали выше в качестве значения имеет объект. По умолчанию в нём находится только одно свойство constructor, которое содержит ссылку на саму эту функцию:

JavaScript
Box.prototype.constructor = Box // true

То есть Box.prototype.constructor – это сам конструктор Box.

Если создать ещё один объект класса Box, то он тоже будет иметь точно такой же прототип. Как мы уже отмечали выше прототипы в JavaScript используются для организации наследования. То есть, если мы сейчас в Box.prototype добавим какой-нибудь, например, метод, то он будет доступен для всех экземпляров класса Box:

JavaScript
// создадим конструктор 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);
// выведем размеры ящиков в консоль
console.log(box1.print()); // Box Size: 25 x 30
console.log(box2.print()); // Box Size: 50 x 70
Прототип объекта на уровне родительского класса в JavaScript

Обратите внимание, что метода print нет у объектов box1 и box2. Но если раскрыть значение свойства [[Prototype]] в консоли в веб-браузере, то вы увидите его. То есть этот метод находится на уровне класса Box и наследуется всеми его экземплярами.

Соответственно получается, что мы можем вызвать print как метод объектов box1 и box2. Таким образом нам доступны не только собственные свойства и методы, но также наследуемые. А наследование, как вы уже понимаете, осуществляется в JavaScript на основе прототипов.

Так что же такое прототип? Прототип в JavaScript – это просто ссылка на объект, который используется для наследования.

Наследование

Что такое наследование? Если после переменной, содержащей некоторый объект поставить точку, то вы увидите все доступные для него свойства и методы:

Список доступных свойств и методов на уровне объекта box1

Здесь 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:

JavaScript
box1.print = function() {
  return `Размеры коробки: ${this.width} x ${this height}`;
}

Почему? Потому что поиск сразу прекращается, как только указанный метод будет найден. А в данном случае он будет найден сразу в объекте, поэтому переход в прототип не осуществится.

Значение this внутри методов

Значение this внутри методов определяется только тем для какого объекта мы его вызываем.

Рассмотрим следующий пример:

JavaScript
function Counter() {
  this.value = 0;
}
Counter.prototype.up = function() {
  this.value++;
  return this.value;
}
const counter1 = new Counter();
const counter2 = new Counter();
counter1.up(); // 1
counter1.up(); // 2
counter2.up(); // 1

Здесь мы вызываем up как метод объектов counter1 и counter2. Данный метод не является собственным для этих объектов, он наследуется и находится на уровне класса Counter. Но на самом деле это не имеет значения. Единственное, что важно для this – это только то, для какого объекта мы вызываем этот метод, то есть что стоит перед точкой. Это и будет this.

При вызове counter1.up(), this внутри этого метода будет указывать на counter1:

JavaScript
counter1.up();

На строчке перед точкой стоит counter2, значит this внутри up будет указывать на него:

JavaScript
counter2.up();

Таким образом, не важно, где находится метод, this внутри него всегда будет указывать на объект, для которого он вызывается, и, следовательно, с помощью this мы всегда сначала будем получать доступ к собственным свойствам и методам этого объекта.

Установка прототипа объекту

Установить прототип объекту можно с помощью статического метода Object.setPrototypeOf() или свойства __proto__.

Например, создадим два объекта и установим для второго объекта в качестве прототипа первый объект с помощью __proto__:

JavaScript
const person1 = {
  name: 'Tom',
  printName() {
    return `Name: ${this.name}`
  }
}
const person2 = {
  name: 'Bob',
  __proto__: person1
}

Проверить что прототипом для person2 является person1 очень просто:

JavaScript
person2.__proto__ === person1 // true
Object.getPrototypeOf(person2) === person1 // true
Установка объекта прототипа с помощью специального свойства __proto__

При этом метод printName становится наследуемым, то есть доступным для объекта person2:

JavaScript
person2.printName(); // "Name: Bob"

Пример установки прототипа с помощью Object.setPrototypeOf():

JavaScript
const message = {
  text: 'Сообщение 1...',
  color: 'black',
  getText() {
    return `<div style="color: ${this.color};">${this.text}</div>`;
  }
}
const errorMessage = {
  text: 'Сообщение 2...',
  color: 'red'
}
Object.setPrototypeOf(errorMessage, message);
Установка объекта прототипа с помощью Object.setPrototypeOf()

В этом примере мы в качестве прототипа для errorMessage установили message.

Чтобы было более понятно как работает метод Object.setPrototypeOf, рассмотрим его синтаксис:

JavaScript
Object.setPrototypeOf(obj, prototype)

Где:

  • obj – объект, для которого необходимо установить прототип;
  • prototype – объект, который будет использоваться в качестве прототипа для obj, или null, если у obj не должно быть прототипа.

Очень важный момент заключается в том, что мы не можем указать в качестве прототипа объект, который уже имеется в цепочке, то есть замкнуть её.

Следовательно, мы получим ошибку, если попытаемся для message установить в качестве прототипа errorMessage:

JavaScript
Object.setPrototypeOf(message, errorMessage);

Кроме этого, в JavaScript нет множественного наследования, то есть нельзя одному объекту назначить несколько прототипов.

Наследование классов

Допустим у нас имеется конструктор Person:

JavaScript
function Person(name, age) {
  this.name = name;
  this.age = age;
}
person.prototype.getName = function() {
  return this.name;
}

Создадим конструктор Student, который будет расширять класс Person:

JavaScript
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__:

JavaScript
Student.prototype.__proto__ = Person.prototype;

Создадим новый экземпляр класса Student:

JavaScript
const student = new Student('Bob', 15, 'ABC School');

Для этого объекта будет доступен как метод getSchoolName, так и getName.

Рассмотрим ещё один очень интересный пример с наследованием классов:

JavaScript
// конструктор для создания объектов класса Rectangle
function Rectangle(x1, y1, x2, y2, bgColor) {
  Rectangle.counter++;
  this.id = `${this.constructor.name.toLowerCase()}-${Rectangle.counter}`;
  [this.left, this.top] = [Math.min(x1, x2), Math.min(y1, y2)];
  [this.width, this.height] = [Math.max(x1, x2) - this.left, Math.max(y1, y2) - this.top];
  this.bgColor = bgColor;
}
// конструктор для создания объектов класса Square
function Square(x, y, side, bgColor) {
  // вызываем конструктор Rectangle в контексте текущего создаваемого объекта
  Rectangle.call(this, x, y, x + side, y + side, bgColor);
}
// конструктор для создания объектов класса Circle
function Circle(x, y, r, bgColor) {
  Square.call(this, x, y, r, bgColor);
  this.radius = r;
}
// счетчик количества созданных объектов
Rectangle.counter = 0;
// установим для Square.prototype прототип Rectangle.prototype
Square.prototype.__proto__ = Rectangle.prototype;
// установим для Circle.prototype прототип Square.prototype
Circle.prototype.__proto__ = Square.prototype;
// объявляем метод draw в прототипе Rectangle
Rectangle.prototype.draw = function() {
  document.body.insertAdjacentHTML('beforeend', `<div id="${this.id}" style="position: absolute; left: ${this.left}px; top: ${this.top}px; width: ${this.width}px; height: ${this.height}px; background-color: ${this.bgColor};"></div>`);
}
// переопределяем метод draw в прототипе Circle
Circle.prototype.draw = function() {
  const el = Rectangle.prototype.draw.call(this);
  document.querySelector(`#${this.id}`).style.borderRadius = `${this.radius}px`;
}
// создадим новые объекты
const rectangle = new Rectangle(70, 80, 200, 300, '#673ab7');
const square = new Square(250, 50, 150, '#ff9800');
const circle = new Circle(450, 140, 90, '#4caf50');
// вызовем для каждого из них метод draw()
rectangle.draw();
square.draw();
circle.draw();

Здесь у нас имеются 3 класса: Rectangle, Square и Circle. Для того чтобы объекты класса Square наследовали свойства и методы Rectangle.prototype мы прописали следующую связь:

JavaScript
Square.prototype.__proto__ = Rectangle.prototype;

Похожим образом мы это сделали также для объектов класса Circle:

JavaScript
Circle.prototype.__proto__ = Square.prototype;

Таким образом, объекты, являющиеся экземплярами класса Circle наследуют свойства и методы Circle.prototype, Square.prototype, Rectangle.prototype и Object.prototype. Это происходит благодаря следующей цепочки прототипов:

circle -> Circle.prototype -> Square.prototype -> Rectangle.prototype -> Object.prototype

Для вызова в Square родительского конструктора мы используем метод call. С помощью call мы задаём контекст, в котором нам нужно вызвать функцию. В данном случае мы вызываем Rectangle в контексте текущего создаваемого объекта.

Свойство constructor

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

По умолчанию свойство prototype функции содержит следующий объект:

JavaScript
function Article(title) {
  this.title = title;
}
Article.prototype = {
  constructor: Article
}

Здесь мы свойству prototype присвоили объект вручную, но точно такой же генерируется автоматически. Этот объект изначально содержит только свойство constructor, которое указывает на сам конструктор, то есть на функцию.

Свойство constructor можно использовать для создания объектов:

JavaScript
const article1 = new Article('Про витамины');
const article2 = new article1.constructor('Про мёд');

Это может пригодиться, когда, например, объект был сделан вне вашего кода и вам необходимо создать новый подобный ему, то есть с использованием этого же конструктора.

Не рекомендуется полностью перезаписывать значение свойства prototype, потому что в этом случае вы потеряете constructor и его придётся добавлять вручную:

JavaScript
// так делать не нужно
Article.prototype = {
  getTitle() {
    return this.title;
  }
}

Если нужно что-то добавить в prototype, то делайте это как в примерах выше, то есть посредством простого добавления ему нужных свойств и методов:

JavaScript
Article.prototype.getTitle = function() {
  return this.title;
}

Встроенные прототипы

В JavaScript практически всё является объектами. То есть функции, массивы, даты и так далее. Исключением являются только примитивные типы данных: строка, число и так далее.

Например, при создании объекта { name: 'Tom' } внутренне используется конструктор Object:

JavaScript
const person = { name: 'Tom' }

Прототипом такого объекта соответственно становится Object.prototype и в этом легко убедиться:

JavaScript
person.__proto__ === Object.prototype // true

Поэтому нам на уровне этого объекта доступны различные методы, они берутся из Object.prototype. Например, метод hasOwnProperty:

JavaScript
person.hasOwnProperty('name'); // true

Этот метод возвращает true, когда указанное свойство является для этого объекта родным. В противном случае – false.

При этом Object.prototype является корнем иерархии других встроенных прототипов. Но при этом он сам не имеет прототипа.

JavaScript - Object.prototype

На рисунке видно, что конструктор Object имеет по умолчанию свойство prototype. Это значение будет автоматически записываться в свойство [[Prototype]] объектов, которые будет создаваться с помощью этого конструктора. В Object.prototype имеется свойство constructor, которые указывает на сам конструктор. Эти связи между Object и Object.prototype показаны на схеме. Кроме этого Object.prototype не имеет прототипа. То есть его значение [[Prototype]] содержит null.

Теперь давайте рассмотрим, как выполняется создание даты в JavaScript. Осуществляется это очень просто посредством конструктора Date:

JavaScript
const now = new Date();
Методы прототипа конструктора Date

Следовательно, прототипом даты является Date.prototype:

JavaScript
now.__proto__ === Date.prototype // true

Этот прототип содержит большое количество методов для работы с датой, например, такие как getDate, getHours и так далее. Их нет в now, но они доступны нам посредством наследования.

Объект Date.prototype имеет в качестве прототипа Object.prototype:

JavaScript
Date.prototype.__proto__ === Object.prototype // true
// или так
now.__proto__.__proto__ === Object.prototype // true

Следовательно, методы Object.prototype, которых нет в Date.prototype также доступны для now. Например, hasOwnProperty:

JavaScript
now.hasOwnProperty('getYear') // false

Таким образом можно нарисовать следующую схему:

JavaScript - Date.prototype

Другие встроенные объекты устроены подобным образом.

Метод Object.create

Object.create предназначен для создания нового объекта, который будет иметь в качестве прототипа объект, переданный в этот метод в качестве аргумента:

JavaScript
const rect1 = {
  a: 8,
  b: 5,
  calcArea() {
    return this.a * this.b
  }
}
// создали новый объект, который будет иметь в качестве прототипа rect1
const rect2 = Object.create(rect1);
rect2.a = 10;
rect2.b = 5;
rect2.calcArea(); // 50

Создание объекта с прототипом Object.prototype:

JavaScript
const obj = Object.create(Object.prototype);

Данный пример аналогичен этому:

JavaScript
const obj = {};

Создание объекта без прототипа:

JavaScript
const obj = Object.create(null);

Во 2 аргументе мы можем объекту сразу передать необходимые свойства. Описываются они в полном формате с использованием специальных атрибутов как в Object.defineProperties:

JavaScript
const person = Object.create(null, {
  name: {
    value: 'John'
  },
  age: {
    value: '18',
    writable: true
  }
});

Здесь мы описали два свойства: name и age. С помощью value мы устанавливаем значение свойству, а посредством аргумента writable задаем доступно ли свойство для изменения.

Пример, в котором мы с помощью Object.create установим для Book.prototype в качестве прототипа объект Product.prototype:

JavaScript
// конструктор 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
});
// цена книги со скидкой
console.log(book.calcDiscountedPrice()); // 580.45

Здесь мы Object.create используем для создания нового объекта, который будет использоваться в качестве прототипа для Book.prototype. При этом свою очередь этот новый объект будет иметь в качестве прототипа Product.prototype. Для этого мы передаем его в качестве аргумента методу Object.create. Кроме этого к этому объекту мы сразу же добавили два свойства: constructor и fullName. С помощью constructor мы восстановили конструктор, который был в прототипе пока мы ему не присвоили новое значение. А динамическое свойство fullName, представляющее собой геттер, будем использовать для получения имени книги с автором.

Комментарии: 0