Подробный обзор наследования JavaScript и концепций, связанных с его реализацией, с помощью примеров кода и выходных данных консоли. Он предназначен для людей, которые имеют базовые представления о том, как работает JavaScript, и хотят подробно разобраться в наследовании JavaScript. Мы ограничим область действия ECMAScript5, то есть версией до ECMAScript6 (ES2015).

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

Люди часто утверждают, что наследование JavaScript проще для понимания по сравнению с типичными классическими шаблонами наследования, встречающимися в других языках программирования, таких как Java, из-за многословности и сложных концепций, таких как области видимости переменных (private, protected, friend, interface и т. Д.), Найденных в этих языках. Хотя таких концепций в JavaScript нет, он имеет свой собственный набор функций и концепций, которые не всегда легко понять, например this , call , prototype , new , конструкторы функций , и constructor , среди прочего.

До сих пор, будучи фронтенд-разработчиком, я обнаружил, что большая часть моей работы вращается вокруг работы с библиотекой / фреймворком JavaScript вместо собственного JavaScript. Эти библиотеки отлично справляются с абстрагированием этих языковых функций и предоставляют нам свои собственные API, что приводит к более быстрой настройке приложения и упрощает написание кода приложения. Однако обратная сторона этого заключается в том, что отказ от работы с собственным JavaScript может со временем ослабить наше понимание этих концепций - все становится немного туманным. Конечно, мы можем создавать приложения, используя только стандартные API JavaScript, и многие люди предпочитают это. Но большая часть сообщества JavaScript, особенно фронтенд, обычно работает с библиотекой. Чтобы стать лучшим разработчиком, важно хорошо владеть этими концепциями. Сегодня мы постараемся укрепить эту хватку.

Классы

В отличие от других языков программирования, в JavaScript нет языковой конструкции class.

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

ES2015, новая версия JavaScript, вводит ключевое слово class, но это просто синтаксический сахар, покрывающий конструкторы функций. «Конструктор функции» и «класс» могут использоваться как взаимозаменяемые, поскольку они передают одну и ту же концепцию в JavaScript. Мы немного увидим, почему такие функции называются функциями конструкторами. Давайте создадим наш первый класс:

function Person(name) {
  this.name = name;
}
var person = new Person('John');

Хотя синтаксис очень прост, здесь задействовано несколько концепций. Попробуем их понять.

новый, конструктор

Сначала мы создаем функцию Person (это еще не конструктор функции!). Первая буква написана с заглавной буквы просто для обозначения того, что она будет использоваться в качестве конструктора функции. Затем мы используем оператор new для создания экземпляра Person. Посмотрим, что делает new под капотом:

  • Он устанавливает значение this для создаваемого экземпляра. Обычно this указывает на объект, внутри которого вызывается функция . Если newне выполнить этот шаг, this будет указывать на объект, в контексте которого выполняется инструкция var person = new Person('John'). Подробнее про this можно прочитать здесь.
  • Неявно возвращает this из функции, то есть нам не нужно было писать return this; в конце функции. Обратите внимание, что этот неявный возврат происходит только потому, что мы явно не указали, что возвращать.
  • Устанавливает constructor из person в Person. На этом этапе Person становится конструктором функции person. Если вы зарегистрируете person.constructor, вы увидите следующий результат:

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

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

Мы можем узнать конструктор функции / класс любого объекта, запросив object.constructor.

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

Давайте проверим встроенные конструкторы функций некоторых распространенных типов данных в JavaScript, чтобы прояснить ситуацию:

Некоторые из вас могут спросить: «Эй, мы не использовали new здесь для создания экземпляра. Итак, почему это было необходимо в примере Person , а не здесь? ». Причина в том, что все эти экземпляры создаются с использованием встроенных конструкторов функций. JavaScript предоставляет нам более простые API для инициализации таких экземпляров и не требует от нас использования new для их создания. Но это не значит, что мы не можем использовать new для их создания:

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

