Создание объекта JavaScript и цепочка прототипов

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

В JavaScript нет «классов», но когда мы используем JavaScript объектно-ориентированным способом, мы можем склоняться к ментальным моделям «наследования», которые знакомы по объектно-ориентированным языкам, которые являются по классам. Хотя JavaScript можно (и часто ) использовать с учетом этой парадигмы, он дает эффект запутывания того, что на самом деле происходит с точки зрения объектных отношений в нашем коде. Такой способ использования JavaScript также может иметь эффект цепной реакции, создавая более крутую кривую обучения для новичков, продвигая излишне сложные ментальные модели.

В этой статье я собираюсь обсудить цепочку прототипов JavaScript, посмотреть, как она работает, изучить, как конструкторы традиционно используются для создания цепочки прототипов, а затем сравнить это с подходом OLOO, который упрощает работу, полностью устраняя необходимость в конструкторах.

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

Объекты

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

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

var myObj = {
  a: 1,
  b: [1, 2, 3],
  c: function() { return this },
};

Когда мы вызываем метод объекта, контекст выполнения (this) для этой функции - это сам объект:

myObj.c() === myObj; // true

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

typeof Math === 'object'; // true
Math.PI; // 3.141592653589793
Math.sqrt(16); // 4

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

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

Это позволяет нам определять поведение для объекта «прототип» и иметь доступ ко всем объектам в цепочке.

Причина, по которой это работает, заключается в делегировании поведения в JavaScript. Это означает, что если вы вызываете метод объекта, JavaScript сначала попытается найти подходящее свойство для этого метода в самом объекте; если объект не имеет такого свойства, тогда JavaScript будет искать это свойство в объекте «прототип» объекта, фактически «делегируя» вызов метода объекту-прототипу.

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

Вам может быть интересно, как эта цепочка «связана вместе». Давайте посмотрим на это дальше.

Свойство [[Prototype]]

Все объекты имеют свойство [[Prototype]]. Значением этого свойства является другой объект, объект-прототип объекта.

[[Prototype]] является «внутренним» свойством, а не тем свойством, с которым вы можете напрямую взаимодействовать обычным способом (то есть через точечную нотацию или нотацию в скобках).

myObj = {};
myObj.[[Prototype]]; // SyntaxError: Unexpected token [
myObj['[[Prototype]]']; // undefined

Получить доступ к значению этого свойства можно двумя способами:

  • __proto__. Это метод доступа для свойства [[Prototype]] объекта. Официально он никогда не входил в языковую спецификацию. Она была стандартизирована как устаревшая функция в ES6, но устарела и была удалена из веб-стандартов. Я бы не рекомендовал использовать его в производственном коде.
  • Object.getPrototypeOf(). Это метод встроенного объекта Object. Он принимает объект в качестве аргумента и возвращает значение свойства [[Prototype]] этого объекта.

Итак, мы знаем, что все объекты имеют это свойство [[Prototype]] и что его можно использовать для связывания вместе объектов для создания «цепочки прототипов», но как свойство [[Prototype]] присваивается объекту «прототип»?

Один из способов сделать это - использовать конструктор.

Конструкторы

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

Помните, что в JavaScript есть несколько «встроенных» объектов. Что ж, многие из этих встроенных объектов также являются конструкторами. Это означает, что их можно использовать для создания других объектов.

Конструкторы обычно выполняются с оператором new.

var myDate = new Date('01/01/2001');
var myRegex = new RegExp('\\w+');

Когда мы создаем объект с помощью конструктора, он имеет доступ к свойству с именем constructor, которое ссылается на конструктор, создавший объект. (Примечание: это свойство принадлежит не самому созданному объекту, а его [[Prototype]]object; пока не беспокойтесь о том, что это означает, мы рассмотрим его более подробно позже).

myDate.constructor === Date; // true
myRegex.constructor == RegExp; // true

Большинство из этих встроенных конструкторов (Date является одним исключением) являются так называемыми «безопасными в области видимости». Это означает, что вы можете вызывать их без использования оператора new, и результат будет таким же.

var myRegex = RegExp('\\w+');
myRegex.constructor == RegExp; // true

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

Object

Встроенный объект JavaScript Object также является конструктором.

