Подробный обзор наследования 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__
. Способ выяснить, о каком из двух идет речь, - это понять контекст, в котором он используется. Если мы говорим о функции, «прототип» относится к prototype
keyword. Если предметом обсуждения является объект, то «прототип» относится к __proto__
.
Завершение!
Прежде всего, спасибо, что нашли время прочитать это!
Концепции, которые мы обсуждали сегодня, сложны, и обычно требуется время, чтобы понять их, особенно людям, начинающим с JavaScript. Так что, если вы все еще не уверены в них на 100%, не волнуйтесь! Это нормально. Это не твоя вина. Скорее всего, потребуется несколько чтений из нескольких источников - и немного реального опыта написания кода JavaScript - чтобы приблизиться к 100.
Если у вас есть вопросы или отзывы, пишите в комментариях! И если вы чувствуете, что статья вам чем-то помогла, пожалуйста, похлопайте за нее, чтобы выразить свою признательность! 😄