JavaScript - подробное описание цепочки прототипов

Изучите концепцию наследования с помощью цепочки прототипов

Изначально размещено в моем личном блоге debuggr.io

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

Наша цель

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

Представьте себе объект Player:

Что, если мы хотим задействовать функциональные возможности этого объекта, например изменить счет. Где бы мы поместили setScore метод?

Объекты

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

Литерал объекта

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

Мы также можем создавать или получать доступ к свойствам объекта с помощью записи через точку или скобку:

Object.create

Другой вариант создания Object - использование метода Object.create:

Object.create всегда возвращает новый пустой объект, но мы получим бонусную функцию, если передадим ему другой объект. Мы вернемся к этому позже.

Автоматизация

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

Заводские функции

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

что произойдет, если мы запустим эту функцию дважды?

у нас получится 2 объекта такой формы:

Вы заметили какие-то дублирования? Наш setScore сохраняется для каждого экземпляра, это нарушает принцип D.R.Y (Don't Repeat Yourself).

Что, если бы мы могли сохранить его где-то еще один раз и по-прежнему иметь к нему доступ через экземпляр объекта: player1.setScore(1000)?

OLOO - объекты, связанные с другими объектами

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

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

Оказывается, что ВСЕ объекты в javascript имеют специальное скрытое свойство, называемое __proto__ (произносится как dunder proto), и если это свойство указывает на объект, то движок будет рассматривать свойства этого объекта, как если бы они были на самом экземпляре. Другими словами, каждый объект может связываться с другим объектом через свойство __proto__ и получать доступ к его свойствам, как если бы они были его собственными.

️️⚠️ Примечание

Не путайте __proto__ со свойством prototype, prototype - это свойство, которое существует только для функций. __proto__, с другой стороны, это свойство, которое только существует для объектов. Чтобы сделать его более запутанным, свойство __proto__ в спецификациях EcmaScript называется [[Prototype]].

Мы вернемся к этому позже 🤔

Давайте посмотрим на пример с нашим кодом для лучшей визуализации:

Это выведет:

Это означает, что и player1, и player2 имеют доступ к свойствам playerFunctions, то есть они оба могут запускать setScore:

Мы достигли своей цели, у нас есть объекты с данными и функциями, прикрепленными к ним, и мы не нарушили принцип D.R.Y.

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

  1. Нам нужно создать объект.
  2. Нам нужно создать другой объект, который будет содержать нашу функциональность.
  3. Мы должны использовать Object.create, чтобы связать свойство __proto__ с функциональным объектом.
  4. Нам нужно заполнить новый объект свойствами.
  5. Нам нужно вернуть новый объект.

Что, если бы некоторые из этих задач могли быть выполнены за нас?

Оператор new - функция конструктора A.K.A

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

Но прежде чем мы увидим это в действии, давайте удостоверимся, что мы находимся на одной странице в отношении того, что такое функция.

Что такое на самом деле функция?

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

Свойство прототипа

Оказывается, все функции (за исключением стрелочных функций) имеют свойство .prototype.

Да, вот еще раз предупреждение:

Не __proto__ или [[Prototype]], а prototype.

Теперь вернемся к новому оператору.

Вызов с оператором new

Вот так наша функция может выглядеть с оператором new:

⚠️ Если вы не на 100% уверены, что понимаете, как работает ключевое слово this, вы можете прочитать JavaScript - подробное описание ключевого слова 'this'

И вот результат:

Давайте пройдемся по этому коду (этап выполнения)

Мы выполняем функцию Player с оператором new, обратите внимание, что я изменил имя функции с createPlayer на Player только потому, что это соглашение между разработчиками. Это способ сообщить потребителю функции Player, что это «функция-конструктор» и ее следует вызывать с оператором new.

Когда мы вызываем функцию с оператором new, JavaScript выполняет за нас 4 вещи:

  1. Это создаст новый объект.
  2. Он назначит новый объект контексту this.
  3. Он свяжет свойство __proto__ этого нового объекта со свойством prototype функции. Player.prototype в нашем случае.
  4. Он вернет этот новый объект, если вы не вернете другой объект.

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

Давайте посмотрим на шаг № 3:

Он свяжет свойство __proto__ этого нового объекта со свойством prototype функции ...

Это означает, что мы можем поместить любые методы в Player.prototype, и они будут доступны для нашего вновь созданного объекта.

И это именно то, что мы сделали:

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

Кстати, если бы мы не использовали оператор new, JavaScript не выполнял бы эти задачи за нас, мы бы просто изменили или создали некоторые свойства в контексте this. Запомните этот вариант, мы воспользуемся этим трюком, когда будем делать подклассы.

Есть способы убедиться, что функция вызывалась с оператором new:

Опять же, для более подробного объяснения ключевого слова this вы можете прочитать JavaScript - подробное ключевое слово 'this'.

Класс

Если вам не нравится писать фабричные функции вручную или вам не нравится синтаксис функции конструктора или ручная проверка того, была ли функция вызвана с помощью оператора new, JavaScript также предоставляет class (начиная с ES2015). Однако имейте в виду, что классы в основном являются синтаксическим сахаром над функциями, и они сильно отличаются от традиционных классов в других языках, мы все еще используем «прототипное наследование».

Цитата из MDN:

Классы JavaScript, представленные в ECMAScript 2015, в первую очередь представляют собой синтаксический сахар по сравнению с существующим наследованием на основе прототипов в JavaScript. Синтаксис класса не вводит в JavaScript новую объектно-ориентированную модель наследования.

Давайте шаг за шагом преобразуем нашу «конструкторскую функцию» в class:

Объявить класс

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

Создать конструктор

Мы возьмем тело функции-конструктора из предыдущего раздела и создадим с его помощью метод constructor для нашего класса:

Добавить методы в класс

Каждый метод, который мы хотим присоединить к Player.prototype, можно просто объявить как метод класса:

Теперь весь код

Когда мы запускаем код, мы получаем тот же результат, что и раньше:

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

Подклассы - A.K.A наследование

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

Итак, давайте посмотрим, какова наша цель здесь:

  • Мы хотим, чтобы у обычного плеера были методы userName, score и setScore.
  • Нам также нужен платный пользовательский игрок, у которого будет все, что есть у обычного игрока + метод setUserName, но, очевидно, мы не хотим, чтобы у обычного игрока была эта способность.

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

Рассмотрим код ниже:

Мы знаем, что если свойство не находится непосредственно в объекте, механизм будет искать это свойство в связанном объекте (если он существует) через свойство __proto__. Но что будет, если искомой собственности тоже нет? Как мы уже узнали, все объекты имеют свойство __proto__, поэтому механизм будет проверять следующий связанный объект через свойство __proto__, и если свойство, которое мы ищем, отсутствует? ну, я думаю, вы поняли, движок будет продолжать двигаться вверх по цепочке через свойство __proto__, пока не попадет в тупик, то есть нулевую ссылку, которая в основном является Object.prototype.__proto__.

Итак, если мы пройдемся по примеру кода:

  1. double не имеет toString метода ✖️.
  2. Пройдите double.__proto__
  3. double.__proto__ указывает на Function.prototype, который является объектом, содержащим метод toString. Проверить ✔️
  1. double не имеет hasOwnProperty метода ✖️.
  2. Пройдите double.__proto__
  3. double.__proto__ указывает на Function.prototype.
  4. Function.prototype не имеет hasOwnProperty метода ✖️.
  5. Пройдите Function.prototype.__proto__.
  6. Function.prototype.__proto__ указывает на Object.prototype.
  7. Object.prototype - это объект, содержащий метод hasOwnProperty. Проверить ✔️

Вот небольшой анимированный гиф, демонстрирующий процесс:

Теперь вернемся к нашей задаче по созданию платной пользовательской сущности. Мы пойдем еще раз до конца, мы реализуем эту функцию с помощью шаблона «OLOO», «Функции конструктора» и с помощью классов. Таким образом, мы увидим компромиссы для каждого паттерна и особенности.

Итак, давайте перейдем к подклассам. 💪

OLOO - подклассы

Это реализация нашей задачи с помощью шаблона OLOO и фабричной функции:

Это выведет:

Как видите, реализация нашей createPlayer функции не изменилась, но с функцией createPaidPlayer нам потребовалось несколько уловок.

В createPaidPlayer мы используем createPlayer для создания начального нового объекта, поэтому нам не нужно дублировать логику создания нового проигрывателя, но, к сожалению, он связывает наш __proto__ с неправильным объектом, поэтому нам нужно исправить это с помощью метода Object.setPrototypeOf . Мы передаем ему целевой объект (вновь созданный объект, который нам нужно исправить __proto__ указатель), и мы передаем ему правильный объект, на который мы хотим, чтобы он указывал, например, paidPlayerFunctions.

Однако мы еще не закончили, потому что теперь мы разорвали связь с объектом playerFunctions, который содержит метод setScore. Вот почему нам нужно было связать paidPlayerFunctions и playerFunctions, опять же с Object.setPrototypeOf. Таким образом мы убедимся, что наш paidPlayer связан с paidPlayerFunctions, а затем оттуда с playerFunctions.

Это много кода для двухуровневой цепочки, представьте себе хлопот для 3 или 4 уровней цепочки.

Функции конструктора - подклассы

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

И мы должны получить результат, аналогичный предыдущей реализации:

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

Наша первая проблема заключалась в том, как использовать функцию Player, чтобы получить логику создания начального Player. Мы сделали это, вызвав его без оператора new (вопреки всем нашим инстинктам!) И используя метод .call, который позволил нам явно передать ссылку на this, таким образом функция Player не работает как метод конструктора, поэтому она не будет создать новый объект и присвоить его this

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

Еще одна задача, которая стоит перед нами, - связать экземпляр, возвращаемый PaidPlayer, с функциями, которые есть у экземпляров Player, мы сделали это с Object.setPrototypeOf и связали PaidPlayer.prototype с Player.prototype.

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

Класс - Подкласс

С классами мы получаем гораздо больше абстракции, а это значит меньше кода:

И мы получаем тот же результат, что и с функциями конструктора:

Итак, как видите, классы - не что иное, как синтаксический сахар над функциями конструктора. Ну вроде

Помните эту строку из документации:

Классы JavaScript, представленные в ECMAScript 2015, в первую очередь являются синтаксическим сахаром по сравнению с существующим наследованием на основе прототипов в JavaScript ...

Да, в первую очередь.

Когда мы использовали ключевое слово extends, нам нужно было использовать функцию super, почему?

Запомните эту (странную) строчку из раздела «Функции конструктора»:

так что super(userName, score) - это своего рода способ имитировать это.

Что ж, если мы хотим быть немного точнее здесь, под капотом используется новая функция, представленная в ES2015: Reflect.construct.

Цитата из документов:

Статический метод Reflect.construct () действует как оператор new, но как функция. Это эквивалентно вызову новой цели (… args). Он также дает дополнительную возможность указать другой прототип.

Так что нам больше не нужно «взламывать» функции конструктора. В основном "под капотом" super реализовано с Reflect.construct. Также важно отметить, что когда мы extend класс, внутри тела constructor мы не можем использовать this до запуска super(), потому что this еще не инициализирован.

Подведение итогов

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

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

У каждого шаблона есть свои плюсы и минусы:

  • С Object.create нам нужно написать больше кода, но у нас есть более точный контроль над нашими объектами. Хотя выполнять глубокую цепочку уровней становится утомительно.
  • С помощью функций конструктора мы получаем некоторые автоматизированные задачи, выполняемые JavaScript, но синтаксис может выглядеть немного странным. Нам также необходимо убедиться, что наши функции вызываются с ключевым словом new, иначе мы столкнемся с неприятными ошибками. Цепочка на глубоком уровне тоже не так хороша.
  • С классами мы получаем более чистый синтаксис и встроенную проверку того, что он был вызван с помощью оператора new. Классы лучше всего проявляются, когда мы делаем «наследование», мы просто используем ключевое слово extends и вызываем super() вместо того, чтобы прыгать с другими шаблонами. Синтаксис также ближе к другим языкам, и кажется, что его легко изучить. Хотя это также и обратная сторона, потому что, как мы видели, он так отличается от классов в других языках, мы все еще используем старое «Прототипное наследование» с множеством слоев абстракций поверх него.

Надеюсь, вы нашли эту статью полезной. Если у вас есть что добавить, или какие-либо предложения или отзывы, которые я хотел бы услышать о них, вы можете написать мне в Твиттере или написать мне в DM @ sag1v. 🤓

Для получения дополнительных статей вы можете посетить debuggr.io.