var myObj = new Object;
myObj.constructor == Object; // true

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

var someObj = {};
var anotherObj = new Object;

За исключением некоторых незначительных различий в производительности, нет никакой функциональной разницы в том, как мы создали два объекта в приведенном выше примере. (Обратите внимание, что здесь мы конкретно говорим о new Object и {}; для примитивов существует функциональная разница между обозначениями литерала и конструктора, о которой мы здесь не будем говорить).

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

Зачем нужны конструкторы?

Учитывая, что при создании объектов нет реальной функциональной разницы между буквальным синтаксисом и обозначением конструктора, зачем вообще использовать конструктор? Что ж, конструкторы могут пригодиться при создании множества настраиваемых объектов одного и того же «типа».

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

rex = {
  name: 'Rex',
  age: 4,
  species: 'dog',
}
paws = {
  name: 'Paws',
  age: 11,
  species: 'cat',
}
fluffy = {
  name: 'Fluffy',
  age: 2,
  species: 'rabbit',
}

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

rex = new Animal('Rex', 4, 'dog');
paws = new Animal('Paws', 11, 'cat');
fluffy = new Animal('Fluffy', 2, 'rabbit');

Фактически, мы можем сделать это с помощью специального конструктора, также известного как функция конструктора.

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

function myFunc() {};
var myObj = new myFunc;
myObj.constructor === myFunc; // true

Если вы никогда раньше не видели функций, используемых в качестве конструкторов, приведенный выше код может показаться немного странным. Давайте разберемся.

В строке 1 мы объявляем функцию с именем myFunc. Как мы уже упоминали, функции в JavaScript автоматически являются конструкторами.

В строке 2 мы объявляем переменную myObj и назначаем ее возвращаемому значению new myFunc (т. Е. Вызываем myFunc с оператором new). Давайте более подробно рассмотрим, что происходит с этим вызовом функции. Когда new myFunc выполняется, происходят следующие вещи:

  1. Создан новый объект
  2. Вызывается функция myFunc, при этом контекст выполнения этого вызова функции (т.е. this) является вновь созданным объектом (поскольку это тело функции пусто, это не имеет особого отношения к этому примеру).
  3. Вновь созданный объект возвращается.

В строке 3 мы проверяем, что значение constructor для myObj действительно равно myFunc. Как упоминалось ранее, constructor не является прямым свойством myObj, но доступно ему через его цепочку прототипов.

Определение атрибутов

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

function Animal(name, age, species) {
  this.name = name;
  this.age = age;
  this.species = species;
}
rex = new Animal('Rex', 4, 'dog');
paws = new Animal('Paws', 11, 'cat');
fluffy = new Animal('Fluffy', 2, 'rabbit');
rex.name; // 'Rex'
paws.age; // 11
fluffy.species; // 'rabbit'
rex.constructor === Animal; // true
paws.constructor === Animal; // true
fluffy.constructor === Animal; // true
  • Когда выполняется new Animal, создается новый объект, а затем вызывается функция Animal с этим объектом в качестве контекста выполнения, то есть this.
  • Тело функции определяет свойства этого вновь созданного объекта (this) с аргументами функции, установленными в качестве значений.
  • Наконец, вновь созданный объект возвращается и назначается нашим переменным rex, paws и fluffy. (Обратите внимание, что использование заглавных букв в имени нашей функции, Animal, является просто соглашением при определении функции, которая будет использоваться в качестве конструктора, а не синтаксическим требованием).

Конструкторы Scope Safe

Помните, мы говорили, что «встроенные» конструкторы JavaScript «безопасны для области видимости»; другими словами, их можно было вызвать без оператора new? Это не относится к пользовательским конструкторам, таким как наша функция Animal. Если мы вызываем функцию без new, то новый объект не создается, а контекст выполнения функции (this) является глобальным объектом (который window в среде браузера), поэтому свойства создается на глобальном объекте, а не на новом объекте. Кроме того, функция неявно возвращает undefined, а не новый объект, поэтому нашей переменной присваивается undefined вместо нового Animalobject.

goldie = Animal('Goldie', 1, 'goldfish');
goldie; // undefined
window.name; // 'Goldie'
window.age; // 1
window.species; // 'goldfish'

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