В приведенном выше примере вы ожидаете, что a и b будут равны, но это не так. Почему? Потому что:

new всегда создает экземпляр типа данных object. Созданный экземпляр, независимо от того, какой тип данных он хранит, следует всем правилам, которые был бы обычным объектом.

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

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

JavaScript следует прототипному шаблону наследования. Давайте разберемся в этом, последовательно рассмотрев все задействованные концепции.

прототип

Обычный вариант использования при создании классов - добавление к нему методов. Посмотрим, как это сделать:

function Person(name) {
  this.name = name;
  this.printName = function() {
    console.log('Hi! My name is ' + this.name);
  }
}
var john = new Person('John');
john.printName();               // Hi! My name is John
var jane = new Person('Jane');
jane.printName();               // Hi! My name is Jane

Проблема с этим подходом состоит в том, что для каждого нового экземпляра Person создается новая копия printName, которая живет в экземпляре и тратит ненужную память. На самом деле нам не нужна новая копия printName каждый раз при создании экземпляра, поскольку он постоянен и ведет себя одинаково для всех экземпляров. Чтобы исправить это, мы используем ключевое слово prototype.

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

function Person(name) {
  this.name = name;
}
Person.prototype.printName = function() {
  console.log('Hi! My name is ' + this.name);
}
var john = new Person('John');
john.printName();               // Hi! My name is John
var jane = new Person('Jane');
jane.printName();               // Hi! My name is Jane

Теперь printName создается в памяти только один раз при определении Person
и живет в Person.prototype. Помимо экономии памяти, еще одним большим преимуществом добавления методов и переменных в prototype является то, что всякий раз, когда мы добавляем что-либо в prototype, это мгновенно становится доступным для всех созданных экземпляров, независимо от того, когда они были созданы. Например:

function Person(name) {
  this.name = name;
}
var person = new Person('John');
Person.prototype.printName = function() {
  console.log('Hi! My name is ' + this.name);
}
person.printName();       // Hi! My name is John

Если бы мы определили printName на Person напрямую без использования prototype в приведенном выше примере, то объект person не смог бы получить доступ к printName, потому что person был создан до того, как printName был добавлен в Person.
Обычно мы не знаем всех методов / констант, которые нам нужно добавить к классу во время определения класса. prototype
позволяет нам добавлять материалы в класс позже и делать их доступными во всех экземплярах, даже если они были созданы в прошлом.

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

Когда мы вызывали person.printName, printName не существовало непосредственно на person. Итак, как JavaScript узнал, что он будет существовать на Person.prototype? На person должно быть что-то, чтобы указать ему использовать Person.prototype.printName. Это свойство __proto__.

Все объекты в JavaScript имеют свойство __proto__, называемое «dunder proto» и часто представляемое как [[Prototype]], которое содержит ссылку на prototype класса, из которого был создан объект.

В приведенном выше примере, когда мы сделали person.printName, на самом деле произошло то, что JavaScript сначала проверил, существует ли printName на person напрямую. Этого не произошло, поэтому person.__proto__ использовался для доступа к prototype из Person, где был найден printName. Вот как выглядит person.__proto__:

Что, если printName не существует на прототипе Person? Если бы это было так, то был бы запрошен Person.prototype.__proto__ и так далее. Этот последовательный поиск снизу вверх для оценки свойства объекта называется цепочкой прототипов.

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

Итак, насколько высока эта цепочка прототипов? Все классы в JavaScript унаследованы от класса Object. Каждый раз, когда мы ищем свойство объекта, Object.prototype - это степень, в которой идет процесс поиска. Другими словами:

Object - это базовый класс для всех классов в Javascript, поэтому Object.prototype является корнем цепочки прототипов.

Если это свойство не существует даже на Object.prototype, считается, что оно не существует.

Давайте посмотрим на простой реальный пример цепочки прототипов, который большинство из нас использовали бы:

var arr = new Array(1, 2, 3);
arr.forEach(function(item) {
  console.log(item);
});

При выполнении arr.forEach JavaScript сначала проверяет, существует ли forEach непосредственно на arr. Это не так, поэтому он проверяет arr.__proto__ внутренне, что указывает на Array.prototype, поскольку Array является конструктором функции arr. Он находит там forEach и вызывает его. toString - еще один пример метода, который обнаруживается после обхода прототипной цепочки. Он живет на Object.prototype.

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

Создание подклассов

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

// base class
function Loader(isLoading) {
  this.isLoading = isLoading;
}
Loader.prototype.showLoader = function() {
  console.log('loader visible!');
}
Loader.prototype.hideLoader = function() {
  console.log('loader hidden');
}
// subclass
function ProgressBarLoader(isLoading, loadedPercent) {
  Loader.call(this, isLoading);
  this.loadedPercent = loadedPercent || 0;
}
ProgressBarLoader.prototype = Object.create(Loader.prototype);
ProgressBarLoader.prototype.constructor = ProgressBarLoader;
ProgressBarLoader.prototype.showLoadedPercent = function() {
  console.log('progress %: ', this.loadedPercent);
}
ProgressBarLoader.prototype.setPercent = function(percent) {
  this.loadedPercent = percent;
}
var progressBarLoader = new ProgressBarLoader(false, 0);
progressBarLoader.showLoader();         // loader visible
progressBarLoader.hideLoader();         // loader hidden 
progressBarLoader.showLoadedPercent();  // progress %: 0

Здесь много чего происходит. Давайте рассмотрим их один за другим:

вызов

call - это функция, которая помогает нам вызывать метод с аргументами и устанавливать для его контекста this явно указанный объект. Этот объект передается как первый аргумент call.

Когда мы создаем объект ProgressBarLoader, Loader вызывается в контексте нашегоProgressBarLoader объекта, и свойство isLoading добавляется к объекту. Таким образом, свойства Loader добавляются (или унаследованы, заимствованы) к любому экземпляру, созданному с использованием ProgressBarLoader.
Следует отметить, что эти свойства и методы добавляются во время создания экземпляра класса, а не во время определения.

После выполнения оператора call к объекту добавляется свойство loadedPercent, которое является эксклюзивным для ProgressBarLoader.

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

Вызов родительских методов из подкласса

Рассмотрим пример, в котором два класса, которые имеют отношение родитель-потомок, имеют одно и то же имя метода, но разные реализации одного и того же метода:

function Parent() {
}
Parent.prototype.printMe = function() {
  console.log('This is parent class!');
}
function Child() {
  Parent.call(this);
}
Child.prototype.printMe = function() {
  console.log('This is child class!');
}
var child = new Child();
child.printMe();        // This is child class!

Child.prototype.printMe вызывается, когда printMe вызывается для Childобъекта. Что, если бы мы хотели вызвать Parent’s printMe для объекта Child? Этого можно добиться, используя call:

Parent.prototype.printMe.call(child);   // This is parent class!

Мы напрямую вызываем Parent.prototype.printMe. Но он вызывается с помощью call, что помогает нам сообщить JavaScript, что printMe должен вызываться в контексте объекта Child.

Это работает, но неудобно писать Parent.prototype.printMe.call каждый раз, когда мы хотим вызвать метод родительского класса. Чтобы исправить это, давайте добавим в Child метод, который предоставляет удобный API для вызова printMe метода Parent:

Child.prototype.printParentMe = function() {
  Parent.prototype.printMe.call(this);
}
var child = new Child();
child.printParentMe();      // This is parent class!

Object.create

Object.create создает новый объект из существующего объекта, при этом новый объект имеет доступ к свойствам существующего объекта.

Например:

var loader = {
   showLoader: function() {
     console.log('loader is visible!');
   },
   hideLoader: function() {
     console.log('loader is hidden!');
   }
};
var progressBarLoader = Object.create(Loader);
progressBarLoader.showLoader();    // loader is visible!
progressBarLoader.hideLoader();    // loader is hidden!

