Как нам уже хорошо известно, JavaScript поставляется с богатым арсеналом встроенных объектов. Эти объекты обеспечивают некоторую базовую функциональность для работы с текстом, числами, коллекциями данных, датами и многим другим. Однако по мере углубления в этот язык, когда вы уже начинаете реализовывать более интересные и продуманные вещи, возникает желание выйти за рамки возможностей встроенных объектов.
Давайте взглянем на пример, демонстрирующий подобную ситуацию. В нем показано, как мы можем перемешивать содержимое массива:
function shuffle(input) {
for (let i = input.length — 1; i >= 0; i-) {
let randomIndex = Math.floor(Math.random() * (i + 1));
let itemAtIndex = input[randomIndex];
input[randomIndex] = input[i];
input[i] = itemAtIndex;
}
return input;
}
Мы используем функцию shuffle, просто вызвав ее и передав массив, чье содержимое нужно перемешать:
let shuffleArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
shuffle(shuffleArray);
// и результат…
console.log(shuffleArray);
После выполнения этого кода конечным результатом будет перегруппировка содержимого. Такая функциональность весьма полезна. Я бы даже сказал, что слишком полезна. Возможность производить перемешивание должна быть частью объекта Array и являться легко доступной наряду с такими его методами, как push, pop, slice и др.
Если бы функция shuffle была частью объекта array, то мы могли бы с легкостью использовать ее следующим образом:
let shuffleArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
shuffleArray.shuffle();
В этом примере мы расширяем встроенный объект (Array) заданной нами функциональностью (shuffle). В нескольких последующих разделах мы конкретно рассмотрим, как это делается, как работает и почему расширение встроенных объектов является спорным решением.
Поехали!
И снова приветствуем прототип!
Расширение встроенного объекта новой функциональностью звучит сложно, но на деле, как только вы поймете, что нужно сделать, это окажется достаточно просто. Для простоты усвоения этого материала мы рассмотрим комбинацию образца кода и диаграмм с участием дружелюбно настроенного объекта Array:
let tempArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
Если бы мы построили диаграмму всей иерархии объекта tempArray, то выглядела бы она, как показано на рис. 19.1.
Рис. 19.1. Паутина объектов (или лжи!), которые существуют под поверхностью
Слева у нас объект tempArray, являющийся экземпляром Array.prototype, который, в свою очередь, является экземпляром основного Object.prototype. Теперь нам нужно расширить возможности нашего массива функцией shuffle. Это означает, что нужно найти способ внедрить эту функцию в Array.prototype, как показывает рис. 19.2.
Рис. 19.2. Здесь должна поселиться наша функция shuffle!
Здесь мы сталкиваемся с проявлением пресловутой странности JavaScript. У нас нет доступа к коду, формирующему функциональность массива. Мы также не можем найти функцию или объект, формирующие сам Array, и внедрить shuffle в них, как это делается в случае с пользовательским объектом. Наши встроенные объекты, подобные Array, определены в вулканических глубинах браузера, куда ни одно человеческое существо не может попасть. Поэтому здесь нам нужен иной подход.
При этом другом подходе мы тайком прокрадываемся и прикрепляем нужную функциональность к свойству prototype объекта Array. Выглядит это примерно так:
Array.prototype.shuffle = function () {
let input = this;
for (let i = input.length — 1; i >= 0; i-) {
let randomIndex = Math.floor(Math.random() * (i + 1));
let itemAtIndex = input[randomIndex];
input[randomIndex] = input[i];
input[i] = itemAtIndex;
}
return input;
}
Обратите внимание, что наша функция shuffle объявлена в Array.prototype. Как часть этого прикрепления мы внесли небольшое изменение в работу функции. Теперь она не получает аргумент для обращения к массиву, который нужно перемешать:
function shuffle(input) {
.
.
.
.
.
}
Вместо этого, так как отныне функция является частью Array, на этот массив указывает ключевое слово this внутри ее тела:
Array.prototype.shuffle = function () {
let input = this;
.
.
.
.
}
Возвращаясь к предыдущему шагу, как только этот код будет запущен, функция shuffle окажется бок о бок со встроенными методами, которые объект Array выражает через Array.prototype, как показано на рис. 19.3.
Рис. 19.3. Великий успех! Теперь функция shuffle на своем месте
С этого момента, если нам понадобится обратиться к возможностям shuffle, мы можем использовать для этого изначально желаемый подход:
let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
numbers.shuffle();
Самое лучшее в этом то, что создаваемые нами массивы будут также по умолчанию иметь доступ к функциональности shuffle благодаря принципам работы наследования прототипов.
Спорность расширения встроенных объектов
Учитывая, насколько просто расширить функциональность встроенного объекта, объявляя методы и свойства с помощью свойства prototype, легко представить себе, что все обожают такую возможность. Но как выясняется, расширение встроенных объектов отчасти спорно. Причины этого витают рядом.
Вы не контролируете будущее встроенного объекта
Ничто не мешает будущей реализации JavaScript включить собственную версию shuffle, применимую к объектам Array. В таком случае у вас возникнет коллизия, когда ваша версия shuffle окажется в конфликте с браузерной версией shuffle, особенно если их поведение или производительность сильно различаются.
Некоторую функциональность не следует расширять или переопределять
Ничто не мешает вам использовать полученные здесь знания для изменения существующих методов и свойств. Например, в следующем примере я меняю поведение slice:
Array.prototype.slice = function () {
let input = this;
input[0] = "This is an awesome example!";
return input;
}
let tempArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] tempArray.slice();
// и результат будет…
console.log(tempArray);
Несмотря на то что это ужасный пример, он прекрасно показывает, как легко оказалось нарушить существующую функциональность.
Что почитать
Подробное обсуждение этого противоречия ищите на ветке StackOverflow: http://stackoverflow.com/questions/8859828/.
КОРОТКО О ГЛАВНОМ: что же мне делать?
Мой ответ на этот вопрос будет прост: пользуйтесь здравым смыслом! Я обозначил всего лишь два случая из множества, которые люди обсуждают в связи с темами, касающимися расширения встроенных объектов. По большей части возражения имеют реальные основания. Вам же при этом стоит себя спросить: «Относятся ли эти возражения к моему сценарию?» Смею предположить, что нет.
Лично я никогда не имел проблем при расширении встроенных объектов нужной мне функциональностью. Эту функцию перемешивания я написал много лет назад, и ни один браузер по сей день даже близко не реализовал ее альтернативы. При этом я не жалуюсь. Я тестирую всю добавляемую функциональность и убеждаюсь, что она полноценно работает в интересующих меня браузерах, на которые я нацелен. До тех пор пока вы будете проводить обширное тестирование (для одной или двух последних версий наиболее популярных браузеров), наверняка все будет в порядке.
Если же вы беспокоитесь о будущем вашего приложения, называйте свойства или методы таким образом, чтобы их могло использовать только ваше приложение. Например, шансы, что функция Array.prototype.kirupaShuffle будет введена в какой-либо браузер, стремятся к нулю.
Теперь же, когда мы подробно изучили некоторые темы, касающиеся объектов, давайте вернемся к рассмотрению и других типов, с которыми вам предстоит работать, и уже затем будем переходить к действительно потрясным вещам.
Если у вас есть вопросы по расширению объектов или вы просто хотите поговорить о жизни, обращайтесь на форум https://forum.kirupa.com.
Глава 20. Использование классов
Мы уже рассмотрели множество основных аспектов работы с объектами. Мы видели, как они создаются, изучили наследование прототипов и даже взглянули на темное искусство расширения объектов. При этом мы работали на очень низком уровне и были замешаны в процессе изготовления самого объекта. Это здорово для качественного понимания происходящего, но не так здорово, когда в вашем приложении появляется сложный объект. В целях упрощения всего этого в ES6-версии JavaScript появилась поддержка так называемых классов.
Те из вас, у кого есть опыт работы в других объектно ориентированных языках, вероятно, знакомы с этим термином. Если же нет, то не стоит беспокоиться. В мире JavaScript классы не представляют собой ничего особенного. Здесь они не более чем горстка новых ключевых слов и условных конструкций, упрощающих набор команд при работе с объектами. В ближайших разделах мы опробуем все это на себе.
Поехали!
Синтаксис классов и создание объектов
Будем осваивать синтаксис классов дедовским способом — через написание кода. Так как рассмотреть предстоит многое, не будем хвататься за все сразу, а начнем с применения синтаксиса классов при создании объектов. Как вы увидите, здесь замешано множество всего, и нам будет над чем потрудиться.
Создание объекта
Вы можете рассматривать класс как шаблон — шаблон, на который ссылаются объекты при создании. Предположим, что мы хотим создать класс Planet. Максимально простая версия этого класса будет выглядеть так:
class Planet {
}
Мы используем ключевое слово class, сопровождаемое именем, которое мы хотим задать нашему классу. Тело этого класса будет содержаться внутри фигурных скобок { }. Очевидно, что на данный момент класс пуст. Пока это нормально, так как начинаем мы с самого простого.
Для создания объекта на основе этого класса вам всего лишь нужно сделать следующее:
let myPlanet = new Planet();
Мы объявляем имя нашего объекта и используем ключевое слово new для создания (то есть инстанцирования) объекта на основе класса Planet. Рисунок 20.1 демонстрирует наглядно, что именно происходит за кадром.
Рис. 20.1. Внутренний процесс при создании myPlanet
Это представление несколько отличается от того, что мы видели при создании объектов с помощью Object.create(). Разница заключается в создании объекта myPlanet с помощью ключевого слова new. При создании объектов с помощью new происходит следующее:
1. Новый объект имеет тип Planet.
2. [[Prototype]] нашего нового объекта является новой функцией или свойством класса prototype.
3. Выполняется функция-конструктор, которая занимается инициализацией нашего созданного объекта.
Не стану утомлять вас излишними дополнительными деталями, но среди них есть одна важная, с которой мы далее познакомимся. Она связана с так называемым конструктором, упомянутым в пункте 3.
Знакомьтесь с конструктором
Конструктор — это функция (или метод), существующий внутри тела класса. Он отвечает за инициализацию создаваемых объектов, и делает он это, выполняя содержащийся в нем код, во время самого процесса создания. Эта деталь является обязательной. Все классы должны быть оснащены функцией-конструктором. Если у вашего класса таковой не имеется (как у Planet), JavaScript автоматически создаст пустой конструктор за вас.
Теперь давайте определим конструктор для нашего класса Planet. Взгляните на следующую модификацию:
class Planet {
constructor(name, radius) {
this.name = name;
this.radius = radius;
}
}
Для определения конструктора мы используем особое ключевое слово constructor, чтобы создать то, что по сути является функцией. Так как это функция, вы можете, как обычно, указать любые аргументы, которые хотите использовать. В нашем случае в виде аргументов мы указываем значения name и radius и используем их, чтобы установить свойства name и radius в нашем объекте:
class Planet {
constructor(name, radius) {
this.name = name;
this.radius = radius;
}
}
Вы можете совершать гораздо больше (или меньше) интересных действий изнутри конструктора, главное не забывать, что этот код будет выполняться каждый раз, когда мы будем создавать новый объект, используя класс Planet. Кстати говоря, вот как вы можете вызвать класс Planet для создания объекта:
let myPlanet = new Planet("Earth", 6378);
console.log(myPlanet.name); // Earth
Обратите внимание, что два аргумента, которые нам нужно указать в конструкторе, в действительности указаны в самом классе Planet. Когда создается наш объект myPlanet, запускается конструктор и значения name и radius, переданные ранее, устанавливаются в этом объекте. Рисунок 20.2 показывает, как это выглядит.
Рис. 20.2. Наш объект myPlanet содержит свойства name и radius
Хоть мы и изучаем синтаксис class и окружающие его детали, всегда помните, что все это лишь посыпка — изысканный синтаксический сахар, разработанный для облегчения вашей жизни. Если не использовать синтаксис class, то можно сделать так, например:
function Planet(name, radius) {
this.name = name;
this.radius = radius;
};
let myPlanet = new Planet("Earth", 6378);
console.log(myPlanet.name); // Земля
Конечный результат почти что идентичен тому, что мы получили с помощью class. Единственное отличие — в средствах достижения этого результата. Тем не менее не дайте этому сравнению сбить вас с верного пути, так как другие полезные варианты использования синтаксиса class уже не получится столь же легко преобразовать с помощью традиционных подходов, как мы сделали в этом примере.
Что помещается в класс
Объекты class очень похожи на функции, но имеют свои причуды. Один из помещаемых внутрь класса элементов мы уже видели — это особая функция constructor. Помимо нее в него можно поместить только другие функции и методы, а также геттеры и сеттеры. Все. Никаких объявлений и инициализаций переменных не допускается.
Чтобы все это увидеть в действии, давайте добавим функцию getSurfaceArea, которая выводит в консоль площадь нашей планеты. Внесите в код следующие изменения:
class Planet {
constructor(name, radius) {
this.name = name;
this.radius = radius;
}
getSurfaceArea() {
let surfaceArea = 4 * Math.PI * Math.pow(this.radius, 2);
console.log(surfaceArea + " square km!");
return surfaceArea;
}
}
Вызовите getSurfaceArea из созданного объекта, чтобы увидеть ее в деле:
let earth = new Planet("Earth", 6378);
earth.getSurfaceArea();
После выполнения этого кода вы увидите в консоли что-то вроде 511 миллионов квадратных километров. Хорошо. Поскольку мы упомянули, что в тело класса могут быть помещены геттеры и сеттеры, давайте их также добавим. Используем же мы их, чтобы представить гравитацию планеты:
class Planet {
constructor(name, radius) {
this.name = name;
this.radius = radius;
}
getSurfaceArea() {
let surfaceArea = 4 * Math.PI * Math.pow(this.radius, 2);
console.log(surfaceArea + " square km!");
return surfaceArea;
}
set gravity(value) {
console.log("Setting value!");
this._gravity = value;
}
get gravity() {
console.log("Getting value!");
return this._gravity;
}
}
let earth = new Planet("Earth", 6378);
earth.gravity = 9.81;
earth.getSurfaceArea();
console.log(earth.gravity) // 9.81
Вот и все. Такое добавление элементов в тело класса хорошо тем, что они не будут существовать в созданном объекте. Вместо этого они будут находиться в прототипе (Planet.prototype), как показано на рис. 20.3.
Рис. 20.3. Нам не нужно делать ничего особенного, чтобы обратиться к объекту-прототипу
Это очень хорошо, так как нам не нужно, чтобы каждый объект без необходимости носил в себе копию содержимого класса, когда с этим прекрасно справляется совместно используемый экземпляр. Наши геттер и сеттер gravity наряду с функцией getSurfaceArea полностью существуют в прототипе.
Почему функции внутри класса выглядят странно?
Вы могли заметить, что функции внутри класса выглядят несколько необычно. К примеру, в них не достает ключевого слова function. Эта странность (в данном случае) не связана с самими классами. Дело в том, что при определении функций внутри объектов можно использовать упрощенный синтаксис.
Вместо написания, например, этого:
let blah = {
zorb: function() {
// что-то интересное
}
};
вы можете сократить определение функции zorb до следующего:
let blah = {
zorb() {
// что-то интересное
}
};
Именно такую сокращенную форму вы будете встречать и использовать при определении функций внутри тела класса.
Расширение объектов
Последнее, что мы рассмотрим, связано с расширением объектов в мире классов. Чтобы разобраться в этой теме, мы будем работать с совершенно новым типом планеты, известным как Potato Planet (планета Картофель).
Планета Картофель содержит все, что присуще обычной планете, но состоит она полностью из картофеля, в противоположность расплавленным камням и газу, составляющим другие виды планет. Наша задача определить планету Картофель как класс. Ее функциональность будет, по большому счету, отражать представленную в классе Planet, но мы также добавим некоторые дополнительные элементы вроде аргумента potatoType в конструкторе и метода getPotatoType, выводящего в консоль значение potatoType.
Не самым лучшим подходом было бы определить класс Картофеля так:
class PotatoPlanet {
constructor(name, radius, potatoType) {
this.name = name;
this.radius = radius;
this.potatoType = potatoType;
}
getSurfaceArea() {
let surfaceArea = 4 * Math.PI * Math.pow(this.radius, 2);
console.log(surfaceArea + " square km!");
return surfaceArea;
}
getPotatoType() {
var thePotato = this.potatoType.toUpperCase() +"!!1!!!";
console.log(thePotato);
return thePotato;
}
set gravity(value) {
console.log("Setting value!");
this._gravity = value;
}
get gravity() {
return this._gravity;
}
}
У нас есть класс PotatoPlanet, и он содержит не только новые связанные с Картофелем элементы, но также всю функциональность класса Planet. Плохо в этом подходе то, что мы повторяем код. А что, если бы вместо повторения кода у нас была возможность расширить функциональность, предоставляемую нашим классом Planet, функциональностью, необходимой для PotatoPlanet? Такой подход будет однозначно лучше. К счастью, эта возможность у нас есть, и предоставлена она в виде ключевого слова extends. Расширив класс Planet классом PotatoPlanet, мы можем сделать следующее:
class Planet {
constructor(name, radius) {
this.name = name;
this.radius = radius;
}
getSurfaceArea() {
let surfaceArea = 4 * Math.PI * Math.pow(this.radius, 2);
console.log(surfaceArea + " square km!");
return surfaceArea;
}
set gravity(value) {
console.log("Setting value!");
this._gravity = value;
}
get gravity() {
return this._gravity;
}
}
class PotatoPlanet extends Planet {
constructor(name, width, potatoType) {
super(name, width);
this.potatoType = potatoType;
}
getPotatoType() {
let thePotato = this.potatoType.toUpperCase() +"!!1!!!";
console.log(thePotato);
return thePotato;
}
}
Обратите внимание, как мы объявляем класс PotatoPlanet — используем ключевое слово extends и указываем класс, который расширяем, то есть Planet:
class PotatoPlanet extends Planet {
.
.
.
.
}
Здесь нужно помнить кое-что, связанное с constructor. Если мы хотим просто расширить класс и не нуждаемся в изменении конструктора, то можем полностью пропустить определение конструктора в этом классе:
class PotatoPlanet extends Planet {
sayHello() {
console.log("Hello!");
}
}
В нашем же случае, поскольку мы изменяем действия конструктора, добавляя свойство для типа картошки, то мы снова определяем его с одним важным дополнением:
class PotatoPlanet extends Planet {
constructor(name, width) {
super(name, width);
this.potatoType = potatoType;
}
getPotatoType() {
var thePotato = this.potatoType.toUpperCase() +"!!1!!!";
console.log(thePotato);
return thePotato;
}
}
Мы производим явный вызов конструктора родителя (Planet) с помощью ключевого слова super и передачи соответствующих необходимых аргументов. Вызов super обеспечивает срабатывание всей необходимой функциональности части Planet нашего объекта.
Чтобы использовать PotatoPlanet, мы создаем объект и заполняем его свойства или вызываем для него методы так же, как и в случае с простым, не расширенным объектом. Вот пример создания объекта типа PotatoPlanet с именем spudnik:
let spudnik = new PotatoPlanet("Spudnik", 12411, "Russet");
spudnik.gravity = 42.1;
spudnik.getPotatoType();
При этом хорошо то, что spudnik имеет доступ не только к функциональности, определенной нами как часть класса PotatoPlanet, но и всей функциональности, предоставляемой классом Planet, который мы расширяем. Мы можем понять, почему это происходит, еще раз обратившись к нашим прототип-объектным связям (рис. 20.4).
Рис. 20.4. Так выглядит расширение объекта
Если мы проследуем по цепочке прототипов, то от объекта spudnik перейдем к PotatoPlanet.prototype, оттуда — к Planet.prototype, а закончим в Object.prototype. Объект spudnik имеет доступ к любому свойству или методу, определенному в каждом этом прототипе, что и дает ему возможность вызывать эти элементы для Object или Planet, несмотря на то что большая их часть не определена в PotatoPlanet. В этом заключается удивительная мощь расширения объектов.
КОРОТКО О ГЛАВНОМ
Синтаксис класса значительно упрощает работу с объектами. Вы можете уловить отголоски этого в текущей главе, но главное вы увидите позднее. Суть этого синтаксиса в том, что он позволяет нам больше фокусироваться на том, что мы хотим сделать, вместо того чтобы разбираться, как это сделать. Несмотря на то что, работая со свойствами Object.create и prototype, мы получали существенный контроль, этот контроль зачастую был не нужен. Работая с классами, мы размениваем сложность на простоту. И это совсем не плохо, когда простое решение оказывается верным… в большинстве случаев!
Есть вопросы? Не откладывайте. Обращайтесь на форум https://forum.kirupa.com.