Можно сделать настраиваемый конструктор безопасным для области видимости, проверив, был ли создан новый объект (т.е. this является экземпляром конструктора) и используя это в условном выражении:

function Animal(name, age, species) {
  if (this instanceof Animal){
    this.name = name;
    this.age = age;
    this.species = species;
  } else {
    return new Animal(name, age, species);  
  }
}

Совместное использование поведения

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

function Animal(name, age, species) {
  this.name = name;
  this.age = age;
  this.species = species;
  this.move = function() {
    console.log(this.name + ' is walking.');
  };
}

rex = new Animal('Rex', 4, 'dog');
paws = new Animal('Paws', 11, 'cat');

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

rex.move(); // 'Rex is walking.'
paws.move(); // 'Paws is walking.'

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

rex.move = function() {
  console.log(this.name + ' is running.');
};
rex.move(); // 'Rex is running.'
paws.move(); // 'Paws is walking.'
paws.move = function() {
  console.log(this.name + ' is running.');
};
paws.move(); // 'Paws is running.'

Если бы у нас было много объектов, это было бы довольно утомительно. Было бы здорово, если бы мы могли определить функцию таким образом, чтобы несколько объектов могли получить к ней доступ без необходимости создавать свои собственные копии. Что ж, мы можем сделать это с помощью делегирования поведения JavaScript. Мы посмотрим, как это сделать дальше.

Конструкторы и цепочка прототипов

Ранее мы рассмотрели, как все объекты имеют свойство [[Prototype]] и как значение этого свойства является другим объектом. Мы увидели, как можно создать серию объектов, каждый из которых связан со следующим своим свойством [[Prototype]], в «цепочку прототипов».

Мы также сказали, что один из способов присвоить объекту свойство [[Prototype]] - использовать конструктор. Так как же это происходит? Когда конструкторы создают объект, они устанавливают значение свойства [[Prototype]] этого нового объекта на тот же объект, что и их собственное свойство prototype.

Чего ждать?

Свойство prototype

Все конструкторы имеют свойство prototype. Значением этого свойства является другой объект, «прототип объекта» конструктора. Вот где это может сбить с толку.

Хотя [[Prototype]] и prototype звучат одинаково, на самом деле они разные свойства. Мы уже говорили, что все объекты имеют внутреннее свойство [[Prototype]]; то же самое нельзя сказать о prototype, только конструкторы имеют это свойство. Как и свойство [[Prototype]], prototype также указывает на объект; конструкторы устанавливают этот объект как значение свойства [[Prototype]] для любых объектов, которые они создают.

Из диаграммы выше мы видим, что свойство prototype конструктора MyFunc указывает на тот же объект, что и свойство [[Prototype]] объекта myObj, созданного конструктором MyFunc. Давайте посмотрим на это в коде.

function MyFunc() {};
var myObj = new MyFunc;
Object.getPrototypeOf(myObj) === MyFunc.prototype; // true

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

Этот объект prototype имеет свойство constructor, которое указывает на функцию. Помните, ранее мы видели, что когда вы создаете объект с помощью конструктора, он имеет доступ к свойству с именем constructor, которое ссылается на конструктор, создавший объект? Причина, по которой объект может ссылаться на это свойство, заключается в том, что он делегирует вызов своему объекту [[Prototype]], который является объектом prototype конструктора.

Еще одна вещь, на которую следует обратить внимание, это то, что конструкторы также имеют свойство [[Prototype]]. Это имеет смысл, потому что конструкторы также являются объектами, и, как мы уже говорили ранее, все объекты имеют свойство [[Prototype]]. [[Prototype]]property конструктора (обычно) никогда не указывает на тот же объект, что и его свойство prototype. Для пользовательских конструкторов свойство [[Prototype]] указывает на объект, назначенный свойству prototype для Function. Опять же, это имеет смысл, поскольку Function является конструктором функций.

Уф! Все это могло быть довольно сложным, поэтому, прежде чем двигаться дальше, давайте резюмируем пару важных моментов:

  • Все объекты имеют свойство [[Prototype]], которое указывает на объект "прототип" этого объекта.
  • Конструкторы имеют свойство prototype. Это указывает на объект, который используется как значение [[Prototype]]property для объектов, созданных этим конструктором.