Итак, что Object.create делает под капотом? Копирует все из loader и добавляет в progressBarLoader? Нет. Он устанавливает __proto__ из progressBarLoader на loader!

Когда мы вызываем progressBarLoader.showLoader, JavaScript проверяет, существует ли showLoader на progressBarLoader. Это не так, поэтому он идет вверх по цепочке прототипов, используя progressBarLoader 's __proto__, находит там метод и вызывает его.

В примере подкласса Object.create используется следующим образом:

ProgressBarLoader.prototype = Object.create(Loader.prototype);

Посмотрим, что бы произошло, если бы этого утверждения не было:

Когда вызывается progressBarLoader.showLoader, JavaScript сначала проверяет метод на progressBarLoader. Он не находит его там и проверяет его на прототипе progressBarLoader. А каков прототип progressBarLoader? Его ProgressBarLoader и showLoader не существует на его прототипе. Поэтому мы используем Object.create, чтобы добавить Loader.prototype к progressBarLoader __proto__. Вот как это выглядит:

Теперь мы можем добавлять что-то в ProgressBarLoader.prototype, не затрагивая методы Loader.prototype, которые были добавлены с помощью Object.create:

ProgressBarLoader.prototype.showLoadedPercent = function() {
  console.log('progress %: ', this.loadedPercent);
}
ProgressBarLoader.prototype.setPercent = function(percent) {
  this.loadedPercent = percent;
}

Subclass.prototype.constructor

Давайте разберемся в значении:

ProgressBarLoader.prototype.constructor = ProgressBarLoader;

Предположим, что этого утверждения не было в нашем примере. На что будет указывать ProgressBarLoader.prototype.constructor? Обычно, когда мы создаем функцию, скажем, fn, тогда fn.prototype.constructor указывает на сам fn. Но ProgressBarLoader.prototype.constructor указывает на Loader, потому что в заявлении:

ProgressBarLoader.prototype = Object.create(Loader.prototype);

мы устанавливаем ProgressBarLoader.prototype на ссылку Loader.prototype, а Loader.prototype.constructor это Loader! Почему это проблема? Эта ветка Переполнение стека прекрасно это объясняет. Поэтому мы устанавливаем ProgressBarLoader.prototype.constructor на ProgressBarLoader.

Примечание. Поскольку ProgressBarLoader считается дочерним классом Loader, можно предположить, что ProgressBarLoader.constructor будет равно Loader. Однако это не. Он по-прежнему равен Function, как и Loader. Это связано с тем, что, как мы узнали, свойство constructor указывает на функцию конструктор, а конструктор функции ProgressBarLoader по-прежнему Function.

Итак, в JavaScript:

создание подкласса - это просто заимствование методов и свойств из базового класса.

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

Использование слова «прототип»

Когда кто-то использует слово «прототип» в предложении, оно может означать две вещи - prototype ключевое слово или __proto__. Способ выяснить, о каком из двух идет речь, - это понять контекст, в котором он используется. Если мы говорим о функции, «прототип» относится к prototypekeyword. Если предметом обсуждения является объект, то «прототип» относится к __proto__.

Завершение!

Прежде всего, спасибо, что нашли время прочитать это!

Концепции, которые мы обсуждали сегодня, сложны, и обычно требуется время, чтобы понять их, особенно людям, начинающим с JavaScript. Так что, если вы все еще не уверены в них на 100%, не волнуйтесь! Это нормально. Это не твоя вина. Скорее всего, потребуется несколько чтений из нескольких источников - и немного реального опыта написания кода JavaScript - чтобы приблизиться к 100.

Если у вас есть вопросы или отзывы, пишите в комментариях! И если вы чувствуете, что статья вам чем-то помогла, пожалуйста, похлопайте за нее, чтобы выразить свою признательность! 😄