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

Объект и его прототип

Прототип объекта

Непосредственный прототип объекта указан в его служебном свойстве __proto__.

let animal = { type: 'animal' };
let cat = {};
cat.__proto__ = animal;

Объект animal становится прототипом объекта cat. Теперь cat наследует свойства animal, если они не определены в нем самом.

console.log(cat.type); // "animal"

Доступ к прототипу объекта

Свойство __proto__ является служебным, поэтому не стоит обращаться к нему непосредственно, а тем более изменять его.

Для чтения значения свойства __proto__ есть специальный метод Object.getPrototypeOf(obj):

Object.getPrototypeOf(cat); // {type: "animal"}

Он возвращает объект, который является прототипом данного объекта.

Установка прототипа

По умолчанию прототипом всех объектов в JavaScript является объект, который хранится в свойстве Object.prototype:

let newObject = {};
Object.getPrototypeOf(newObject) === Object.prototype; // true

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

newObject.toString(); // "[object Object]"

Можно создать объект с другим прототипом (отличающимся от Object.prototype). Для этого предназначен метод Object.create:

Object.create(proto, descriptors)

В первом параметре proto указывается прототип нового объекта. Прототипом может стать любой другой объект.

Второй параметр descriptors необязателен, в нем можно указать встроенные свойства нового объекта.

let dog = Object.create(animal);
console.log(dog.type); // "animal"

Объект без прототипа

Можно создать объект совсем без прототипа, передав null в метод Object.create:

let emptyObject = Object.create(null);

Объект, созданный таким образом не наследует даже от Object.prototype, он абсолютно чист и не имеет никаких методов и свойств.

console.log(emptyObject.toString); // undefined

Перезапись прототипа

Можно изменить прототип для уже существующего объекта:

let horse = {};
Object.setPrototypeOf(horse, animal);
console.log(horse.type); // "animal"

При создании у объекта horse установлен прототип по умолчанию — Object.prototype. Мы меняем его на animal. Теперь horse может обращаться к свойствам объекта animal, если у него нет аналогичных собственных свойств.

Цепочка прототипов

Повторим еще раз.

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

При обращении к свойствам и методам объекта сначала они ищутся в самом объекте:

let animal = { type: 'animal', canFly: false };
let bird = { canFly: true };

// Назначаем объект animal прототипом объекта bird
Object.setPrototypeOf(bird, animal); 

console.log(bird.canFly); // true

Здесь мы обращаемся к свойству canFly, которое определено у самого объекта bird, так что обращения к прототипу не происходит.

Если свойство или метод в объекте не нашлось, он перенаправляет запрос к своему непосредственному прототипу.

console.log(bird.type); // "animal"

У объекта bird нет свойства type, но оно есть у его прототипа animal, поэтому в консоль выводится строка "animal", а не undefined.

Запрос может идти дальше, вверх по цепочке прототипов, пока не найдется нужное свойство.

console.log(bird.toString()); // "[object Object]"

В объекте bird нет метода toString. В его прототипе animal тоже нет. А вот в прототипе animalObject.prototype — этот метод есть.

Важно понимать, что эти свойство type и метод toString не хранятся в объекте bird и не принадлежат ему. Просто при необходимости он может найти их у своих прототипов и одолжить на время. При этом методы будут выполняться в контексте объекта bird, как если бы они ему принадлежали.

Например, добавим объекту animal новое свойство — количество ног и новый метод, который возвращает это количество:

animal.legs = 4;
animal.getLegsCount = function() { return this.legs; }

Объекту bird тоже добавим количество ног, но не будем добавлять метод:

bird.legs = 2;

Теперь если вызвать у bird метод getLegsCount, он вернет значение 2, так как будет выполнен именно для объекта bird, то есть его this будет ссылаться на bird, как и положено:

console.log(bird.getLegsCount()); // 2

Для справки: Все, что вы хотели знать о this, но боялись спросить

Проверка принадлежности свойства

Иногда требуется узнать, принадлежит ли свойство (или метод) непосредственно объекту или наследуется по цепочке прототипов. Для этого используется метод object.hasOwnProperty(propName):

bird.hasOwnProperty("legs");          // true
bird.hasOwnProperty("getLegsCount");  // false

Обратите внимание, что этот метод определен в корневом прототипе — Object.prototype. То есть он не будет доступен для объектов, у которых в цепочке прототипов нет Object.prototype, например, для объектов, созданных через Object.create(null), и их прототипных потомков.

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

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

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

Этот объект возвращается из функции автоматически, поэтому инструкция return не нужна.

Кроме того, функция-конструктор, как любая другая функция может принимать параметры.

// Функция-конструктор
function Rabbit(name) {

  this.species = "Кролик";
  this.name = name;
} 

// Создание объектов с помощью конструктора
let rabbit1 = new Rabbit("Питер");
let rabbit2 = new Rabbit("Джон");