Построение цепочки

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

Вернемся к нашему предыдущему примеру с животными.

function Animal(name, age, species) {
  this.name = name;
  this.age = age;
  this.species = species;
}
rex = new Animal('Rex', 4, 'dog');
paws = new Animal('Paws', 11, 'cat');
fluffy = new Animal('Fluffy', 2, 'rabbit');

В приведенном выше коде мы создали три наших объекта-животных с помощью конструктора Animal, но в настоящее время метод move нигде не определен.

rex.move(); // TypeError: rex.move is not a function

Если мы хотим определить метод таким образом, чтобы все наши существующие объекты-животные могли получить к нему доступ, мы можем добавить его к объекту [[Prototype]] для этих объектов, который является тем же объектом, что и Animal.prototype.

Object.getPrototypeOf(rex) === Animal.prototype; // true

Путем определения метода в этом объекте «прототип», когда наши объекты-животные вызывают этот метод, этот вызов делегируется вверх по цепочке прототипов объекту «прототип», где он определен.

Animal.prototype.move = function() {
  console.log(this.name + ' is walking.');
};
rex.move(); // 'Rex is walking.'
paws.move(); // 'Paws is walking.'
fluffy.move(); // 'Fluffy is walking.'

Для ясности: в отличие от нашего предыдущего примера, где каждый объект получил копию функции, этот метод не существует непосредственно на объектах-животных. Вместо этого он существует в их объекте «прототип» и доступен им через делегирование поведения.

Мы можем проверить это с помощью метода hasOwnProperty(). Это метод, доступный для всех объектов через prototypeobject из Object (который находится в цепочке прототипов всех настраиваемых объектов). Этот метод вызывается для объекта и принимает имя свойства в качестве аргумента, он возвращает true, если свойство действительно определено для объекта (в отличие от другого места в его цепочке прототипов), и false в противном случае.

rex.hasOwnProperty('move'); // false
Object.getPrototypeOf(rex).hasOwnProperty('move'); // true

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

Animal.prototype.move = function() {
  console.log(this.name + ' is running.');
};
rex.move(); // 'Rex is running.'
paws.move(); // 'Paws is running.'
fluffy.move(); // 'Fluffy is running.'

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

Шаблоны создания объектов

Такой подход использования конструктора для создания объектов и совместного использования поведения между этими объектами путем добавления методов к prototype объекту конструктора известен как «псевдоклассический» шаблон создания объекта. Примеры, которые мы рассмотрели до сих пор, были довольно простыми, но можно создать гораздо более длинные и более сложные цепочки прототипов, используя псевдоклассический шаблон. Вскоре мы рассмотрим пример этого. В качестве контраргумента мы увидим, как шаблон OLOO создает цепочку прототипов вообще без использования конструкторов.

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

До сих пор в наших основных примерах псевдоклассического шаблона мы добавляли поведения к существующему prototypeobject наших конструкторов. Чтобы создать более длинные цепочки прототипов с помощью этого шаблона, нам на самом деле нужно заменить prototypeobject конструкторов в цепочке на объект, созданный конструктором, расположенным дальше по цепочке. Это может быть немного сложно представить, поэтому давайте рассмотрим наш Animal пример, чтобы увидеть, как это работает.

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

function Animal(name, age, species) {
  this.name = name;
  this.age = age;
  this.species = species;
}
function Dog(name, age, breed) {
  Animal.call(this, name, age, 'dog');
  this.breed = breed;
};
function Cat(name, age, colour) {
  Animal.call(this, name, age, 'cat');
  this.colour = colour;
};
Dog.prototype = new Animal;
Cat.prototype = new Animal;
rex = new Dog('Rex', 4, 'bulldog');
paws = new Cat('Paws', 11, 'tabby');

Использование call в Dog и Cat выполняет Animal как функцию (не как конструктор), но с объектами, созданными конструкторами Dog и Cat в качестве контекста выполнения (this). Это позволяет нам сгруппировать «общие» атрибуты, такие как name и age, в одном месте (Animal) и, таким образом, избежать повторения в наших конструкторах Dog и Cat. Важно отметить, что свойства фактически определены для объектов, созданных конструкторами Dog и Cat.

rex.name; // 'Rex'
paws.name; // 'Paws'
rex.hasOwnProperty('name'); // true
paws.hasOwnProperty('name'); // true

Используя этот шаблон, мы также можем определить определенные свойства для разных типов животных. Объекты Dog имеют свойство breed, а объекты Cat имеют свойство colour.

rex.breed; // 'bulldog'
paws.breed; // undefined
rex.colour; // undefined
paws.colour; // 'tabby'

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

Animal.prototype.move = function() {
  console.log(this.name + ' is walking.');
};
Dog.prototype.bark = function() {
  console.log(this.name + ' says woof woof!');
};
Cat.prototype.purr = function() {
  console.log(this.name + ' says purrrr....');
};

Метод move доступен для всех объектов ниже по цепочке от Animal.prototype, но bark доступен только для тех, кто ниже Dog.prototype и purr для тех, кто ниже Cat.prototype.

rex.move(); // 'Rex is walking.'
paws.move(); // 'Paws is walking.'

rex.bark(); // 'Rex says woof woof!'
paws.bark(); // TypeError: paws.bark is not a function

rex.purr(); // TypeError: rex.purr is not a function
paws.purr(); // 'Paws says purrrr....'

Свойство constructor

При использовании этого подхода следует учитывать влияние на свойство constructor.

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

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

Когда мы заменяем объект prototype функции-конструктора, у нас больше нет свойства constructor, указывающего на конструктор.

Если мы ссылаемся на constructor на наш Dog объект rex, мы могли бы ожидать, что это укажет обратно на Dog, но это не так. Вместо этого он указывает на Animal

rex.constructor === Dog; // false
rex.constructor === Animal; // true

Причина в том, что мы заменили объект, присвоенный Dog.prototype, на объект, возвращенный new Animal. У этого объекта нет свойства constructor, как и у объекта, назначенного для rex, поэтому, когда мы вызываем rex.constructor, он делегируется вверх по цепочке объекту, который является [[Prototype]] из Dog.prototype. Из-за переназначения Dog.prototype этот объект теперь является объектом Animal.prototype, поэтому его свойство constructor указывает на Animal.

Если для нашей программы важно поддерживать отношения constructor, мы можем «сбросить» эти отношения, добавив свойство к новому объекту Dog.prototype.

Dog.prototype.constructor = Dog;
rex.constructor === Dog; // true

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

До сих пор мы много говорили о механике псевдоклассического паттерна, но не много о ментальной модели, порождаемой этим паттерном.

Идея определения конструкторов для создания объектов и использования prototype объектов этих конструкторов (почти так, как если бы они были «расширением» конструктора) для совместного использования поведения, может привести нас к представлению такой иерархической структуры, как эта:

Если вы думаете, что это очень похоже на наследование на основе классов такого языка, как Ruby или Java, вы будете правы. Псевдоклассический шаблон создания объекта эффективно имитирует наследование на основе классов без классов. Синтаксис ES6 class продвигается еще дальше к этой парадигме.

class Animal {
  constructor(name, age, species) {
    this.name = name;
    this.age = age;
    this.species = species;
  }
  move() {
    console.log(this.name + ' is walking.');
  }
}
class Dog extends Animal {
  constructor(name, age, breed) {
    super(name, age, 'dog');
    this.breed = breed;
  }
  bark() {
    console.log(this.name + ' says woof woof!');
  }
}
class Cat extends Animal {
  constructor(name, age, colour) {
    super(name, age, 'cat');
    this.colour = colour;
  }
  purr() {
    console.log(this.name + ' says purrrr....');
  }
}
rex = new Dog('Rex', 4, 'bulldog');
paws = new Cat('Paws', 11, 'tabby');
rex.name; // 'Rex'
paws.name; // 'Paws'
rex.move(); // 'Rex is walking.'
paws.move(); // 'Paws is walking.'
rex.bark(); // 'Rex says woof woof!'
paws.bark(); // TypeError: paws.bark is not a function
rex.purr(); // TypeError: rex.purr is not a function
paws.purr(); // 'Paws says purrrr....'