rabbit1 и rabbit2 — это обычные объекты. У каждого из них есть свойство species, равное "Кролик", и свойство name (имя у каждого свое).

console.log(rabbit1.name); // "Питер"
console.log(rabbit2.species); // "Кролик"

Очень часто шаблон, по которому конструктор создает объекты называется классом, а созданные объекты — экземплярами класса.

Function.prototype

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

console.log(Rabbit.prototype);

На самом деле, этот объект не совсем пустой. У него есть свойство constructor, в котором находится ссылка на саму функцию-конструктор. Чуть позже еще поговорим о нем.

Когда мы вызываем конструктор с ключевым словом new, он создает нам новый объект. Так вот, прототипом этого нового объекта будет тот объект, который лежит в свойстве prototype функции-конструктора.

let rabbit = new Rabbit("Николай");
Object.getPrototypeOf(rabbit) === Rabbit.prototype; // true

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

Object.setPrototypeOf(rabbit, Rabbit.prototype);

Обычно в этот объект-прототип Function.prototype добавляют методы, которые должны быть доступны новому созданному объекту. Таким образом, он сможет пользоваться ими по цепочке прототипов. А свойства определяют внутри самого конструктора.

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

function Rabbit(name) {

  this.species = "Кролик";
  this.name = name;
} 
Rabbit.prototype.hello = function() {
  console.log("Привет! Я " + this.species + " " + this.name);
}

let rabbit1 = new Rabbit("Питер");
let rabbit2 = new Rabbit("Джон");

rabbit1.hello(); // Привет! Я Кролик Питер
rabbit2.hello(); // Привет! Я Кролик Джон

Function.prototype.constructor

Мы уже сказали, что в свойстве prototype функции-конструктор по умолчанию уже есть поле constructor, которое ссылается на саму функцию-конструктор.

Rabbit.prototype.constructor == Rabbit;

Это нужно, чтобы у новых объектов, созданных функцией Rabbit осталась информация о конструкторе, который их создал.

let rabbit = new Rabbit();
rabbit.constructor === Rabbit; // true

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

Если мы хотим, чтобы объекты, созданные конструктором Rabbit, имели в качестве прототипа объект animal, то мы должны просто записать его в свойство Rabbit.prototype.

function Rabbit(name) {

  this.species = "Кролик";
  this.name = name;
} 
Rabbit.prototype = animal;

let rabbit = new Rabbit("Питер");
console.log(rabbit.type); // "animal"

В качестве прототипа для нового объекта rabbit будет установлен тот объект, который лежит в Rabbit.prototype, то есть animal. Таким образом, мы можем обратиться к свойству rabbit.type, хотя у самого rabbit этого свойства нет.

Важно понимать, что изменение свойства prototype функции-конструктора повлияет только на объекты, созданные уже после этого изменения:

let proto1 = { property: 1 };
let proto2 = { property: 2 };

function ClassConstructor() {

  this.otherProperty = 3;
}

// Устанавливаем proto1 в качестве прототипа для создаваемых объектов
ClassConstructor.prototype = proto1;
// Создаем объект с прототипом proto1
let obj1 = new ClassConstructor();

// Меняем прототип для создаваемых объектов на proto2
ClassConstructor.prototype = proto2;
// Создаем объект с прототипом proto2
let obj2 = new ClassConstructor();

Object.getPrototypeOf(obj1) === proto1; // true
Object.getPrototypeOf(obj2) === proto2; // true

Для объекта obj1 прототипом является объект proto1, и последующее изменение ClassConstructor.prototype никак на это не повлияет.

Проблема #1: Потеря ссылки на конструктор

Если мы присваиваем какой-то объект в свойство prototype функции-конструктора, то теряем ссылку на сам конструктор. Поэтому рекомендуется установить ее вручную:

Rabbit.prototype = animal;
Rabbit.prototype.constructor = Rabbit;

Проблема #2: Изменение прототипа

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

let animal = {
  type: "animal",
  legs: 4
};

function Rabbit(name) {
  this.species = "Кролик";
  this.name = name;
}
Rabbit.prototype = animal;
Rabbit.prototype.constructor = Rabbit;

function Cow(name) {
  this.species = "Корова";
  this.name = name;
}
Cow.prototype = animal;
Cow.prototype.constructor = Cow;

let rabbit = new Rabbit("Питер");
rabbit.constructor === Rabbit; // false
rabbit.constructor === Cow; // true

Мы создали две функции-конструктора Rabbit и Cow. Одна создает кроликов, а другая — коров. Обе используют в качестве прототипа для своих экземпляров объект animal.

Однако у объект rabbit почему-то в свойстве constructor лежит функция-конструктор Cow.

Дело в том, что и в Rabbit.prototype, и в Cow.prototype лежит ссылка на один и тот же объект animal. Поэтому, когда мы устанавливаем свойство Rabbit.prototype.constructor, оно устанавливается для объекта animal. А когда мы устанавливаем Cow.prototype.constructor — мы это свойство у animal обновляем. Теперь в animal.constructor лежит функция Cow.

Чтобы такое не происходило, нужно каждому конструктору выделить свой собственный объект прототипа, чтобы его можно было менять без ущерба для исходного объекта animal. Для создания такого объекта мы используем метод Object.create, то есть создадим объект, прототипом которого является animal, и используем его как прототип для функции-конструктора. Таким образом мы просто удлиняем цепочку прототипов на один элемент.

rabbit.prototype = Object.create(animal);
Rabbit.prototype.constructor = Rabbit;

Cow.prototype = Object.create(animal);
Cow.prototype.constructor = Cow;

Как работает Object.create(proto)

Обобщив знания о конструкторах и прототипах, мы можем лучше представить логику работы метода Object.create.

function inherit(proto) {
  function F() {}
  F.prototype = proto;
  let object = new F();
  return object;
}

Функция inherit эмулирует метод Object.create(proto).

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

В качестве прототипа для создаваемых объектов (метод F.prototype) устанавливаем объект proto, переданный как параметр.

Создаем новый объект, вызывая конструктор F с ключевым словом new. Его прототипом окажется proto, что и нам и было нужно.

Теперь просто возвращаем новый объект из функции.

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

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

Мы можем легко наследовать один класс (конструктор) от другого.

Создадим для начала функцию-конструктор Animal:

function Animal(name) {
  this.name = name;
  this.type = "Животное";
  this.species = "Животное";
}
Animal.prototype.hello = function() {
  console.log("Привет! Я " + this.species + " " + this.name);
}

Этот конструктор может создавать объекты с собственными свойствами name, type и species, а также с доступным из прототипа методом hello.

Теперь мы хотим создать конструктор Rabbit, который будет наследовать все методы и свойства от Animal с некоторыми небольшими изменениями.

Для этого нам нужно сделать две вещи:

1) В самом конструкторе Rabbit нужно заполнить новый объект (this) теми же свойствами, которыми свои объекты заполняет конструктор Animal.

function Rabbit(name) {
  // отправляем this для заполнения свойствами в конструктор Animal
  Animal.apply(this, arguments);
  // переопределяем нужные свойства

  this.species = "Кролик";
}

2) Установить для новых объектов класса прототип с тем же набором методов, который есть в Animal.prototype.

// Используем для основы прототипа Animal.prototype
Rabbit.prototype = Object.create(Animal.prototype);
Rabbit.prototype.constructor = Rabbit;

// Добавляем дополнительные методы
Rabbit.prototype.jump = function() {
  console.log(this.species + " " + this.name + " прыгает");
};

Теперь создаем нового кролика:

let rabbit = new Rabbit("Питер");
console.log(rabbit.type); // "Животное" - унаследовано от Animal
console.log(rabbit.species); // "Кролик" - переопределено в Rabbit
console.log(rabbit.name); // "Питер" - задано входящим параметром
rabbit.hello(); // "Привет! Я Кролик Питер" - метод из класса Animal
rabbit.jump(); // "Кролик Питер прыгает" - метод из класса Rabbit
rabbit.constructor === Rabbit; // true

Проверка принадлежности к классу

В JavaScript есть оператор instanceof, который позволяет узнать, принадлежит ли объект к определенному классу (то есть создавал ли его определенный конструктор).

rabbit instanceof Rabbit; // true

Эта проверка учитывает всю цепочку прототипного наследования:

rabbit instanceof Animal; // true

Здесь тоже будет true, так как конструктор Animal тоже участвовал в создании объекта rabbit.

Алгоритм проверки выглядит так:

  1. Сначала свойство __proto__ объекта rabbit сравнивается со свойством prototype функции-конструктора Animal. Если бы они были равны, это означало бы, что rabbit создан конструктором Animal. Но они не равны.
  2. Вместо rabbit берется его прототип (rabbit.__proto__) и теперь его свойство __proto__ сравнивается с Animal.prototype. А вот они уже равны, ведь прототип rabbit (Rabbit.prototype) действительно наследует напрямую от Animal.prototype. Проверка пройдена.
rabbit instanceof Animal 

rabbit.__proto__ === Animal.prototype; // false
rabbit.__proto__.__proto__ === Animal.prototype; // true

Проверка может продолжаться до тех пор, пока прототипы не закончатся.

1 комментарий

  • Endem
    Endem
    20/01/2020, 13:23
    Очень доступно и компактно подан материал. Спасибо автору.

Оставить комментарий

*Доступные HTML-теги: a, abbr, blockquote, code, pre, del, i, em, strong, b, strike
*Не будет опубликован