Использование синтаксиса class вместе с extends устраняет необходимость вручную устанавливать prototype конструкторов Cat и Dog для объекта Animal. Ключевое слово super заменяет использование метода call из нашего примера ES5.

Еще одно преимущество синтаксиса class состоит в том, что, в отличие от синтаксиса ES5, он поддерживает отношения объект / конструктор без необходимости вручную сбрасывать свойство constructor.

rex.constructor; // Dog
paws.constructor; // Cat

Что действительно важно понять в синтаксисе ES6 class, так это то, что он не делает внезапно JavaScript языком на основе классов с классическим наследованием. Использование class, extends, super и т. Д. - всего лишь синтаксический сахар поверх существующей прототипной структуры JavaScript.

Когда мы используем псевдоклассический шаблон (будь то с синтаксисом ES5 или ES6), если мы игнорируем способ создания объектов, на самом деле мы получаем набор объектов, связанных с другими объектами через их [[Prototype]] собственность.

Шаблон OLOO

Шаблон OLOO - это шаблон создания объектов, популяризированный Кайлом Симпсоном. В отличие от псевдоклассического шаблона, OLOO не пытается имитировать иерархию на основе классов. Вместо этого он использует тот факт, что цепочка прототипов - это, по сути, просто объекты, связанные с другими объектами через их свойство [[Prototype]], чтобы полностью избавиться от необходимости в конструкторах. Фактически OLOO - это просто аббревиатура от Объекты, связанные с другими объектами.

В JavaScript механизм [[Prototype]] связывает объекты с другими объектами. Не существует абстрактных механизмов, таких как «классы», как бы вы ни пытались убедить себя в обратном.

- Кайл Симпсон

Шаблон OLOO использует метод Object.create() для создания экземпляров новых объектов. Этот метод принимает объект в качестве аргумента и возвращает новый объект, свойство [[Prototype]] которого установлено на объект, который был передан в метод. Давайте посмотрим на пример этого в коде.

var myObj = {};
var myObj2 = Object.create(myObj);
Object.getPrototypeOf(myObj2) === myObj; // true

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

Давайте посмотрим, как наш пример с животными будет работать с шаблоном OLOO.

var Animal = {
  initAnimal: function(name, age, species) {
    this.name = name;
    this.age = age;
    this.species = species;
    return this;
  },
  move: function() {
    console.log(this.name + ' is walking.');
  }
}
var Dog = Object.create(Animal);
Dog.initDog = function(name, age, breed) {
  this.initAnimal(name, age, 'dog');
  this.breed = breed;
  return this;
};
Dog.bark = function() {
  console.log(this.name + ' says woof woof!');
};
var Cat = Object.create(Animal);
Cat.initCat = function(name, age, colour) {
  this.initAnimal(name, age, 'cat');
  this.colour = colour;
  return this;
};
Cat.purr = function() {
  console.log(this.name + ' says purrrr....');
};
rex = Object.create(Dog).initDog('Rex', 4, 'bulldog');
paws = Object.create(Cat).initCat('Paws', 11, 'tabby');
rex.name; // 'Rex'
paws.name; // 'Paws'
rex.move(); // 'Rex is walking.'
paws.move(); // 'Paws is walking.'
rex.bark(); // 'Rex says woof woof!'
paws.bark(); // TypeError: paws.bark is not a function
rex.purr(); // TypeError: rex.purr is not a function
paws.purr(); // 'Paws says purrrr....'

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

Сначала мы создаем объект, используя буквальную нотацию, и назначаем его переменной с именем Animal (как и в псевдоклассическом шаблоне, использование заглавных букв здесь просто соглашение, хотя наш объект Animal не является конструктором). У объекта есть несколько методов: initAnimal() и move(). Метод move() такой же, как в нашем псевдоклассическом примере. Метод initAnimal() просто определяет свойства name, age и species для любого объекта, для которого он вызывается (this); затем он возвращает этот объект.

Затем мы создаем еще пару объектов, используя Object.create(), и назначаем их переменным Dog и Cat. Поскольку мы передали наш объект Animal в Object.create(), он устанавливается как значение свойства [[Prototype]] для объектов Dog и Cat.

Обратите внимание, что Animal, Dog и Cat - это просто обычные объекты, а не конструкторы.

Затем мы добавляем несколько методов к нашим объектам Dog и Cat. initDog и initCat оба первоначально вызывают initAnimal для объекта, для которого они вызываются (this), а затем определяют дополнительное свойство (breed и colour соответственно) для того же объекта; затем они возвращают объект (позже мы увидим, почему этот явный возврат полезен). Методы bark и purr такие же, как в нашем псевдоклассическом примере.

Затем мы создаем еще два объекта, используя Object.create(), и назначаем их переменным rex и paws. Поскольку мы передали наши объекты Dog и Cat в Object.create(), эти объекты устанавливаются как значение свойства [[Prototype]]property для объектов rex и paws соответственно. Мы связываем вызовы методов initDog() и initCat() с нашими Object.create вызовами, чтобы определить различные свойства наших объектов. Здесь следует отметить несколько моментов:

  1. Методы initDog() и initCat() могут вызывать initAnimal() для объекта, созданного Object.create (т.е. this), потому что этот объект может делегировать вызов этого метода объекту Animal, который установлен как [[Prototype]] этих объектов.
  2. Тот факт, что методы initDog() и initCat() возвращают объект, для которого они вызываются (this), означает, что мы можем связать их с Object.create и по-прежнему иметь rex и paws, назначенные объектам. Если бы они не вернули this, нам пришлось бы вызывать их по отдельности, а не связывать их в цепочку.

Самое важное, что нужно понимать в этом примере кода OLOO, - это то, что мы получаем точно такие же объекты с теми же отношениями, что и при псевдоклассическом подходе. Разница в том, что мы достигаем этой структуры без использования конструкторов и всей связанной с ними сложности, которую они вносят.

Нам не нужно заниматься определением конструкторов и настройкой их prototype объектов или беспокоиться об ошибках, вызванных тем, что мы забыли использовать new или сделали наши конструкторы безопасными для области видимости. Что еще более важно, работа только с объектами, а не с конструкторами предоставляет нам гораздо более простую мысленную модель и освобождает нас от ограничений, связанных с мышлением наших объектных отношений в терминах иерархии классов; он позволяет нам думать о цепочке прототипов JavaScript такой, какая она есть на самом деле, а не абстрагировать ее до ограничительной и несуществующей структуры.

Теперь мы просто настраиваем объекты, связанные друг с другом, без всякой неразберихи и путаницы, которые выглядят (но не ведут себя!) как классы, с конструкторами, прототипами и новыми вызовами. Спросите себя: если я могу получить те же функциональные возможности с кодом стиля OLOO, что и с кодом стиля «класс», но OLOO проще и не о чем думать, разве OLOO не лучше?

- Кайл Симпсон

В конечном итоге выбранный вами подход и используемый шаблон создания объекта будут зависеть от ряда различных факторов, но использование шаблона OLOO имеет большой смысл. Простота его ментальной модели и гибкость, которую она может привнести в ваш код, означают, что ее определенно стоит рассмотреть, даже с синтаксисом class ES6, который теперь доступен нам.

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

Резюме

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

  • Все объекты имеют свойство [[Prototype]], которое указывает на другой объект. Мы можем использовать это свойство, чтобы «связать» объекты вместе, чтобы сформировать «цепочку прототипов».
  • Когда мы вызываем метод объекта, JavaScript сначала ищет в объекте подходящее свойство, а затем при необходимости выполняет поиск по цепочке прототипов. Таким образом, мы можем использовать цепочку прототипов, чтобы «разделять поведение» между объектами.
  • Конструкторы - это функциональные объекты, которые создают объекты. У конструкторов есть свойство prototype, которое указывает на объект. Когда мы создаем объект с помощью конструктора, объект prototype конструктора устанавливается как объект [[Prototype]] вновь созданного объекта.
  • Использование конструкторов для создания объектов и конструктора prototype для совместного использования поведения - это шаблон создания объекта, известный как псевдоклассический шаблон.
  • Шаблон OLOO может создавать те же объекты и отношения [[Prototype]] более простым способом без использования конструкторов с помощью Object.create().

Название этой статьи - дань уважения Арете Франклин, которая, к сожалению, скончалась на прошлой неделе.