При написании кода возможны две ситуации. В первой вас будет терзать любопытство: а запустится ли только что написанный вами код? Во второй вы будете знать, что код выполняется, но при этом выполняется некорректно. Что-то не так… Но где же…
В обеих ситуациях вам понадобится дополнительное вˆидение действий кода. Неизменным подходом для получения такого вˆидения является использование метода alert:
let myButton = document.querySelector("#myButton");
myButton.addEventListener("click", doSomething, false);
function doSomething(e) {
alert("Is this working?");
}
Использование метода alert, в общем-то, неплохой вариант, и он прекрасно работает в простых ситуациях. Но когда код усложняется, такой подход теряет свою эффективность. Как новичка вас, скорее всего, взбесят постоянные закрытия огромного количества диалоговых окон, появляющихся в процессе выполнения кода. Кроме того, потребуется удобный способ сохранять получаемые сообщения, а динамичная смена диалоговых окон alert изрядно затрудняет любые попытки журналирования.
В текущем уроке мы рассмотрим одно из величайших изобретений, помогающее нам лучше понять, что именно делает наш код. Если говорить точнее, мы приступаем к изучению консоли.
Поехали!
Даже если вы считаете, что пишете идеальный JS-код, то все равно будете проводить много времени в так называемой консоли. Если ранее вам не приходилось ею пользоваться, поясню, что она относится к инструментам разработчика и служит для вывода на экран текста и прочих данных, с которыми (иногда) можно взаимодействовать.
Выглядит она примерно так, как на рис. 11.1.
Рис. 11.1. Знакомство с консолью
Консоль помогает решать многие задачи на очень высоком уровне:
• читать сообщения, задав в коде их журналирование и вывод на экран;
• модифицировать состояние приложения, устанавливая (или переписывая) переменные и значения;
• просматривать значение элемента DOM, используемый стиль или доступный код, находящийся в области видимости;
• использовать ее как виртуальный редактор кода и записывать/выполнять определенный код просто ради интереса.
В этом разделе мы не будем заострять внимание на всех возможностях консоли. Вместо этого плавно, шаг за шагом, научимся использовать ее для простого отображения сообщений. Остальные невероятные возможности консоли мы успеем рассмотреть позже, поэтому не переживайте.
Первое, что мы сделаем, — запустим саму консоль. Она относится к браузерным инструментам разработки, доступ к которым вы можете получить либо через меню браузера, либо с помощью горячих клавиш. Находясь в браузере, нажмите комбинацию Ctrl + Shift + I, если работаете в Windows, или Cmd + Alt + I, работая на Mac.
В зависимости от платформы браузера каждый из инструментов разработки будет выглядеть несколько иначе. Нам же важно найти вкладку консоли и открыть ее.
На рис. 11.2. показана консоль Chrome.
Рис. 11.2. Консоль Chrome
В Safari консоль будет выглядеть, как на рис. 11.3.
Рис. 11.3. Консоль Safari
Консоль Firefox изображена на рис. 11.4.
Рис. 11.4. Консоль Firefox
Консоль Microsoft Edge показана на рис. 11.5.
Рис. 11.5. Консоль Edge
Хочу отметить, что не имеет значения, какой браузер вы используете. Консоль выглядит и работает почти одинаково в любом из них. Просто вызовите ее в своем любимом браузере и приготовьтесь использовать, пока читаете следующие разделы.
Сейчас вы можете просто прочесть несколько следующих разделов и изучить целую кучу информации, и пальцем не пошевельнув. Если такой вариант вам по душе, тогда пропустите этот раздел и переходите к следующему.
Но если вы хотите немного потренироваться и увидеть некоторые из особенностей консоли, создайте HTML-документ и добавьте в него следующий код HTML, CSS и JavaScript:
let myButton = document.querySelector("#myButton");
myButton.addEventListener("click", doSomething, false);
function doSomething(e) {
alert("Is this working?");
}
Перед нами очень простая HTML-страница с кнопкой, по которой вы можете кликнуть. Когда вы это сделаете, появится диалоговое окно (такое же было описано ранее). Далее мы изменим этот пример, чтобы опробовать некоторые из возможностей консоли.
Первое, что мы сделаем, — прикажем нашей консоли вывести информацию на экран. Это аналогично тому, что мы ранее делали с помощью инструкции alert, и осуществляется почти так же легко. Ключом же в данном случае выступает API консоли. Этот протокол содержит множество свойств и методов, предоставляющих различные способы вывода данных на экран. Основным и, вероятно, наиболее популярным является метод log.
В самом базовом варианте использование этого метода выглядит так:
console.log("Look, ma! I'm logging stuff.")
Вы вызываете его через объект console и передаете ему текст, который хотите отобразить. Чтобы увидеть этот процесс в действии, можно заменить alert в нашем примере на следующее:
function doSomething(e) {
console.log("Is this working?");
}
После запуска кода щелкните по кнопке click me и загляните в консоль. Если все сработало как надо, вы увидите в ней текст «Is this working?» («Работает?»), как показано на рис. 11.6:
Рис. 11.6. Кнопка click me выведена на экран
Если вы продолжите щелкать по кнопке, то увидите, что количество экземпляров «Is this working?» увеличивается, как показано на рис. 11.7.
Рис. 11.7. Каждое нажатие на кнопку отображается в консоли
То, как это выглядит, будет зависеть от используемых инструментов разработки. Вы можете просто увидеть счетчик слева от сообщения, который будет увеличиваться, как показано на скриншоте. Вы также увидите повторение текста «Is this working?» в каждой строке. Не переживайте, если то, что вы видите в своей консоли, немного отличается от скриншотов. Важно то, что ваш вызов console.log работает и журналирует сообщения, которые вы видите в консоли. Более того, эти сообщения не только для чтения. Вы можете их выбирать, копировать и даже распечатать и повесить в рамочке на стене.
Теперь, когда вы познакомились с основами, углубимся немного в тему. При использовании консоли ваши возможности не ограничены выводом только предопределенного текста. Например, распространенный случай — это вывод на экран значения чего-либо существующего только в качестве вычисления выражения или обращения к значению. Чтобы наглядно увидеть, что я имею в виду, внесите следующие изменения в вашу функцию doSomething:
function doSomething(e) {
console.log("We clicked on: " + e.target.id);
}
Тем самым мы даем команду консоли отобразить текст «We clicked on» в дополнение к значению id элемента, по которому мы щелкнули. Если вы выполните предпросмотр внесенных изменений в браузере и щелкните по кнопке click me, то в консоли увидите результат, соответствующий рис. 11.8.
Рис. 11.8. Отображен id кнопки, по которой мы кликнули
Значение id кнопки отображается в дополнение к предопределенному тексту. Конечно, вывод значения id элемента не самая потрясающая идея, но на деле вы вольны выводить практически все, что может быть представлено в виде текста. Так что это мощная возможность!
Настало время рассмотреть метод log. Объект console предоставляет в наше распоряжение методы warn и error, которые позволяют выводить сообщения в формате предупреждений и ошибок, как это показано на рис. 11.9 соответственно.
Рис. 11.9. Мы можем делать предупреждения и указывать на ошибки… как босс!
Способ использования этих двух методов отличается от того, как вы использовали метод log. Просто передайте в них то, что нужно вывести на экран. В следующем листинге приведен пример их использования:
let counter = 0;
function doSomething(e) {
counter++;
console.log("Button clicked " + counter + " times!");
if (counter == 3) {
showMore();
}
}
function showMore() {
console.warn("This is a warning!");
console.error("This is an error!");
}
Когда этот код выполняется и известная нам кнопка нажимается трижды, происходит вызов функции showMore. В этой функции мы расположили только сообщения консоли о предупреждении и об ошибке:
function showMore() {
console.warn("This is a warning!");
console.error("This is an error!");
}
Ошибки и предупреждения предоставляют еще одну крутую возможность, выходящую за пределы простого отображения и очень отличающую их от скучного аналога log. Вы можете развернуть их в консоли и просмотреть полную трассировку стека функций, выполненных кодом, до момента встречи с ними (рис. 11.10).
Рис. 11.10. Просмотр деталей ошибок
Это полезно при работе с большими частями кода, где есть множество ответвлений. Методы warn и error являются прекрасным способом понять те извилистые пути, которые прошел код, чтобы оказаться в том или ином конечном состоянии.
КОРОТКО О ГЛАВНОМ
Консоль предоставляет вам один из лучших инструментов для понимания действий кода. Отображение сообщений — это только часть предлагаемых консолью возможностей. Помимо рассмотрения одного лишь примера с выводом сообщений нам предстоит освоить еще больше информации. Мы также узнаем о других возможностях консоли, но даже эти несколько техник, изученных к настоящему моменту, позволят вам значительно продвинуться при поиске и устранении багов.
Если у вас появились любые вопросы по этой теме, задавайте их на форуме https://forum.kirupa.com. Мы с остальными разработчиками с удовольствием поможем их решить!
Пора заняться серьезными делами. Суперсерьезными! В последних нескольких главах мы изучили разные значения, в том числе: строки (текст), числа, логические значения (true и false), функции и другие встроенные элементы JavaScript.
Вот некоторые примеры, чтобы освежить память:
let someText = "hello, world!";
let count = 50;
let isActive = true;
В отличие от других языков, JavaScript упрощает определение и использование этих встроенных элементов. Нам даже не требуется составлять план их будущего использования. Но несмотря на всю простоту, существует множество скрытых деталей. И их знание важно, так как не только облегчает понимание кода, но и ускоряет выявление причин его неисправностей.
Как вы могли предположить, встроенные элементы — не самый удачный способ описания различных значений, используемых в JS. Существует более официальное имя для таких значений, а именно типы. В этой главе мы начнем плавное знакомство с их сутью и назначением.
Поехали!
Поскольку я постоянно что-нибудь ем (или думаю, что бы съесть), то постараюсь объяснить загадочный мир типов на более простом примере — мире пиццы.
Если вы давненько ее не ели, то напомню, как она выглядит:
Конечно же, пицца не появляется в таком виде из ниоткуда. Она создается из простых и сложных ингредиентов:
Простые ингредиенты легко выявить. Это грибы и халапеньо. Причина, по которой мы называет их простыми, в том, что их нельзя разложить на составные части:
Они не изготавливаются и не составляются из других компонентов.
К сложным же ингредиентам относятся сыр, соус, основа из теста и пеперони. Сложными их делает то, что они сделаны из других ингредиентов:
К сожалению, такие ингредиенты, как сыр и пеперони, не бывают простыми. Для их приготовления нужно смешивать, жарить и добавлять различные компоненты. Кроме того, их получение не ограничивается смешиванием простых ингредиентов, но может также требовать совмещения сложных.
Все, что мы узнали о пицце в предыдущем разделе, было неспроста. Описание простых и сложных ингредиентов вполне применимо к типам в JavaScript. Каждый отдельно взятый ингредиент можно рассматривать как аналог типа, который вы можете использовать (рис. 12.1).
Рис. 12.1. Список простых типов JavaScript
Подобно сыру, соусу, пеперони, грибам и бекону в нашей пицце, типами в JavaScript являются string (строка), number (число), boolean (логическое значение), null (пустой), undefined (не определен), bigint (целочисленные значения), symbol (символы) и Object (объект). С некоторыми из этих типов вы уже можете быть знакомы, с некоторыми — нет. Подробнее мы будем рассматривать их в дальнейшем, сейчас же в табл. 12.1 вы можете посмотреть краткое описание их назначения.
Как мы видим, каждый тип имеет свое уникальное назначение. При этом они, аналогично ингредиентам пиццы, также подразделяются на простые и сложные. Только в терминологии JavaScript простые и сложные типы называются примитивами (примитивные типы) и объектами (объектные типы) соответственно.
К примитивным типам относятся string, number, boolean, null, bigint, symbol и undefined. Любые значения, попадающие в их юрисдикцию, не подлежат делению на части. Они являются халапеньо и грибами в мире JavaScript. Примитивы достаточно легко определять и оформлять в понятные элементы. В них нет глубины, и при встрече с ними мы, как правило, получаем то, что видим изначально.
Табл. 12.1. Типы
Тип
Назначение
string
Основная структура для работы с текстом
number
Позволяет работать с числами
boolean
Используется там, где нужно получить true или false
null
Предстает цифровым эквивалентом ничего
undefined
Похожий по смыслу на null. Возвращается, когда значение подразумевается, но на деле отсутствует. Например, если вы объявляете переменную, но ничего ей не присваиваете
bigint
Позволяет работать с крайне большими и малыми числами, выходящими за пределы возможностей обычного типа number
symbol
Нечто уникальное и неизменяемое, то, что при желании можно использовать как идентификатор свойств объекта
Object
Выступает в роли оболочки для других типов, включая другие объекты
Объектные же типы, представленные как Object в вышеприведенной таблице, оказываются более загадочными. Поэтому, прежде чем перейти к описанию деталей всех перечисленных типов, стоит отдельно рассмотреть, чем именно являются объекты.
Принцип объектов в таких языках программирования, как JavaScript, прекрасно отражает их аналогию из реальной жизни, в которой мы все буквально окружены объектами. К ним относятся ваш компьютер, книга на полке, картошка (спорно), будильник, плакат, заказанный на eBay, и т. д. Продолжать можно бесконечно.
Некоторые объекты вроде пресс-папье малофункциональны и могут долго бездействовать.
Другие объекты, вроде телевизора, уже выходят за рамки простого существования и выполняют множество задач:
Обычный телевизор получает сигнал, позволяет вам включать его и выключать, щелкать каналы, регулировать громкость и прочее.
Здесь важно понять, что объекты имеют разную форму, размер и назначение. Несмотря на эти отличия, на верхнем уровне они все одинаковы и представляют собой абстракцию. Они дают возможность пользоваться ими, не задаваясь вопросом об их внутреннем устройстве. Даже простейшие объекты скрывают в себе определенный уровень сложности, о котором можно не париться.
Например, не важно, что именно происходит внутри телека, как спаяны провода или какой клей использовался для соединения деталей. Все это не имеет значения. Все, что вас интересует, так это чтобы телевизор выполнял свое предназначение. Он должен исправно переключать каналы, позволять регулировать громкость и пр. Остальное — лишние заморочки.
В принципе, объект можно рассматривать как черный ящик. Существует ряд предопределенных/описанных действий, которые он совершает. Увидеть же, как он это делает, достаточно непросто. На деле вас это и не интересует до тех пор, пока он делает все как надо. Мы изменим свое представление об этом позже, когда поучимся создавать внутренности объекта, а пока насладимся простотой этого мира.
Помимо встроенных типов, перечисленных ранее, в JS также изначально присутствуют предопределенные объекты. Эти объекты позволяют работать с чем угодно, включая наборы данных, даты, текст и числа. В табл. 12.2 приводится аналогичный предыдущему список, описывающий их назначения:
Табл. 12.2. Объекты
Тип
Назначение
Array
Помогает хранить, извлекать и манипулировать наборами данных
Boolean
Служит оболочкой примитива Boolean, а также работает посредством значений true и false
Date
Упрощает работу с датами и их представление
Function
Позволяет вызывать заданный код
Math
Умник среди типов, расширяющий возможности работы с числами
Number
Служит оболочкой примитива number
RegExp
Предоставляет богатые возможности сопоставления текстовых шаблонов
String
Служит оболочкой примитива string
Использование встроенных объектов несколько отличается от использования примитивов. Каждый объект в этом плане по-своему особенный. Подробное пояснение всех этих особенностей использования я отложу на потом, а здесь приведу короткий фрагмент кода c комментарием, который покажет возможные варианты:
// массив
let names = ["Jerry", "Elaine", "George", "Kramer"];
let alsoNames = new Array("Dennis", "Frank", "Dee", "Mac");
// округленное число
let roundNumber = Math.round("3.14");
// текущая дата
let today = new Date();
// объект boolean
let booleanObject = new Boolean(true);
// бесконечность
let unquantifiablyBigNumber = Number.POSITIVE_INFINITY;
// объект string
let hello = new String("Hello!");
Вас может несколько озадачить то, что примитивы string, boolean, symbol, bigint и number могут существовать и в форме объектов. Внешне эта объектная форма выглядит очень похожей на примитивную. Вот пример:
let movie = "Pulp Fiction";
let movieObj = new String("Pulp Fiction");
console.log(movie);
console.log(movieObj);
При выводе обоих вариантах вы увидите одинаковый результат. Тем не менее внутренне movie и movieObj весьма различны. Первый буквально является примитивом типа string, а второй имеет тип Object. Это ведет к интересному (а иногда и непонятному) поведению, о котором я постепенно расскажу в процессе изучения встроенных типов.
КОРОТКО О ГЛАВНОМ
Если вам кажется, что все оборвалось на самом интересном месте, то это вполне нормально. Главный вывод здесь в том, что примитивы составляют большинство основных типов, которые вы будете использовать. Объекты несколько сложнее и состоят из примитивов или других объектов. Мы узнаем обо всем этом больше, когда начнем углубляться в тему. Помимо прочего, мы также узнали имена встроенных типов и некоторые присущие им особенности.
В последующих главах мы глубже изучим все эти типы, а также связанные с их использованием нюансы. Рассматривайте эту главу как плавный разгон, после которого вы резко влетите на рельсы безумных американских горок.
Давайте представим, что вы хотите составить список на листке бумаги. Назовем его продукты. Теперь запишите в нем пронумерованный список, начинающийся с нуля, и перечислите все, что вам нужно (рис. 13.1).
Рис. 13.1. Список продуктов
Написав простой список, вы получили пример массива из реальной жизни. Листок бумаги, проименованный как продукты, это и есть ваш массив. Предметы же, которые вы хотите купить, — это значения массива.
В этом уроке вы не только узнаете, какие продукты я предпочитаю покупать, но и познакомитесь с очень распространенным типом — массивом.
Поехали!
Сейчас для создания массивов крутые чуваки используют открывающиеся и закрывающиеся квадратные скобки. Ниже приведена переменная groceries (продукты), инициализированная как пустой массив:
let groceries = [];
Такой скобочный способ создания массива больше известен как литеральная нотация массива.
Как правило, вы будете создавать массив, изначально содержащий определенные элементы. Для этого просто поместите нужные элементы в скобки и разделите их запятыми:
let groceries = ["Milk", "Eggs", "Frosted Flakes", "Salami", "Juice"];
Обратите внимание, что теперь массив содержит Milk (молоко), Eggs (яйца), Frosted Flakes (глазированные хлопья), Salami (салями) и Juice (сок). Считаю необходимым напомнить о важности запятых.
Теперь, когда вы научились объявлять массив, давайте взглянем на то, как его можно использовать для хранения данных и работы с ними.
Одна из прелестей массивов в том, что вы имеете легкий доступ не только к ним самим, но и к их значениям, аналогично выделению одного из продуктов в вашем списке (рис. 13.2).
Рис. 13.2. Массивы позволяют выборочно обращаться к отдельным элементам
Для этого вам достаточно знать простую процедуру обращения к отдельному элементу.
Внутри массива каждому элементу присвоен номер, начиная с нуля. На рис. 13.2 Milk имеет значение 0, Eggs — 1, FrostedFlakes соответствует значение 2 и т. д. Формально эти номера называются значением индекса (индексами).
В данном случае наш массив groceries объявлен следующим образом:
let groceries = ["Milk", "Eggs", "Frosted Flakes", "Salami", "Juice"];
Если мне понадобится обратиться к одному из элементов, то все, что потребуется, — это передать значение его индекса:
groceries[1]
Значение индекса передается массиву внутри квадратных скобок. В текущем примере мы обращаемся к значению Eggs, так как именно этому элементу соответствует позиция индекса 1. Если передать 2, то вернется FrostedFlakes. Вы можете продолжать передавать значения индекса, пока они не закончатся.
Диапазон чисел, которые вы можете использовать в качестве значений индекса, на одно меньше, чем длина самого массива. Причина в том, что индексы начинаются с 0. Если в массиве есть пять элементов, то попытка отобразить grocery[6] или grocery[5] приведет к появлению сообщения undefined.
Идем дальше. В большинстве реальных сценариев вам понадобится программно перебирать весь массив вместо обращения к каждому элементу отдельно.
Для осуществления этого вы можете использовать цикл for:
for (let i = 0; i < groceries.length; i++) {
let item = groceries[i];
}
Помните, что диапазон цикла начинается с 0 и заканчивается на одно значение раньше полной длины массива (возвращаемой как свойство length). Все работает именно так по уже описанной мной причине — значения индекса начинаются с 0 и заканчиваются на одно значение раньше, чем возвращаемая длина массива. При этом свойство length возвращает точное число элементов.
Ваши массивы будут редко сохранять свое изначальное состояние, так как вы, скорее всего, будете добавлять в них элементы. Для этого используется метод push:
groceries.push("Cookies");
Метод push вызывается непосредственно для массива, при этом в него передаются добавляемые данные. В итоге вновь добавленные элементы всегда оказываются в конце массива.
Например, если выполнить этот код для изначального массива, вы увидите, что элемент Cookies (печенье) добавлен в его конец (рис. 13.3).
Рис. 13.3. Теперь массив расширен добавленным в конец элементом Cookies
Если же вы хотите добавить данные в начало, используйте метод unshift:
groceries.unshift("Bananas");
При добавлении данных в начало массива значение индекса каждого из существующих в нем элементов увеличивается с учетом вновь появившихся данных (рис. 13.4).
Рис. 13.4. Только что добавленный элемент вставлен в начало
Причина в том, что первый элемент массива всегда будет иметь значение индекса 0. Поэтому элемент, изначально занимающий позицию значения 0, вынужденно смещается, смещая и все следующие за ним элементы, освобождая тем самым место для добавляемых данных.
При использовании методы push и unshift помимо добавления элементов также возвращают новую длину массива:
console.log(groceries.push("Cookies")); // возвращает 6
Я не уверен, полезно ли это, но на всякий случай имейте это в виду.
Для удаления можно использовать методы pop и shift. Pop удаляет последний элемент и возвращает его:
let lastItem = groceries.pop();
Метод shift делает то же самое, но с обратной стороны массива, то есть вместо удаления и возвращения последнего элемента он проделывает это с первым:
let firstItem = groceries.shift();
При удалении элемента из начала массива позиции индексов остальных уменьшаются на 1, заполняя тем самым появившийся пропуск (рис. 13.5).
Рис. 13.5. Что происходит при удалении элементов из массива
Обратите внимание, что при добавлении элементов с помощью unshift или push значение, возвращаемое при вызове этих методов, является новой длиной массива. Но при использовании методов pop или shift происходит не то же самое. В данном случае при удалении элементов значение, возвращаемое при вызове метода, является самим удаляемым элементом.
Для поиска элементов внутри массива существует несколько методов: indexOf, lastIndexOf, includes, find, findIndex и filter. Во избежание усложнения мы пока что сконцентрируемся на indexOf и lastIndexOf. Работа этих двух индексов заключается в сканировании массива и возвращении индекса совпадающего элемента.
Метод indexOf возвращает первый найденный индекс искомого элемента:
let groceries =["Milk", "Eggs", "Frosted Flakes", "Salami", "Juice"];
let resultIndex = groceries.indexOf("Eggs",0);
console.log(resultIndex); // 1
Обратите внимание, что переменная resultIndex содержит результат вызова indexOf для массива groceries. Для использования indexOf я передаю ему искомый элемент вместе с индексом, с которого следует начать:
groceries.indexOf("Eggs", 0);
В данном случае indexOf вернет значение 1.
Метод lastIndexOf похож на indexOf в использовании, но отличается тем, что возвращает при обнаружении элемента. Если indexOf находит первый индекс искомого элемента, то lastIndexOf находит и возвращает последний индекс этого элемента.
Если же искомый элемент в массиве отсутствует, оба этих метода возвращают -1.
Последнее, что мы рассмотрим, — это слияние двух раздельных массивов для создания нового. Предположим, у вас есть два массива good (хорошие) и bad (плохие):
let good = ["Mario", "Luigi", "Kirby", "Yoshi"];
let bad = ["Bowser", "Koopa Troopa", "Goomba"];
Чтобы совместить их, используйте метод concat для массива, который вы хотите расширить, и передайте в него второй массив в виде аргумента. В итоге будет возвращен новый массив, содержащий и good, и bad:
let goodAndBad = good.concat(bad);
console.log(goodAndBad);
В этом примере метод concat возвращает новый массив, поэтому переменная goodAndBad становится массивом, содержащим результат произведенной конкатенации. Первыми в новом массиве идут элементы good, а затем элементы bad.
До сих пор мы рассматривали различные способы добавления элементов, их удаления и другие счетные операции. Помимо этого, массивы предлагают простые способы управления содержащимися в них данными. Эти простые способы представлены методами map (отображение), reduce (сокращение) и filter (фильтрация).
Прежде чем говорить о map, reduce и filter и предлагаемом ими удобстве обращения с данными, давайте рассмотрим не самый удобный подход. При этом подходе вы традиционно используете цикл for и отслеживаете свое местонахождение в массиве, испытывая при этом, мягко говоря, не самые приятные чувства.
Для наглядности давайте рассмотрим следующий массив имен:
let names = ["marge", "homer", "bart", "lisa", "maggie"];
Этот соответствующим образом названный массив names содержит список имен, написанных в нижнем регистре. Мы же хотим исправить это, сделав первую букву каждого из них заглавной. С помощью цикла for это можно сделать так:
let names = ["marge", "homer", "bart", "lisa", "maggie"];
let newNames = [];
for (let i = 0; i < names.length; i++) {
let name = names[i];
let firstLetter = name.charAt(0). toUpperCase();
newNames.push(firstLetter + name.slice(1));
}
console.log(newNames);
Обратите внимание, что мы перебираем каждый элемент, делаем первую букву заглавной и добавляем исправленное имя в новый массив newNames. Здесь нет ничего магического или сложного, но вы будете часто брать элементы массива, изменять их (или обращаться к ним) и возвращать новый массив с измененными данными. Это достаточно тривиальная задача, где задействуется много рутинного повторяющегося кода. В больших кодовых базах разбор происходящего в цикле добавляет ненужные хлопоты. Вот почему были введены методы map, filter и reduce. С их помощью вы получаете все возможности цикла for без ненужных побочных эффектов и лишнего кода. Кому бы это не понравилось?
Начнем с метода map, который мы используем для модификации всех элементов массива во что-либо другое, представленное в виде нового массива (рис. 13.6).
Рис. 13.6. Оригинальный и новый массивы
Используется map следующим образом:
let newArray = originalArray.map(someFunction);
Эта единственная строка выглядит приятно и располагающе, но изнутри является весьма сложной. Давайте с этим разберемся. Работает метод map так: вы вызываете его для массива, на который хотите воздействовать (originalArray), и передаете ему функцию (someFunction) в качестве аргумента. Функция будет выполняться для каждого элемента массива, то есть вы изначально сможете написать код для изменения всех этих элементов на ваше усмотрение. В конечном итоге вы получите новый массив, содержащий данные, полученные после выполнения функции someFunction для элементов оригинального массива. Звучит просто, не правда ли?
Теперь, вооружившись map, давайте вернемся к нашей предыдущей задаче по изменению первых букв имен в массиве на заглавные. Сначала взглянем на весь код целиком, а затем рассмотрим важные детали.
let names = ["marge", "homer", "bart", "lisa", "maggie"];
function capitalizeItUp(item) {
let firstLetter = item.charAt(0). toUpperCase();
return firstLetter + item.slice(1);
}
let newNames = names.map(capitalizeItUp);
console.log(newNames);
Разберемся, как этот код работает. Нас интересует функция capitalizeItUp, переданная в виде аргумента методу map. Эта функция выполняется для каждого элемента, и стоит обратить внимание, что текущий элемент передается ей в качестве аргумента. Для ссылки на аргумент текущего элемента вы можете использовать любое имя на ваш выбор. Мы ссылаемся на этот аргумент с помощью банального item:
function capitalizeItUp(item) {
let firstLetter = item.charAt(0). toUpperCase();
return firstLetter + item.slice(1);
}
Внутри этой функции мы можем написать любой код для нужного изменения текущего элемента массива. Единственное, что остается сделать, — это вернуть значение элемента нового массива:
function capitalizeItUp(item) {
let firstLetter = item.charAt(0). toUpperCase();
return firstLetter + item.slice(1);
}
Вот и все. После выполнения этого кода map возвращает новый массив, в котором все элементы имеют заглавные буквы и расположены на соответствующих местах. Исходный массив остается неизмененным, имейте это в виду.
Функции обратных вызовов
Наша функция capitalizeItUp также известна как функция обратного вызова. Такие функции подразумевают два действия:
• передачу в качестве аргумента другой функции;
• вызов из другой функции.
Вы будете встречать ссылки на функции обратных вызовов постоянно. Например, когда мы вскоре начнем рассматривать методы filter и reduce. Если вы слышите о них впервые, то теперь будете иметь о них лучшее представление. Если же вы были знакомы с этими функциями ранее, тем лучше для вас.
При использовании массивов вы будете часто фильтровать (то есть удалять) элементы на основе заданного критерия (рис. 13.7).
Рис. 13.7. Уменьшение количества элементов
Например, у нас есть массив чисел:
let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
В данный момент в нем есть и четные, и нечетные числа. Предположим, что нам надо проигнорировать все нечетные и просмотреть только четные. Этого можно добиться с помощью метода filter, отфильтровав все нечетные числа, чтобы остались только нужные нам четные.
Используется метод filter аналогично методу map. Он получает один аргумент — функцию обратного вызова, а эта функция, в свою очередь, определяет, какие элементы массива отфильтровать. Это легче понять, взглянув на код:
let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
let evenNumbers = numbers.filter(function (item) {
return (item % 2 == 0);
});
console.log(evenNumbers);
Мы создаем новый массив evenNumbers, который будет содержать результат выполнения метода filter для массива numbers. Содержимым этого массива будут четные числа благодаря нашей функции обратного вызова, проверяющей каждый элемент, чтобы узнать, будет ли результат item % 2 (то есть будет ли остаток при делении на 2) равен 0. Если функция вернет true, то элемент будет отправлен в отфильтрованный массив. Если же вернется false, элемент будет проигнорирован.
Здесь стоит заметить, что наша функция обратного вызова не является явно именованной, как функция capitalizeItUp в предыдущем примере. Она является анонимной, но это не мешает ей выполнять свою работу. Вы будете часто встречать функции обратного вызова в анонимной форме, поэтому стоит знать такой способ их определения.
Последним мы рассмотрим метод reduce. Он достаточно странный. В случаях с методами map и filter мы начинали с массива, имеющего один набор значений, а заканчивали другим массивом с другим набором значений. Используя метод reduce, мы по-прежнему будем начинать с массива. А вот в конце будем получать всего одно значение (рис. 13.8).
Рис. 13.8. От множества к одному
Здесь для прояснения происходящего необходим пример.
Давайте еще раз используем массив чисел из предыдущего раздела:
let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
Мы хотим сложить все значения. В этом и есть смысл метода reduce, когда все значения массива сужаются в один элемент. Взгляните на этот код:
let total = numbers.reduce(function(total, current) {
return total + current;
}, 0);
console.log(total);
Мы вызываем reduce для массива чисел и передаем в него два аргумента:
• функцию обратного вызова;
• начальное значение.
Начинаем суммирование с начального значения 0, а функция отвечает за добавление каждого элемента массива. В отличие от предыдущих примеров, где функции получали только текущий элемент массива, функция для метода reduce задействуется в большей степени. В данном случае ей приходится иметь дело с двумя аргументами:
• первый содержит итоговое значение, полученное в результате всех произведенных на этот момент действий;
• второй — это текущий элемент массива.
Используя эти два аргумента, вы можете легко создавать различные сценарии, задействующие отслеживание чего-либо. В нашем примере, поскольку нам просто нужна сумма всех элементов массива, мы складываем total со значением current. Итоговым результатом будет 31.
Для методов map и filter в наших функциях обратных вызовов мы определяли только один аргумент, представляющий текущий элемент массива. Для метода reduce мы определяли два аргумента, представлявших итоговое значение и текущий элемент. Помимо этого, функции обратных вызовов имеют два опциональных аргумента, которые вы также можете определить:
• индекс текущего элемента массива;
• массив, для которого вызывается map, filter или reduce.
Для методов map и filter эти аргументы стали бы вторым и третьим. Для reduce они бы оказались третьим и четвертым. Вы можете никогда не столкнуться с необходимостью определять эти опциональные аргументы, но если они все же вам понадобятся, знайте, где их искать.
Мы почти закончили. Давайте взглянем на пример, где показана работа метода reduce с нечисленными значениями:
let words = ["Where", "do", "you", "want", "to", "go", "today?"];
let phrase = words.reduce(function (total, current, index) {
if (index == 0) {
return current;
} else {
return total + " " + current;
}
}, "");
console.log(phrase);
Здесь совмещается текстовое содержимое массива words (слов), чтобы создать значение, которое будет выглядеть как Where do you want to go today? (Куда ты хочешь пойти сегодня?) Обратите внимание, что происходит в функции обратного вызова. Помимо совмещения каждого элемента в одну фразу мы определяем опциональный третий аргумент, представляющий индекс нашего текущего элемента. Мы используем этот индекс для отдельного случая с первым словом, чтобы определить, нужен перед ним пробел или нет.
Как показали последние несколько разделов, методы map, filter и reduce существенно упрощают работу с массивами. Но они проявляют себя и еще в одной огромной области, которая известна как функциональное программирование. Функциональное программирование — это способ написания кода, где вы используете функции, которые:
• могут работать внутри других функций;
• избегают совместного использования и изменения состояния;
• возвращают один и тот же вывод для одного и того же ввода.
Есть и другие мелочи, которые можно было бы здесь перечислить, но для начала хватит и этого. Вы уже видели работу принципов функционального программирования в функциях обратных вызовов. Эти функции идеально подходят под три перечисленных критерия, поскольку могут быть добавлены в любую ситуацию или исключены из нее до тех пор, пока аргументы будут работать. Они не изменяют никакое состояние и полноценно работают внутри методов map, filter и reduce. Функциональное программирование — это занятная тема, требующая гораздо более тщательного рассмотрения. Поэтому пока оставим все как есть, а подробным изучением этой темы займемся позднее.
КОРОТКО О ГЛАВНОМ
Пожалуй, это все, что следует знать про массивы, так как именно для этого вы их и будете использовать чаще всего. По крайней мере, теперь вы точно сможете создать с их помощью список продуктов.
Дополнительные ресурсы и примеры:
• Перемешивание массива: http://bit.ly/kirupaArrayShuffle
• Выбор произвольного элемента массива: http://bit.ly/kirupaRandomItemArray
• Удаление повторов из массива: http://bit.ly/kirupaRemoveDuplicates
• Хеш-таблицы против массивов: href="http://bit.ly/kirupaHvA
Будучи людьми, мы постоянно взаимодействуем со словами — произносим, пишем и также прибегаем к их использованию при написании программ. Так уж вышло, что JavaScript тоже привязан к словам. Буквы и всяческие символы, составляющие наш с вами язык, имеют в JS свое официальное имя — строки. Строки в JavaScript — не что иное, как наборы знаков. Несмотря на то что звучит это занудно, умение обращаться к этим знакам и манипулировать ими является необходимым навыком. Этому и будет посвящен этот урок.
Поехали!
Работать со строками в коде легко. При этом нам просто нужно заключать их в одинарные или двойные кавычки. Вот некоторые примеры:
let text = "this is some text";
let moreText = 'I am in single quotes!';
console.log("this is some more text");
Помимо простого перечисления строк мы нередко будем их совмещать. Это легко делается с помощью оператора +:
let initial = "hello";
console.log(initial + " world!");
console.log("I can also " + "do this!");
Во всех этих примерах мы видим строку. Единственная причина, по которой я указываю на столь очевидный факт, в том, что когда мы видим содержимое строк так же буквально, как сейчас, эти строки правильнее называть строчные литералы. Итоговая структура при этом не меняется и по-прежнему является примитивным типом строка (как один из простых ингредиентов пиццы в предыдущей главе).
Рисунок 14.1 показывает визуальное представление строк text и moreText.
Рис. 14.1. Визуализация строк
У нас есть всего две переменные, указывающие на литеральные куски текста. Больше здесь ничего не происходит. Если вам интересно, зачем я вообще представил визуализацию столь очевидного, то стоит отметить, что визуализации существенно усложнятся, когда мы перейдем на территорию Object. В этой главе вы это отчасти увидите.
Тем не менее все это не так уж важно… пока что. Единственное, что важно помнить, — это то, что необходимо заключать строчные литералы в кавычки (") или апострофы ('), тем самым обособляя их как отдельный участок текста. Если этого не сделать, то ваш код, скорее всего, просто не запустится.
На этом основы заканчиваются. Самое же интересное начнется при использовании всего спектра функциональности JS для работы со строками. Мы перейдем к рассмотрению этой и другой информации в ближайших разделах.
Когда мы работаем со строками, то реализация объекта String предполагает множество свойств, обычно упрощающих работу с текстом. В последующих разделах вместо скучного изучения каждого из этих свойств я сфокусируюсь на наиболее важных, которые будут актуальны в контексте ваших задач.
Несмотря на то что строка выглядит как единый элемент, на деле она состоит из набора знаков. Мы можем обращаться к каждому из них несколькими способами. Наиболее распространено использование массивоподобной скобочной нотации, в которую передается число, соответствующее индексу знака:
let vowels = "aeiou";
console.log(vowels[2]);
В этом примере мы увидим знак i, так как именно этот элемент находится под индексом 2. Для наглядного представления происходящего рассмотрите рис. 14.2.
Рис. 14.2. Гласные отображены в виде индексов
Когда дело касается индекса, стоит помнить, что его позиция в JavaScript начинается с 0. Именно поэтому позиция нашего индекса 2, но порядковый номер действительной позиции элемента — 3. Вы к этому привыкнете в процессе работы с JavaScript и прочими языками, в чьих названиях не содержатся слова Visual и Basic, что подразумевало бы начало отсчета индексов с 1.
Переходим далее. Мы можем обращаться ко всем знакам строки с помощью цикла, перебирающего их индексы. Началом цикла будет 0, а завершение будет определяться длиной самой строки. Длина (то есть количество знаков) возвращается свойством length.
Рассмотрим это на примере из предыдущего раздела:
let vowels = "aeiou";
for (let i = 0; i < vowels.length; i++) {
console.log(vowels[i]);
}
Несмотря на то что мы можем редко использовать циклы для строк, достаточно распространено использование свойства length для получения числа знаков строки.
Также существует альтернатива массиво-скобочной нотации, известная как метод charAt. Он возвращает знак согласно определенному индексу:
let vowels = "aeiou";
console.log(vowels.charAt(2));
В итоге мы получаем то же, что и в случае с описанной ранее массивоподобной нотацией. Я бы рекомендовал использовать этот метод, только если вы работаете для устаревших версий браузеров вроде Internet Explorer 7.
Погодите, что?!
Если вам интересно, где строковые примитивы имеют возможность обратиться к свойствам, доступным только для строковых объектов, потерпите до следующей главы. Там мы рассмотрим это более подробно.
Для совмещения двух строк мы можем просто использовать операторы + или +=, складывая их как обычный набор чисел:
let stringA = "I am a simple string.";
let stringB = "I am a simple string, too!";
console.log(stringA + " " + stringB);
Обратите внимание, что в третьей строке мы складываем stringA со stringB. Между ними мы определяем пустое пространство (" "), чтобы обеспечить разделяющий их пробел. Вы можете смешивать и сопоставлять строчные литералы со строчными примитивами и строчными объектами, получая при этом все тот же совмещенный текст.
К примеру, вот рабочий вариант:
let textA = "Please";
let textB = new String("stop!");
let combined = textA + " make it " + textB;
console.log(combined);
Несмотря на все это смешение, тип переменной combined будет простым строчным примитивом.
Для совмещения строк мы также можем использовать метод concat. Его можно вызывать из любой строки, определив порядок строчных примитивов, литералов и объектов, которые мы хотим склеить в единую мегастроку:
let foo = "I really";
let blah = "why anybody would";
let blarg = "do this";
let result = foo.concat(" don't know", " ", blah, " ", blarg);
console.log(result);
В большинстве случаев для этой цели можете просто использовать подход с + и +=. Он быстрее, чем метод concat. А если нет разницы, то почему бы не повысить скорость кода?
Иногда мы заинтересованы в определенном отрезке знаков отдельной части строки. Для удовлетворения такого интереса служат два метода — slice и substr. Предположим, у нас есть следующая строка:
let theBigString = "Pulp Fiction is an awesome movie!";
Давайте с ней немного поработаем.
Метод slice позволяет нам определять начальную и конечную позиции интересующей части строки, которую мы хотим извлечь:
let theBigString = "Pulp Fiction is an awesome movie!";
console.log(theBigString.slice(5, 12));
В этом примере мы извлекаем знаки между индексами 5 и 12. В итоге будет возвращено слово Fiction.
Значения начальной и конечной позиций не обязательно должны быть положительными. Если вы определите отрицательное значение конечной точки, то она будет вычислена обратным отсчетом от конца строки:
let theBigString = "Pulp Fiction is an awesome movie!";
console.log(theBigString.slice(0, -6));
То же касается и определения начальной точки, которая при отрицательном значении вычисляется также с конца строки:
let theBigString = "Pulp Fiction is an awesome movie!";
console.log(theBigString.slice(-14, -7));
Мы только что рассмотрели три варианта использования метода slice. Я всегда использовал только первый способ с положительными значениями начала и конца нужного отрезка, и вы, вероятно, последуете тем же путем.
Следующий подход для разделения строк — метод substr. Он также работает с двумя аргументами:
let newString = substr(start, length);
Первый из них является числом, определяющим стартовую позицию, а второй представляет число, задающее длину итоговой подстроки. Станет понятнее, если взглянуть на следующие примеры:
let theBigString = "Pulp Fiction is an awesome movie!";
console.log(theBigString.substr(0, 4)); // Pulp
Наша подстрока начинается с нулевой позиции и отсчитывает четыре знака вперед. Поэтому возвращается Pulp. Если мы захотим извлечь слово Fiction, то код будет выглядеть так:
let theBigString = "Pulp Fiction is an awesome movie!";
console.log(theBigString.substr(5, 7)); // Fiction
Если мы не определим длину, возвращаемая подстрока будет содержать знаки от стартовой позиции и до конца:
let theBigString = "Pulp Fiction is an awesome movie!";
console.log(theBigString.substr(5)); // Fiction is an awesome movie!
Есть еще несколько вариаций передачи значений в метод substr, но эти являются основными.
То, что вы можете объединить, можно и разделить на части. Я уверен, что это изречение одного из мудрецов. Еще одним способом разделения строк является метод split. Вызов этого метода для строки возвращает массив подстрок. Точки же разделения изначальной строки на подстроки мы определяем знаком или регулярным выражением (RegExp).
Давайте взглянем на простой пример:
let inspirationalQuote = "That which you can concatenate, you can
also split apart.";
let splitWords = inspirationalQuote.split(" ");
console.log(splitWords.length); // 10
Здесь мы разделяем текст inspirationalQuote в местах пробелов. При каждой встрече со знаком пробела оставшаяся позади часть массива удаляется и становится элементом возвращаемого массива.
Вот другой пример:
let days = "Monday,Tuesday,Wednesday,Thursday,Friday, Saturday,Sunday";
let splitWords = days.split(",");
console.log(splitWords[6]); // Sunday
У нас есть переменная days, содержащая строку дней, разделенных запятыми. Если мы хотим отделить каждый день, то можем использовать метод split с запятой в качестве разделителя. Конечным результатом будет массив из семи элементов, каждый из которых будет представлять день недели из оригинальной строки.
Вас удивит, как часто вы будете использовать метод split для разрыва последовательности знаков, которая может быть как простым предложением, так и сложными данными, возвращаемыми веб-службой.
Если нам вдруг понадобится найти знак(и) в строке, мы можем использовать методы indexOf, lastIndexOf и match. Сперва рассмотрим indexOf.
Этот метод получает искомый нами знак(и) в качестве аргумента. Если он его (их) находит, то возвращает позицию индекса строки, где происходит первое включение. Если совпадений не обнаруживается, indexOf возвращает -1. Посмотрим на пример:
let question = "I wonder what the pigs did to make these birds so
angry?";
console.log(question.indexOf("pigs")); // 18
Мы пытаемся выяснить, есть ли pigs (свиньи) в нашей строке. Так как искомый элемент существует, метод indexOf сообщает нам, что первое включение этого слова встречается в 18-м индексе. Если же мы ищем что-либо несуществующее вроде буквы z в следующем примере, возвращается значение -1:
let question = "I wonder what the pigs did to make these birds so
angry?";
console.log(question.indexOf("z")); // -1
Метод lastIndexOf очень похож на indexOf. Как можно догадаться по его имени, он возвращает индекс последнего включения искомого элемента:
let question = "How much wood could a woodchuck chuck if
a woodchuck could chuck wood?";
console.log(question.lastIndexOf("wood")); // 65
Вы можете определить еще один аргумент для описанных методов indexOf и lastIndexOf. Помимо указания искомых знаков вы можете также определить индекс строки, с которого нужно начать поиск:
let question = "How much wood could a woodchuck chuck if
a woodchuck could chuck wood?";
console.log(question.indexOf("wood", 30)); // 43
Последнее, что стоит упомянуть об indexOf и lastIndexOf, — это то, что вы можете сопоставлять любой экземпляр знаков, существующих в строке. При этом не важно, делаете вы это для целых слов или того, что ищете в виде более крупного набора знаков. Обязательно это учитывайте.
Прежде чем подвести итог, давайте рассмотрим метод match. В его случае у вас уже меньше контроля. Этот метод в качестве аргумента получает regexp:
let phrase = "There are 3 little pigs.";
let regexp = /[0–9]/;
let numbers = phrase.match(regexp);
console.log(numbers[0]); // 3
Здесь также возвращается массив совпадающих подстрок, поэтому будет уместно применить свои навыки работы с массивами, чтобы налегке продолжить работу с результатом. Работу с регулярными выражениями мы с вами затронем позже.
Под конец давайте рассмотрим то, что не потребует сложных объяснений. Для изменения регистра строки мы можем использовать методы с соответствующими именами, а именно toUpperCase для подъема в верхний регистр и toLowerCase для приведения в нижний. Взгляните на пример:
let phrase = "My name is Bond. James Bond.";
console.log(phrase.toUpperCase()); // MY NAME IS BOND. JAMES BOND.
console.log(phrase.toLowerCase()); // my name is bond. james bond.
Я же говорил, что это очень легко!
КОРОТКО О ГЛАВНОМ
Строки — это один из немногих основных типов данных в JavaScript, и вы только что видели хороший обзор многих возможностей, которые они предоставляют. Без ответа я оставил всего один случай, когда примитивы загадочным образом получают свойства, обычно характерные исключительно для объектов. Мы рассмотрим этот вопрос в следующей главе.
Некоторые дополнительные ресурсы и примеры:
• Devowelizer (функция, исключающая гласные буквы): http://bit.ly/kirupaDeVowelize
• Капитализация первой буквы строки: http://bit.ly/kirupaCapLetter
• 10 способов развернуть строку: http://bit.ly/kirupaWaysToReverseString
Если у вас есть какие-либо вопросы, касающиеся строк… жизни или JavaScript в целом, обращайтесь за ответами на форум https://forum.kirupa.com.
В предыдущей главе «Строки» и отчасти в главе «О пицце, типах, примитивах и объектах» мы мельком затронули нечто сбивающее с толку. Я несколько раз отмечал, что примитивы очень просты и понятны. В отличие от объектов, они не содержат свойств, которые позволяют обыгрывать значения интересными (и не очень) способами. Действительно, при наглядном рассмотрении всех возможностей использования строк кажется, что наши примитивы таят в себе некую темную сторону:
let greeting = "Hi, everybody!!!";
let shout = greeting.toUpperCase(); // Откуда появился toUpperCase?
Как видно из приведенного фрагмента, переменная greeting, содержащая примитивное значение в форме текста, судя по всему, имеет доступ к методу toUpperCase. Как такое вообще возможно? Откуда появился этот метод? Почему мы здесь? Ответы на подобные непростые вопросы и составят львиную долю информации этой главы.
Поехали!
Так как строки весьма интересны и в некотором смысле игривы (прямо как золотистый ретривер), их легко выбрать в качестве главного виновника этой путаницы с примитивами и объектами. Но как в итоге выясняется, в их банду также входят и многие другие примитивные типы. Таблица 15.1 показывает популярные встроенные типы Object, включая большинство виновников (Symbol и BigInt отсиживаются в стороне), которые, помимо прочего, замешаны и в связях с примитивами:
Табл. 15.1. Объектные типы, включая те, что представляют примитивы
Тип
Назначение
Array
Помогает хранить, извлекать и управлять наборами данных
Boolean
Выступает в роли обертки для примитива boolean; а также работает с помощью true и false
Date
Упрощает представление дат и работу с ними
Function
Позволяет вызывать заданный код
Math
Умник среди типов, расширяющий возможности работы с числами
Number
Выступает в качестве обертки для примитива number
RegExp
Предоставляет множество возможностей для сопоставления шаблонов в тексте
String
Выступает в качестве обертки для примитива string
Всегда при работе с логическими значениями, числами или строчными примитивами у нас есть доступ к свойствам, представленным их объектными эквивалентами. В ближайших разделах вы увидите, что конкретно при этом происходит.
Как и говорилось в предыдущих главах, обычно мы используем строки в форме литералов:
let primitiveText = "Homer Simpson";
Как видно из таблицы, строки тоже могут быть использованы как объекты. Есть несколько способов создания нового объекта, но в случае создания объекта для типа вроде строки чаще всего используется ключевое слово new, сопровождаемое String:
let name = new String("Batman");
String в данной ситуации не просто обычное слово. Оно представляет собой так называемую функцию-конструктор, которая используется исключительно для создания новых объектов. Аналогично наличию нескольких способов создания объектов есть несколько способов создания объектов String. Я же считаю, что достаточно знать один способ, который не следует использовать для их создания.
Как бы то ни было, главное отличие между примитивной и объектной формами строки — это существенное количество лишнего багажа, присущего объекту. На рис. 15.1 — визуальное представление нашего объекта String с именем name.
Рис. 15.1. Углубленный вид объекта String
Переменная name содержит указатель на текст "Homer Simpson". Нам также доступно все множество свойств и методов, присущих объекту String, включая те, что вы уже использовали ранее (indexOf, toUpperCase и пр.).
Мы сделаем обширный обзор этого визуального представления позднее, когда будем рассматривать объекты подробнее. Пока не беспокойтесь, если не совсем понимаете ее суть. Просто знайте, что объектная форма любого примитива несет в себе богатый функционал.
Давайте вернемся к сбивающему с толку моменту. Наша строка — это примитив. Как может примитивный тип позволить обращаться к его свойствам? Дело в том, что JavaScript — весьма странный язык. Предположим, у нас есть следующая строка:
let game = "Dragon Age: Origins";
Очевидно, что переменная game — это строковый примитив, присвоенный конкретному тексту. Если мы захотим обратиться к length этого текста, то сделаем следующее:
let game = "Dragon Age: Origins";
console.log(game.length);
Как часть вычисления game.length JavaScript преобразует строковый примитив в объект. На короткое время наш приземленный примитив станет прекрасным объектом, чтобы мы могли выяснить его длину. Здесь следует помнить, что все это временно. Так как этот временный объект не имеет основы и ни к чему не привязан, то после выполнения своей миссии он удаляется и остается лишь результат вычисления length (число), а переменная game по-прежнему является строковым примитивом.
Такая трансформация происходит только с примитивами. Если мы создадим объект String, то он так и останется навсегда объектом. Представим следующий пример:
let gameObject = new String("Dragon Age: Origins");
В данном случае переменная gameObject очень четко указывает на что-то имеющее тип Object. Эта переменная продолжит указывать на тип Object, пока вы не измените строку или сделаете что-нибудь, что приведет к изменению ссылки. Способность примитива трансформироваться в объект, а затем обратно в примитив является уникальной. Объекты в такой глупости не участвуют.
Вы можете легко убедиться в сказанном мной, проверив тип ваших собственных данных. Это можно сделать с помощью ключевого слова typeof. В следующем примере я использую его для подтверждения всего, что только что сказал:
let game = "Dragon Age: Origins";
console.log("Length is: " + game.length);
let gameObject = new String("Dragon Age: Origins");
console.log(typeof game); // строка
console.log(typeof game.length); // число
console.log(typeof gameObject); // объект
Думаю, вы по достоинству оцените эти знания.
КОРОТКО О ГЛАВНОМ
Надеюсь, это краткое объяснение поможет вам осознать, почему примитивы при необходимости ведут себя как объекты. Здесь может возникнуть другой вопрос, а именно: «Зачем вообще кто-то мог решить разработать язык с такими странностями?» В конце концов, если примитив превращается в объект при необходимости, то почему бы ему так и не оставаться им навсегда? Ответ на этот вопрос будет связан с потреблением памяти.
Как я уже недавно упоминал, объектная форма объекта примитива несет на себе существенно больше багажа, чем обычный примитив. В итоге это требует дополнительных ресурсов для поддержания функциональности. Решением в этом случае послужил компромисс. Все литеральные значения вроде текста, чисел и логических значений хранятся в виде примитивов, если изначально таковыми создаются и/или используются. Только при необходимости они преобразовываются в соответствующие им формы Object. Чтобы обеспечить минимальное потребление памяти приложением, эти преобразованные объекты быстро удаляются (сборщиком мусора), как только выполнят свою задачу.
Есть вопросы? Задавайте их на форуме https://forum.kirupa.com и получайте развернутые оперативные ответы от единомышленников.
В JavaScript приходится часто иметь дело с числами. Даже если вы не будете работать непосредственно с числами, то будете косвенно сталкиваться с ними во многих базовых и не только задачах вроде ведения подсчета чего-либо, работы с массивами и т. д.
В текущей главе я представлю вам числа на примере их использования для выполнения многих привычных задач. Наряду с этим мы несколько выйдем за рамки основ, чтобы обширнее представить себе интересные и полезные возможности, связанными с ними.
Поехали!
Используются числа очень просто. Ниже приведен простой пример, в котором я объявляю переменную stooges, инициализированную как число 3:
let stooges = 3;
Вот и все. Ничего сложного. Если вы захотите использовать более сложные числа, то просто используйте их, как обычно:
let pi = 3.14159;
let color = 0xFF;
let massOfEarth = 5.9742e+24;
В этом примере вы видите десятичное, шестнадцатеричное, а также очень большое значение, в котором используется экспонента. В итоге ваш браузер автоматически сделает то, что потребуется. Имейте в виду, что при этом также могут быть использованы и отрицательные значения. Для этого достаточно добавить знак минуса (-) перед числом:
let temperature = -42;
В этом кратком разделе вы познакомились с тем, как чаще всего будете использовать числа. В течение следующих разделов мы углубимся в тему и рассмотрим некоторые интересные возможности, применимые к ним.
Числа в JavaScript
Вам любопытно, почему работать с числами так легко? Отвечаю! JavaScript не богат на численные типы. Вам не придется объявлять числа с типами int, double, byte, float и пр., как это делается в некоторых других языках. Единственное исключение представлено в виде типа BigInt, который вы будете использовать, если вам понадобится действительно огромное или малое число. Об этом типе мы поговорим позже.
Отмечу еще, что в JavaScript все числа конвертируются в 64-битные числа с плавающей точкой.
Ни одно введение в тему чисел нельзя считать полноценным, не показав, как используются математические операторы для реализации задач первого класса школьной программы по математике.
В текущем разделе мы рассмотрим распространенные операторы.
В JavaScript вы можете создавать простые математические выражения, используя +, -. *, / и % для сложения, вычитания, умножения, деления и нахождения остатка (модуля) чисел соответственно. Если вы умеете пользоваться калькулятором, то сможете производить простые вычисления и в JavaScript.
Вот некоторые примеры с применением перечисленных операторов:
let total = 4 + 26;
let average = total / 2;
let doublePi = 2*3.14159;
let subtractItem = 50–25;
let remainder = total % 7;
let more = (1 + average * 10) / 5;
Обратите внимание, что в последней строке я определяю фиксированный порядок выполнения операций, заключая в скобки выражение, которое хочу вычислить как группу. Опять же это все уровень калькулятора.
JavaScript производит вычисление выражений в следующем порядке:
1. Скобки.
2. Экспоненты.
3. Умножение.
4. Деление.
5. Сложение.
6. Вычитание.
Для запоминания этого порядка иногда используют соответствующие мнемонические схемы. В начальных классах меня научили вот такому: Please Excuse My Dear Aunt Sally[2].
Нередко в отношении чисел вы будете производить увеличение и уменьшение переменной на определенную величину. Ниже представлен пример увеличения переменной i на 1:
let i = 4;
i = i + 1;
Вам не обязательно увеличивать или уменьшать именно на 1. Вы можете использовать произвольное число:
let i = 100;
i = i — 2;
При этом также не обязательно использовать именно сложение или вычитание. Вы можете выполнять и другие операции:
let i = 100;
i = i / 2;
Здесь стоит разглядеть шаблон. Независимо от того, какой оператор используете, вы заметите, что всегда изменяете переменную i. В связи с частым использованием этого шаблона существуют специальные операторы для упрощения процесса (табл. 16.1).
Табл. 16.1. Операторы, упрощающие увеличение и уменьшение
Выражение
Действие
i++
Увеличивает i на 1 (i = i + 1)
i —
Уменьшает i на 1 (i = i — 1)
i += n
Увеличивает i на n (i = i + n)
i — = n
Уменьшает i на n (i = i — n)
i *= n
Умножает i на n (i = i * n)
i /= n
Делит i на n (i = i / n)
i %= n
Находит остаток i при делении на n (i = i % n)
i **= n
Экспоненциальный оператор, где i возводится в степень n
Если я использую эти операторы для трех примеров, приведенных ранее, то код будет выглядеть так:
i++;
i — = 2;
i /= 2;
Прежде чем мы здесь закончим, есть одна хитрость, о которой вам следуем знать. Она касается операторов — и ++ для увеличения и уменьшения значения на 1. Тут важно определить оператор перед переменной или после нее.
Рассмотрим пример:
let i = 4;
let j = i++;
После выполнения этих двух строк значением i будет 5, как вы и могли ожидать. Значением j будет 4. Обратите внимание, что в этом примере оператор используется после переменной.
Если же мы расположим его перед ней, то результат будет несколько иным:
let i = 4;
let j = ++i;
В этом случае значением i по-прежнему будет 5. Но при этом удивительным образом значением j теперь также будет 5.
Эти два примера отличаются лишь расположением оператора, которое определяет, будет ли возвращено увеличенное значение или значение, имевшееся до увеличения.
Помимо обычных десятичных значений вы можете использовать шестнадцатеричные (основание 16) и восьмеричные (основание 8). При работе с восьмеричными обязательно начинайте числа с 0:
let leet = 0°2471;
При использовании шестнадцатеричных начинайте с 0х:
let leet = 0x539;
Во многих ситуациях придется взаимодействовать с этими значениями в форме строк. В связи с этим вы уже не сможете манипулировать ими, как обычными числами. Для начала потребуется преобразовывать эти строки в числа.
Делается это с помощью функции parseInt:
let hexValue = parseInt('FFFFFF', 16);
let octalValue = parseInt('011', 8);
Функция parseInt получает шестнадцатеричное или восьмеричное значение, сопровождаемое основанием, из которого вы производите преобразование.
Последним, что мы рассмотрим, будут два глобальных свойства, с которыми вам предстоит сталкиваться и которые не являются числовыми значениями. Это Infinity (бесконечность) и NaN (не число):
Вы можете использовать значения Infinity и — Infinity для определения бесконечно больших и бесконечно малых чисел:
let myLoveForYou = Infinity * 2;
На деле вы вряд ли будете часто использовать Infinity. Чаще такие значения могут быть возвращены в результате выполнения кодом каких-то задач. Например, если вы разделите на 0, то в качестве результата будет возвращено именно значение Infinity.
Ключевое слово NaN обозначает «не число» и возвращается, когда вы пытаетесь произвести недопустимую вычислительную операцию. Например:
let nope = 1920 / "blah";
В данном случае будет возвращено NaN, так как нельзя делить число на строку. Существуют простые случаи, в которых это будет происходить, и некоторые из них мы рассмотрим позднее.
Иногда у вас будут числа, заключенные внутри строк. Чтобы подробно ознакомиться с этой темой, прочтите статью «Получение числа из строки» (https://www.kirupa.com/html5/going_from_a_string_to_a_number.htm).
Числа используются во множестве математических выражений, которые зачастую выходят за рамки простого сложения, вычитания, умножения и деления. Если бы в курсе математики читали только перечисленное выше, все было бы проще. Для упрощения выполнения сложных операций с числами как раз и служит объект Math. Он предлагает множество удобных функций и констант, мы же вкратце рассмотрим, на что он способен.
Буду с вами честен. Разбор всех предлагаемых объектом Math возможностей был бы скучен. Если вы не фанат этой темы, то я предлагаю пробежаться по следующим разделам и возвращаться к ним уже по мере необходимости. Объект Math никуда не уйдет — друзей у него нет, поэтому он будет преданно ждать вас и никуда не денется.
Чтобы избавить вас от необходимости определять такие математические постоянные, как число π, постоянная Эйлера, натуральный логарифм и т. д., объект Math определяет большинство распространенных констант за вас (табл. 16.2).
Табл. 16.2. Константы
Использование
Что обозначает
Math.E
Постоянная Эйлера
Math.LN2
Натуральный логарифм 2
Math.LN10
Натуральный логарифм 10
Math.LOG2E
Log E по основанию 2
Math.LOG10E
Log E по основанию 10
Math.PI
3,14159 (это все, что я помню, и мне лень искать остальное!)
Math.SQRT1_2
1
2
Math.SQRT2
2
Из всех этих констант я чаще всего использовал Math.PI:
Ее вы будете использовать везде, от рисования кругов на экране и до определения тригонометрических выражений. Честно говоря, даже и не помню, использовал ли я вообще другие константы. Вот пример функции, возвращающей длину окружности по заданному радиусу:
function getCircumference(radius) {
return 2 * Math.PI * radius;
}
console.log(getCircumference(2));
Используется Math.PI и все прочие константы так же, как и любая переменная с именем.
Ваши числа часто будут содержать ненужную точность:
let position = getPositionFromCursor(); // 159.3634493939
Чтобы облегчить округление таких чисел до разумного целочисленного значения, используются функции Math.round(), Math.ceil() и Math.floor(), в которые число передается в виде аргумента (табл. 16.3).
Табл. 16.3. Функции округления
Функция
Действие
Math.round()
Возвращает число, округленное до ближайшего целого числа. При этом округление происходит вверх, если аргумент больше или равен 0,5. Если аргумент меньше 0,5, округление производится до текущего целого числа
Math.ceil()
Возвращает число, которое больше или равно вашему аргументу
Math.floor()
Возвращает число, которое меньше или равно вашему аргументу
Легче всего понять эту таблицу, посмотрев функции в действии:
Math.floor(.5); // 0
Math.ceil(.5); // 1
Math.round(.5); // 1
Math.floor(3.14); // 3
Math.round(3.14); // 3
Math.ceil(3.14); // 4
Math.floor(5.9); // 5
Math.round(5.9); // 6
Math.ceil(5.9); // 6
Эти функции всегда округляют до целого числа. Если вы хотите произвести округление до точного набора цифр, то ознакомьтесь со второй половиной статьи «Округление чисел в JavaScript» (https://www.kirupa.com/html5/rounding_numbers_in_javascript.htm).
Больше всего мне нравится, что объект Math дает удобный способ обращаться почти что к любым тригонометрическим функциям, которые могут понадобиться (табл. 16.4).
Для их использования просто передайте число в качестве аргумента:
Math.cos(0); // 1
Math.sin(0); // 0
Math.tan(Math.PI / 4); // 1
Math.cos(Math.PI); // 1
Math.cos(4 * Math.PI); // 1
Табл. 16.4. Тригонометрические функции
Функция
Действие
Math.cos()
Вычисляет косинус аргумента
Math.sin()
Вычисляет синус аргумента
Math.tan()
Вычисляет тангенс аргумента
Math.acos ()
Вычисляет арккосинус аргумента (крутое название, да?)
Math.asin()
Вычисляет арксинус аргумента
Math.atan ()
Вычисляет арктангенс аргумента
Эти функции получают значения в виде радиан. Если же ваши числа представлены в виде градусов, то сначала преобразуйте их в радианы.
В табл. 16.5 даны еще несколько функций, присущих объекту Math, а именно Math.pow(), Math.exp() и Math.sqrt().
Табл. 16.5. Функции для вычисления степеней и квадратных корней
Функция
Действие
Math.pow()
Возводит число в заданную степень
Math.exp()
Возводит постоянную Эйлера в заданную степень
Math.sqrt()
Возвращает квадратный корень заданного аргумента
Теперь взглянем на несколько примеров:
Math.pow(2, 4); //эквивалент 2^4 (или 2 * 2 * 2 * 2)
Math.exp(3); //эквивалент Math.E^3
Math.sqrt(16); //4
Обратите внимание, что Math.pow() получает два аргумента. Это, вероятно, первая рассмотренная нами встроенная функция, получающая два аргумента, что даже несколько вдохновляет.
Если вам понадобится получить абсолютное значение, просто используйте функцию Math.abs():
Math.abs(37); //37
Math.abs(-6); //6
На этом все.
Для генерации случайных чисел между 0 и чуть меньше, чем 1, можно использовать функцию Math.random(). Эта функция не получает аргументы, но вы можете легко использовать ее как часть выражения:
let randomNumber = Math.random() * 100;
При каждом вызове этой функции вы увидите случайное число, возвращаемое для Math.random(). Все подробности ее использования для генерации случайных чисел вы можете найти в статье «Случайные числа в JS» (https://www.kirupa.com/html5/random_numbers_js.htm).
КОРОТКО О ГЛАВНОМ
На этом ознакомительная глава, посвященная числам и объекту Math в JavaScript, окончена. Как вы видите, легче уже некуда. JS предоставляет максимально простой подход для работы с этими элементами, а эта глава лишь мельком показала горизонты их возможностей на случай, если вы решите направиться к ним.
Ниже представлены дополнительные ресурсы с примерами, которые помогут вам лучше понять возможности использования чисел в JavaScript:
• Получение числа из строки: http://bit.ly/kirupaStrToNum
• Случайные числа в JS: http://bit.ly/kirupaRandom
• Продвинутые случайные числа в JS: http://bit.ly/AdvRandom
• Почему мои числа не складываются: http://bit.ly/kirupaFPG
• Случайные цвета в JS: http://bit.ly/kirupaRandomColors
Числа в JavaScript — это занятная тема, которая местами может быть запутывающей. Если у вас вдруг возникнут трудности, то прояснить ситуацию вы можете, обратившись на форум https://forum.kirupa.com.
Свойства, с которыми мы работали до сих пор, известны как свойства данных. Для этих свойств мы задаем имя и присваиваем им значение:
let foo = {
a: "Hello",
b: "Monday";
}
Для считывания свойства нужно просто обратиться к нему напрямую:
console.log(foo.a);
Записываются же значения в свойства вполне ожидаемым способом:
foo.a = "Manic";
Помимо чтения и записи значения, мы больше ничего не можем сделать. Такова горькая правда о свойствах данных. Продолжая тему чтения и записи свойств, что, если бы мы могли следующее:
• использовать существующий синтаксис для чтения и записи значений свойств;
• получать возможность выполнять пользовательский код на фоне?
Это было бы неплохо, как считаете? Скажу больше: все это нам доступно. Такие возможности предоставляют дружественные и трудолюбивые свойства-аксессоры. В текущем разделе мы все о них узнаем и познакомимся с великими рок-звездами — загадочными геттерами и сеттерами.
Поехали!
Внешне свойства-аксессоры и свойства данных очень схожи. Для свойств данных вы можете производить чтение и запись свойства:
theObj.storedValue = "Unique snowflake!"; // запись
console.log(theObj.storedValue); // чтение
С помощью свойств-аксессоров вы можете, в принципе, то же самое:
myObj.storedValue = "Also a unique snowflake!"; // запись
console.log(myObj.storedValue); // чтение
Глядя на само использование свойства, мы не можем сказать, является ли оно свойством данных или свойством-аксессором. Чтобы обнаружить отличие, нам нужно посмотреть туда, где свойство фактически определено. Взгляните на следующий код, в котором внутри объекта zorb определено несколько свойств:
let zorb = {
message: "Blah",
get greeting() {
return this.message;
},
set greeting(value) {
this.message = value;
}
};
Первое сверху — это message, стандартное свойство данных:
let zorb = {
message: "Blah",
get greeting() {
return this.message;
},
set greeting(value) {
this.message = value;
}
};
Мы узнаем, что это свойство данных, так как в нем присутствует только имя свойства и значение. А вот дальше все немного интереснее. Следующее свойство — это greeting, которое не похоже ни на одно из свойств, встреченных нами ранее:
let zorb = {
message: "Blah",
get greeting() {
return this.message;
},
set greeting(value) {
this.message = value;
}
};
Вместо того чтобы обходиться именем и значением, как message, свойство greeting разделено на две функции, которым предшествует ключевое слово get или set:
let zorb = {
message: "Blah",
get greeting() {
return this.message;
},
set greeting(value) {
this.message = value;
}
};
Эти ключевые слова и пары функций известны как геттеры и сеттеры соответственно. Особенными их делает то, что мы не обращаемся к greeting как к функции, а делаем это так же, как и с обычным свойством:
zorb.greeting = "Hola!";
console.log(zorb.greeting);
Самое же интересное происходит на уровне геттеров и сеттеров, поэтому мы рассмотрим их глубже.
На данный момент мы знаем лишь, что геттер и сеттер — это модные названия функций, которые ведут себя как свойства. Когда мы пытаемся считать свойство-аксессор (zorb.greeting), вызывается функция геттер:
let zorb = {
message: "Blah",
get greeting() {
return this.message;
},
set greeting(value) {
this.message = value;
}
};
Аналогичным образом, когда мы задаем новое значение свойству-аксессору (zorb.greeting = "Hola!"), вызывается функция сеттер:
let zorb = {
message: "Blah",
get greeting() {
return this.message;
},
set greeting(value) {
this.message = value;
}
};
Основной потенциал геттеров и сеттеров лежит в коде, который мы можем выполнять, когда считываем или записываем свойство. Так как мы имеем дело с функциями под прикрытием, то можем выполнять любой нужный нам код. В примере с zorb мы использовали геттер и сеттер greeting, чтобы приблизительно повторить поведение свойств данных. Мы можем назначить значение, а затем считать его. Скучновато, не правда ли? Но это не должно происходить именно так, и следующие примеры привнесут больше интереса в этот процесс.
Вот пример, в котором любое определяемое нами сообщение будет преобразовано в верхний регистр:
var shout = {
_message: "HELLO!",
get message() {
return this._message;
},
set message(value) {
this._message = value.toUpperCase();
}
};
shout.message = "This is sparta!";
console.log(shout.message);
Обратите внимание, что как часть определения значения свойства message мы храним введенное значение в верхнем регистре благодаря методу toUpperCase, который передается всем строковым объектам. Все это гарантирует, что при попытке считать сохраненное сообщение мы увидим полностью заглавную версию того, что введем.
В следующем примере у нас есть объект superSecureTerminal, регистрирующий имена всех пользователей:
var superSecureTerminal = {
allUserNames: [],
_username: "",
showHistory() {
console.log(this.allUserNames);
},
get username() {
return this._username;
},
set username(name) {
this._username = name;
this.allUserNames.push(name);
}
}
Это регистрирование обрабатывается внутри сеттера username, где каждое предоставляемое имя пользователя сохраняется в массиве allUserNames, а функция showHistory выводит сохраненные имена пользователей на экран. Прежде чем продолжить, давайте протестируем этот код. Мы попробуем обратиться к superSecureTerminal не так, как делали это до сих пор. Для этого мы используем кое-какие знания о создании объектов и сделаем следующее:
var myTerminal = Object.create(superSecureTerminal);
myTerminal.username = "Michael Gary Scott";
myTerminal.username = "Dwight K. Schrute";
myTerminal.username = "Creed Bratton";
myTerminal.username = "Pam Beasley";
myTerminal.showHistory();
Мы создаем новый объект myTerminal, основанный на объекте superSecureTerminal. С этого момента мы можем делать с myTerminal все, что угодно, в привычном режиме.
Последним мы рассмотрим пример, в котором сеттеры производят проверку переданных им значений:
let person = {
_name: "",
_age: "",
get name() {
return this._name;
},
set name(value) {
if (value.length > 2) {
this._name = value;
} else {
console.log("Name is too short!");
}
},
get age() {
return this._age;
},
set age(value) {
if (value < 5) {
console.log("Too young!");
} else {
this._age = value;
}
},
get details() {
return "Name: " + this.name +", Age: " + this.age;
}
}
Обратите внимание, что мы производим проверку допустимого ввода для обоих свойств name и age. Если введенное имя короче двух знаков, выводится соответствующее уведомление. Если указан возраст меньше пяти, то также выскакивает уведомление. Возможность проверять, является ли присваиваемое свойству значение подходящим, вероятно, одна из лучших возможностей, предлагаемых геттерами и сеттерами.
КОРОТКО О ГЛАВНОМ
Стоит ли прекращать создавать стандартные свойства данных и использовать эти модные свойства-аксессоры? На самом деле нет. Все зависит от текущих потребностей и будущих нужд. Если вы уверены, что свойству никогда не потребуется дополнительная гибкость, предлагаемая геттерами и сеттерами, то можете просто оставить его в виде свойства данных. Если вам когда-нибудь понадобится к нему вернуться, то изменение свойства данных на свойство-аксессор полностью происходит за кадром. Мы можем делать это, не влияя на итоговое применение самого свойства. Круто, не правда ли?
Если у вас возникнут сложности в этой теме, то обращайтесь за помощью на форум https://forum.kirupa.com.
Знакомясь с объектами в главе 12 «О пицце, типах, примитивах и объектах», мы произвели очень поверхностный обзор того, чем являются объекты в JavaScript и как их воспринимать. На тот момент этого было достаточно, чтобы рассмотреть основы некоторых встроенных типов, но теперь пора двигаться дальше. В этой главе увидим, что вся предыдущая информация была лишь вершиной айсберга.
Здесь мы уже подробнее пересмотрим объекты и затронем некоторые наиболее продвинутые темы вроде объекта Object, создания пользовательских объектов, наследования, прототипов и ключевого слова this. Если все перечисленное кажется вам совершенно непонятным, то я обещаю, что к завершению главы мы это исправим.
Поехали!
В самом низу пищевой цепочки есть тип Object, который закладывает основу как для пользовательских объектов, так и для встроенных типов вроде Function, Array и RegExp. Практически все, за исключением null и undefined, непосредственно связано с Object или может стать им при необходимости.
Как мы уже видели, функциональность, которую предоставляет Object, весьма мала. Он позволяет определять множество именованных пар ключ — значение, которые мы с любовью называем свойствами. Это не особо отличается от того, что мы видим в других языках, использующих хэш-таблицы, ассоциативные массивы и словари.
Как бы то ни было, все это скучно. Мы же собираемся изучать объекты на практике.
Первое, что мы рассмотрим, — это создание объекта. Для этого существует несколько способов, но все крутые ребята создают их с помощью забавного (но компактного) синтаксиса объектного литерала:
let funnyGuy = {};
Все верно. Вместо написания new Object(), как это делали еще ваши деды, мы можем просто инициализировать наш объект, используя {}. По завершении выполнения этой строки мы получим созданный объект funnyGuy с типом Object:
Создание объектов имеет еще кое-какие особенности кроме только что рассмотренного нами синтаксиса объектного литерала, но их мы рассмотрим в более подходящее время.
Как только у нас появился объект, мы можем использовать один из ряда путей для добавления к нему свойств. Возьмем простой и производительный вариант, который задействует подобную массиву скобочную нотацию, где имя свойства будет указано в виде индекса.
Продолжим с нашего объекта funnyGuy:
let funnyGuy = {};
Предположим, мы хотим добавить свойство firstName и задать ему значение Conan. Добавление свойства в данном случае производится с помощью синтаксиса записи через точку:
funnyGuy.firstName = "Conan";
Вот и все. После добавления свойства мы можем обращаться к нему посредством того же синтаксиса:
let funnyFirstName = funnyGuy.firstName;
Альтернатива записи через точку
Для определения считывания свойств мы использовали подход, называемый записью через точку. Но у него есть альтернатива, использующая вместо точки скобки:
let funnyGuy = {};
funnyGuy["firstName"] = "Conan";
funnyGuy["lastName"] = "O'Brien";
Какой из этих подходов использовать, решать только вам (или команде), но есть определенные случаи, для которых предназначены именно скобки. Имеются в виду случаи, когда мы работаем со свойствами, чьи имена нам нужно генерировать динамически. В примере же с firstName и lastName мы прописали их статично. Взгляните на следующий фрагмент кода:
let myObject = {};
for (let i = 0; i < 5; i++) {
let propertyName = "data" + i;
myObject[propertyName] = Math.random() * 100;
}
Мы имеем объект myObject — обратите внимание на то, как мы устанавливаем его свойства. У нас нет статичного списка имен свойств, вместо этого мы создаем имя свойства, опираясь на значение индекса массива. Когда мы выясняем имя свойства, то используем эти данные для создания свойства в myObject. Генерируемые именами свойств будут data0, data1, data2, data3 и data4. Эта возможность динамического определения имени свойства в процессе изменения или чтения объекта оказывается доступной благодаря именно скобочному синтаксису.
Теперь, прежде чем продолжить, давайте добавим еще одно свойство, назовем его lastName и присвоим ему значение O’Brien:
funnyGuy.lastName = "O'Brien";
К этому моменту мы уже в хорошей форме, а наш полный код funnyGuy выглядит следующим образом:
let funnyGuy = {};
funnyGuy.firstName = "Conan";
funnyGuy.lastName = "O'Brien";
При его выполнении будет создан объект funnyGuy, и в нем будут определены два свойства — firstName и lastName.
Мы только что рассмотрели, как пошагово создавать объект и устанавливать для него свойства. Если же мы изначально знаем, какие свойства должны быть в объекте, то можем объединить некоторые шаги:
let funnyGuy = {
firstName: "Conan",
lastName: "O'Brien"
};
Конечный результат в таком случае будет идентичен предыдущему, в котором мы сперва создали объект funnyGuy и лишь затем определили в нем свойства.
Есть и еще одна деталь, касающаяся добавления свойств, на которую стоит обратить внимание. К текущему моменту мы рассмотрели различные объекты, имеющие свойства, чьи значения состоят из чисел, строк и т. д. А вы знали, что свойством объекта может являться другой объект? Это вполне возможно! Взгляните на следующий объект colors, чье свойство content содержит объект:
let colors = {
header: "blue",
footer: "gray",
content: {
title: "black",
body: "darkgray",
signature: "light blue"
}
};
Объект внутри объекта определяется так же, как и свойство с использованием скобочного синтаксиса для установки значения свойства для объекта. Если мы хотим добавить свойство во вложенный объект, то можем для этого использовать те же только что полученные знания.
Допустим, мы хотим добавить свойство frame во вложенный объект content. Сделать мы это можем, например, так:
colors.content.frame = "yellow";
Начинаем с объекта colors, переходим к объекту content, а затем определяем свойство и значение, которые нам нужны. Если же для обращения к свойству content вы предпочтете использовать скобочную нотацию, то сделаете так:
colors["content"]["frame"] = "yellow";
Если вы хотите одновременно использовать оба вида нотации, то это тоже возможно:
colors.content["frame"] = "yellow";
В начале я говорил, что существует ряд способов для добавления свойств объекту. Мы рассмотрели один из них. Более сложный способ задействует методы Object.defineProperty и Object.defineProperties. Эти методы также позволяют вам устанавливать свойство и его значение, но при этом дают и другие возможности. Например, возможность указать, может ли свойство быть пронумеровано или может ли оно быть перенастроено и т. д. Это однозначно выходит за рамки того, что мы будем делать 99 % времени в начале обучения, но если вам это нужно, то упомянутые два метода вполне пригодятся. Документация MDN (https://mdn.dev/) приводит хорошие примеры их использования для добавления одного или нескольких свойств объекту.
Если добавление свойств могло показаться вам занятным, то их удаление несколько муторно. Но при этом оно проще. Продолжим работать с объектом colors:
let colors = {
header: "blue",
footer: "gray",
content: {
title: "black",
body: "darkgray",
signature: "light blue"
}
};
Требуется удалить свойство footer. Для этого используем один из двух способов в зависимости от того, хотим мы обратиться к свойству посредством скобочной нотации или точечной:
delete colors.footer;
// или
delete colors["footer"];
Главную роль при этом играет ключевое слово delete. Просто используйте его, сопроводив свойством, которое хотите удалить.
Но JavaScript не был бы собой, если бы тут не содержался подвох. В данном случае он связан с производительностью. Если вы будете часто удалять большое количество свойств во множестве объектов, то использование delete окажется намного медленнее, чем определение значений свойств как undefined:
colors.footer = undefined;
// или
colors["footer"] = undefined;
Оборотная же сторона определения свойства как undefined в том, что оно по-прежнему остается в памяти. Вам потребуется взвесить все за и против (скорость или память) для каждой отдельной ситуации, чтобы выбрать оптимальный вариант.
Мы научились создавать объекты и производить с ними некоторые простые модификации. Так как объекты — это сердце всех возможностей JavaScript, то важно как можно лучше разобраться в происходящем. И не ради расширения багажа знаний, хоть это и помогло бы впечатлить друзей или родственников за ужином. Главная часть работы в JavaScript — это создание объектов на основе других объектов и выполнение традиционных, присущих объектному программированию действий. Все эти действия будут для вас гораздо понятнее, когда мы разберемся в том, что же происходит при работе с объектами.
Давайте вернемся к нашему объекту funnyGuy:
let funnyGuy = {};
Итак, что мы можем сделать с пустым объектом, не имеющим свойств? Неужели наш объект funnyGuy совсем одинок и изолирован от всего происходящего? В ответ эхом — нет. Причина скрыта в том, как создаваемые в JS объекты автоматически связываются с более крупным Object и всей присущей ему функциональностью. Лучшим способом понять эту связь будет визуализация. Сосредоточьтесь и внимательно рассмотрите рис. 18.1.
Рис. 18.1. Что на самом деле происходит с простым, казалось бы, объектом funnyGuy
Здесь отображено, что именно происходит за кадром, когда мы создаем пустой объект funnyGuy.
Рассмотрение этого представления начнем с самого объекта. Здесь все по-прежнему, а вот остальное уже отличается. Мы видим, что наш funnyGuy — это просто пустой объект. У него нет свойств, которые мы могли бы ему определить, но есть свойства, которые определяются по умолчанию. Эти свойства связывают объект funnyGuy с лежащим в основе типом Object, не требуя для этого нашего вмешательства. Эта связь позволяет вызывать стандартные свойства Object для funnyGuy:
let funnyGuy = {};
funnyGuy.toString(); // [объект Object]
Для ясности еще раз скажу, что именно эта связь позволяет вызвать toString для нашего кажущегося пустым объекта funnyGuy. Однако называть эту связь связью не совсем точно. Эта связь в действительности известна как прототип (и зачастую представлена как [[Prototype]]), который в итоге указывает на другой объект. Другой объект может иметь свой собственный [[Prototype]], который будет также указывать на другой объект, и т. д. Такой род связи называется цепочкой прототипов. Перемещение по цепочке прототипов — это существенная часть того, что делает JavaScript при поиске вызываемого вами свойства. В нашем случае это вызов toString для объекта funnyGuy, который визуально представлен на рис. 18.2.
Рис. 18.2. Переход по цепочке прототипов в поиске нужного свойства
В цепочке прототипов, даже если в нашем объекте не определено конкретное свойство, которое мы ищем, JavaScript все равно продолжит поиск по цепочке в попытке найти его в каждом последующем пункте. В нашем случае цепочка прототипов объекта funnyGuy состоит из прототипа самого этого объекта и Object.prototype, то есть является весьма простой. Когда же мы будем работать с более сложными объектами, цепочки будут становиться намного длиннее и сложнее. И вскоре мы это увидим.
Объект не является частью цепочки прототипа
В предыдущих визуализациях объекта мы видели выделенные точки соединения и линии, соединяющие его свойства с Object.prototype. Здесь стоит заметить, что объект не является частью цепочки прототипов. Он играет роль в том, как объекты реализуют связь между их конструктором и неудачно названным свойством prototype (не связанным с нашим [[Prototype]]), и мы еще коснемся этой его роли позднее. Я продолжу показывать роль объекта в будущих реализациях объектов, но помните, что он не принимает участия в проходе по цепочке прототипов.
Далее, как мы видим, наш объект funnyGuy очень прост. Давайте для интереса добавим в него свойства firstName и lastName:
let funnyGuy = {
firstName: "Conan",
lastName: "O'Brien"
};
На рис. 18.3 показано, как будет выглядеть наша прежняя визуализация при участии добавленных свойств.
Рис. 18.3. Поздоровайтесь с нашими старыми знакомыми firstName и lastName
Свойства firstName и lastName являются частью объекта funnyGuy и также представлены. Покончив с рассмотрением этих основ объекта, мы можем переходить к подробностям.
Работа с обобщенным объектом Object и добавление в него свойств служит определенной цели, но вся его прелесть быстро исчезает, когда мы создаем много одинаковых в основе объектов. Взгляните на этот фрагмент:
let funnyGuy = {
firstName: "Conan",
lastName: "O'Brien",
getName: function () {
return "Name is: " + this.firstName + " " + this.lastName;
}
};
let theDude = {
firstName: "Jeffrey",
lastName: "Lebowski",
getName: function () {
return "Name is: " + this.firstName + " " + this.lastName;
}
};
let detective = {
firstName: "Adrian",
lastName: "Monk",
getName: function () {
return "Name is: " + this.firstName + " " + this.lastName;
}
};
Этот код создает объект funnyGuy и вводит два новых очень похожих на него объекта theDude и detective. Наша визуализация всего этого теперь будет выглядеть, как показано на рис. 18.4.
Рис. 18.4. Каждый вновь созданный объект расширяется от Object.prototype
На первый взгляд кажется, что здесь многовато повторений. Каждый из только что созданных объектов содержит свою собственную копию свойств firstName, lastName и getName. Итак, все же повторение — это не всегда плохо. Да, есть противоречие тому, что я утверждал ранее, но дайте-ка я все объясню. В случае с объектами нужно выяснить, какие свойства имеет смысл повторять, а какие нет. В нашем примере свойства firstName и lastName будут, как правило, уникальны для каждого объекта, а значит, это повторение имеет смысл. А вот свойство getName хоть и выступает в роли помощника, но не содержит ничего, что отдельный объект мог бы определить уникально:
getName: function () {
return "Name is: " + this.firstName + " " + this.lastName;
}
В этом случае его повторение ни к чему, следовательно, нам стоит сделать его общедоступным и избежать повторения. И как же?
Что ж… Для этого есть прямой путь, а именно создание промежуточного родительского объекта, содержащего общие свойства. В свою очередь, наши дочерние объекты смогут наследовать от этого родительского объекта вместо наследования напрямую от Object. Для большей конкретики мы создадим объект person, содержащий свойство getName. Наши объекты funnyGuy, theDude и detective станут наследниками person. Упорядоченная таким образом структура обеспечит, чтобы все свойства, требующие повторения, были повторены, а требующие совместного использования использовались совместно. Лучше понять все сказанное поможет рис. 18.5, где эти действия изображены наглядно.
Рис. 18.5. Добавление промежуточного объекта person со свойством (теперь используемым совместно) getName
Заметьте, что теперь person стал частью цепочки прототипов, удачно расположившись между Object.prototype и нашими дочерними объектами. Как же это делается? Один из подходов мы уже видели ранее, и в нем мы опираемся на Object.create. При использовании Object.create мы можем указать объект, на основе которого требуется создать новый объект. Например:
let myObject = Object.create(fooObject);
Когда мы это делаем, за кадром происходит следующее: прототип нашего объекта myObject теперь будет fooObject. При этом он становится частью цепочки прототипов. Теперь, когда мы сделали крюк и расширили наше понимание Object.create, освоив содержание этой главы. Давайте вернемся к изначальному вопросу о том, как же именно наши объекты funnyGuy, theDude и detective наследуют от person.
Код, осуществляющий все это, будет таким:
let person = {
getName: function () {
return "The name is " + this.firstName + " " + this.lastName;
}
};
let funnyGuy = Object.create(person);
funnyGuy.firstName = "Conan";
funnyGuy.lastName = "O'Brien";
let theDude = Object.create(person);
theDude.firstName = "Jeffrey";
theDude.lastName = "Lebowski";
let detective = Object.create(person);
detective.firstName = "Adrian";
detective.lastName = "Monk";
Принцип работы цепочки прототипов позволяет нам вызывать getName для любого из наших объектов funnyGuy, theDude или detective, что приведет к ожидаемому результату:
detective.getName(); // Имя Adrian Monk
Если мы решим расширить объект person, то достаточно сделать это всего один раз, и это также отразится на всех наследующих от него объектах, не требуя дополнительного повторения. Предположим, мы хотим добавить метод getInitials, возвращающий первую букву из имени и фамилии:
let person = {
getName: function () {
return "The name is " + this.firstName + " " + this.lastName;
},
getInitials: function () {
if (this.firstName && this.lastName) {
return this.firstName[0] + this.lastName[0];
}
}
};
Мы добавляем метод getInitials в объект person. Чтобы использовать этот метод, можем вызвать его для любого объекта, расширяющего person, например funnyGuy:
funnyGuy.getInitials(); // CO
Такая возможность создавать промежуточные объекты, помогающие разделять функциональность кода, является мощным инструментом. Она повышает эффективность создания объектов и добавления в них функциональности. Неплохо, правда?
В предыдущих фрагментах кода вы могли заметить использование ключевого слова this, особенно в случае с объектом person, где мы задействовали его для обращения к свойствам, созданным в его потомках, а не к его собственным. Давайте вернемся к этому объекту, а в частности к его свойству getName:
let person = {
getName: function () {
return "The name is " + this.firstName + " " + this.lastName;
},
getInitials: function () {
if (this.firstName && this.lastName) {
return this.firstName[0] + this.lastName[0];
}
}
};
Когда мы вызываем getName, то возвращаемое имя будет зависеть от того, из какого объекта мы это делаем. Например, если мы сделаем следующее:
let spaceGuy = Object.create(person);
spaceGuy.firstName = "Buzz";
spaceGuy.lastName = "Lightyear";
console.log(spaceGuy.getName()); // Buzz Lightyear
При выполнении этого кода мы увидим в консоли Buzz Lightyear. Если мы еще раз взглянем на свойство getName, то увидим, что там нет свойств firstName и lastName в объекте person. Но как мы видели ранее, если свойство не существует, мы переходим далее по цепочке от родителя к родителю, как показано на рис. 18.6.
Рис. 18.6. Цепочка прототипов для объекта person
В нашем случае единственной остановкой в цепочке будет Object.prototype, но в нем также не обнаруживаются свойства firstName и lastName. Как же тогда метод getName умудряется сработать и вернуть нужные значения?
Ответ заключается в ключевом слове this, предшествующем firstName и lastName в инструкции return метода getName:
let person = {
getName: function () {
return "The name is " + this.firstName + " " + this.lastName;
},
getInitials: function () {
if (this.firstName && this.lastName) {
return this.firstName[0] + this.lastName[0];
}
}
};
Ключевое слово this ссылается на объект, к которому привязан наш метод getName. В данном случае объектом является spaceGuy, так как именно его мы используем в качестве точки входа в этот совершенный процесс навигации между прототипами (рис. 18.7).
Рис. 18.7. Ключевое слово this ссылается на spaceGuy!
Когда происходит вычисление метода getName и свойства firstName и lastName должны разрешиться, поиск начинается там, куда указывает ключевое слово this. Это означает, что наш поиск начинается с объекта spaceGuy, который, как выясняется, содержит свойства firstName и lastName. Именно поэтому мы получаем верный результат при вызове кода для getName (а также и getInitials).
Понимание, на что ссылается ключевое слово this, скрыто под галлонами пролитых чернил, и полноценное рассмотрение этого вопроса выходит далеко за рамки того, о чем мы собираемся говорить. Но хорошо то, что пройденного материала вам уже будет достаточно, чтобы решать большинство задач.
КОРОТКО О ГЛАВНОМ
Из-за неразберихи вокруг объектной ориентированности в JavaScript разумным было сделать рассмотрение этой темы глубоким и обширным, как мы и поступили. Многое из того, что было затронуто здесь, прямо или косвенно связано с наследованием — когда объекты разветвляются и основываются на других объектах. В отличие от классических языков, использующих классы как шаблоны для объектов, в JavaScript понятия классов, строго говоря, не существует. Здесь используется так называемая модель наследования прототипов. Вы не инстанцируете объекты из шаблона. Вместо этого вы создаете их либо заново, либо, чаще всего, копированием или клонированием другого объекта. JavaScript попадает в ту самую серую область, где не соответствует классической форме языка, но при этом имеет подобные классам конструкции (некоторые из них вы увидите позже), которые позволяют ему сидеть за одним столом с классическими языками. Не хочу здесь увлекаться навешиванием ярлыков.
Среди всего этого множества страниц я постарался сгруппировать новую функциональность JavaScript для работы с объектами и их расширения для ваших дальнейших нужд. Тем не менее еще многое предстоит рассмотреть, поэтому сделайте перерыв, и мы в ближайшем будущем затронем более интересные темы, которые дополнят пройденное более мощными и выразительными возможностями.
Дополнительные ресурсы и примеры:
• Понимание прототипов в JS: http://bit.ly/kirupaJSPrototypes
• Простое английское руководство по прототипам JS: http://bit.ly/kirupaPrototypesGuide
• Как работает prototype? http://bit.ly/kirupaPrototypeWork
• Это большая и странная тема, поэтому обращайтесь на форум https://forum.kirupa.com, если столкнетесь с какими-либо сложностями.
Как нам уже хорошо известно, 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.
Мы уже рассмотрели множество основных аспектов работы с объектами. Мы видели, как они создаются, изучили наследование прототипов и даже взглянули на темное искусство расширения объектов. При этом мы работали на очень низком уровне и были замешаны в процессе изготовления самого объекта. Это здорово для качественного понимания происходящего, но не так здорово, когда в вашем приложении появляется сложный объект. В целях упрощения всего этого в 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.
Из вежливости можно сказать, что все типы одинаково интересны и занятны, но и вы, и я знаем, что это неправда. Некоторые из них весьма скучны. Одним из таких примеров является логический тип данных, и вот почему. Мы создаем логический тип каждый раз, когда инициализируем переменную, используя true либо false:
let sunny = false;
let traffic = true;
Примите мои поздравления! Если вам это известно, то вы уже на 80 % достигли полного понимания функционирования логических типов. Конечно, если задуматься, то 80 % недостаточно. Это как есть хот-дог без соуса, уйти с концерта, не дождавшись выхода на бис, или не дописать предложение.
Мы же собираемся заполнить эти недостающие 20 %, которые состоят из различных особенностей логических типов, объекта Boolean, функции Boolean и очень важных операторов === и!==.
Поехали!
Логические типы рождены для использования в качестве примитивов. Я не стану бороться с ленью и просто приведу пример, который вы только что видели, чтобы продемонстрировать, как выглядит этот примитив:
let sunny = false;
let traffic = true;
Как вы уже видели много раз, в тени каждого примитива скрывается его объектная форма. Создание логического объекта происходит с помощью ключевого слова new, имени конструктора Boolean и начального значения:
let boolObject = new Boolean(false);
let anotherBool = new Boolean(true);
В виде начального значения вы можете передать логическому конструктору либо true, либо false. Но при этом вы вполне можете передать и нечто иное, что в итоге будет вычислено как true или false. Расскажу немного о том, какие виды значений будут предсказуемо становиться true или false, но относительно этого подхода есть обязательное предостережение: используйте логические объекты только в исключительных случаях, в остальных старайтесь придерживаться примитивов.
Конструктор Boolean предоставляет одну существенную выгоду, которая связана с возможностью передачи любого произвольного значения или выражения в процессе создания объекта Boolean:
let boolObject = new Boolean(< arbitrary expression >);
Выгодно же это, потому что вам может понадобиться вычислить логическое выражение, в котором итоговые данные оказываются не чистыми true или false. Это особенно актуально, когда вы имеете дело с внешними данными или кодом и не контролируете получение значения false или true. Вот пример из головы:
let isMovieAvailable = getMovieData()[4];
Значение isMovieAvailable, вероятно, true или false. Когда дело доходит до обработки данных, у вас зачастую нет уверенности, что в какой-то момент что-либо вдруг не даст сбой или не вернет иное значение. Как и в реальной жизни, простая вера в то, что все будет работать как надо, неразумна, если не предпринять действенные меры. Одной из таких мер и является функция Boolean.
Создание специальной функции для разрешения двусмысленности может быть излишним, но у конструктора Boolean есть побочный эффект — у вас остается логический объект, что нежелательно. К счастью, есть способ получить гибкость конструктора Boolean совместно с легковесностью логического примитива, причем достаточно легко. Этот способ основывается на функции Boolean:
let bool = Boolean(true);
Логическая функция позволяет передавать произвольные значения и выражения, при этом по-прежнему возвращая примитивное логическое значение true либо false. Главным же отличием этого подхода от использования конструктора является то, что вы не используете ключевое слово new. Как бы то ни было, давайте приостановимся и рассмотрим, что именно вы можете передать в логическую функцию. Имейте в виду, что все это также можно передавать в логический конструктор, который мы видели в предыдущем разделе.
Для возвращения false вы можете передать следующие значения: null, undefined, пусто или ничего, 0, пустую строку и, конечно же, false:
let bool;
bool = Boolean(null);
bool = Boolean(undefined);
bool = Boolean();
bool = Boolean(0);
bool = Boolean("");
bool = Boolean(false);
Во всех этих примерах переменная bool вернет false. Чтобы вернуть true, мы можем передать значение true или что угодно, что не приведет к одному из перечисленных выше значений false:
let bool;
bool = Boolean(true);
bool = Boolean("hello");
bool = Boolean(new Boolean()); // Внедрение!!!
bool = Boolean("false"); // "false" — это строка
bool = Boolean({});
bool = Boolean(3.14);
bool = Boolean(["a", "b", "c"]);
В этих примерах переменная bool вернет true. Это может показаться немного странным, учитывая некоторые варианты инструкций, поэтому давайте обратим внимание на имеющиеся нюансы. Если то, что мы вычисляем, является объектом, как new Boolean(new Boolean()), то вычисляться всегда будет true. Причина в том, что простое существование объекта уже приводит к срабатыванию true, а вызов new Boolean() создает именно новый объект. Если дополнить логику происходящего, это означает, что следующая инструкция if также будет вычислена как true:
let boolObject = new Boolean(false);
if (boolObject) {
console.log("Bool, you so crazy!!!");
}
При этом не важно, если вычисляемый нами объект скрывает в себе значение false… или объект String или Array и т. д. Правила, касающиеся примитивов, гораздо проще. Если мы передаем примитив (или то, что вычисляется как примитив), то все, за исключением null, undefined, 0, пустой строки, NaN или false, будет вычисляться как true.
Последнее, что мы рассмотрим, объединит наши знания о типах, в том числе и логических, и привнесет разнообразие в условные операторы, изученные ранее. Итак, мы знаем об операторах == и!= и, вероятно, видели их пару раз в деле. Это операторы равенства и неравенства, которые позволяют понять, являются ли два элемента равными или нет. А вот и сюжетный поворот. Они демонстрируют утонченное, отклоняющееся от нормы поведение, о котором мы можем не знать.
Вот пример:
function theSolution(answer) {
if (answer == 42) {
console.log("You have nothing more to learn!");
}
}
theSolution("42"); // 42 передано как строка
В этом примере выражение answer == 42 будет вычислено как true. Так происходит несмотря на то, что переданное значение 42 является строкой, мы же производим сравнение с 42, являющимся числом. Что здесь происходит? Неужели мы попали в мир, где числа и строки равны? При использовании операторов == и!= такое поведение вполне ожидаемо. В этом случае значением обоих сравниваемых элементов будет 42. Для этого JavaScript осуществляет нужные операции, и оба значения в итоге рассматриваются как одинаковые. Формально это называется приведением типа.
Проблема в том, что такое поведение иногда мешает — особенно когда так происходит у нас за спиной. Во избежание подобных ситуаций у нас есть более строгие версии операторов равенства/неравенства, а именно === и!== соответственно. Задача этих операторов заключается в сравнении как значения, так и типа. При этом они не делают приведения типов. Они ведут к тому, что все заботы по обеспечению равенства или неравенства ложатся непосредственно на нас, и это хорошо.
Теперь давайте исправим предыдущий пример, заменив оператор == на ===:
function theSolution(answer) {
if (answer === 42) {
console.log("You have nothing more to learn!");
}
}
theSolution("42"); // 42 передано как строка
На сей раз условное выражение будет вычислено как false. В этом более строгом мире строка и число — это разные типы, несмотря на то что их значения одинаковы. Так как приведение типа не производится, то и результат в итоге false.
Общее правило гласит: всегда используйте более строгую форму операторов равенства/неравенства. Помимо всего прочего, их использование поможет обнаруживать ошибки в коде — ошибки, которые в противном случае может быть сложно распознать.
Если мы сравниваем два разных объекта, то строгий оператор равенства (и менее строгий тоже) не будет работать ожидаемым образом. Например, все приведенные ниже случаи будут вычислены как false:
console.log(new String("A") == new String("A"));
console.log([1, 2, 3] == [1, 2, 3]);
console.log({ a: 1 } == { a: 1 });
Имейте это в виду при выяснении равенства/неравенства двух отдельных самостоятельных объектов.
КОРОТКО О ГЛАВНОМ
Логические типы являются одними из наиболее часто используемых типов при написании кода. Несмотря на внешнюю простоту, они играют ключевую роль в разветвлении кода. Хоть я и могу посчитать на одной руке количество раз, когда мне приходилось использовать функцию Boolean или строгие операторы равенства и неравенства, мне не хватит рук и пальцев, чтобы счесть все случаи, когда я сталкивался с этими странными вещами в сторонних проектах.
Если у вас возникнут вопросы, добро пожаловать на форум https://forum.kirupa.com.
Одна из величайших загадок мира JS витает вокруг null и undefined. Зачастую код буквально напичкан этими значениями, и вы, возможно, уже с ними встречались. Но как только спадает завеса тайны, оказывается, что null и undefined не такое уж странное явление. Они просто ужасно скучные. Возможно, скучнейшие (но важные) элементы JavaScript из всех, с какими вам когда-либо предстоит познакомиться.
Поехали!
Начнем с null. Ключевое слово null — это примитив, который выполняет особую роль в мире JavaScript. Он является явным определением, обозначающим отсутствие значения. Если вам доводилось просматривать чужой код, то, вероятно, вы видели, что null встречается достаточно часто. Этот элемент весьма популярен, так как имеет преимущество в виде определенности. Вместо работы с переменными, содержащими устаревшие значения или таинственные неопределенные значения, вы можете установить их как null, однозначно указав, что значение существовать не должно.
Такая возможность важна, когда вы пишите код и хотите инициализировать или освободить переменную, чтобы она ничего не представляла.
Вот пример:
let name = null;
if (name === null) {
name = "Peter Griffin";
} else {
name = "No name";
}
Примитив null не появляется сам собой. Его вы присваиваете сознательно, поэтому предстоит часто встречаться с ним в объявлениях переменных или среди аргументов, передаваемых в вызовы функций. Использовать null легко. Проверить его наличие также несложно:
if (name === null) {
// делает что-нибудь интересное или нет
}
Имейте в виду, что при этом нужно использовать более строгий оператор === вместо ==. Хоть от использования == конец света и не наступит, но при работе с null лучше производить проверку как значения, так и типа.
А вот здесь уже интереснее. Чтобы представить что-то, что не определено, вы используете примитив undefined. Он пригождается в нескольких случаях. Чаще всего это происходит, когда вы пытаетесь обратиться к переменной, которая не была инициализирована или когда обращаетесь к значению функции, которая ничего не возвращает.
Следующий фрагмент кода приводит несколько реальных случаев с undefined:
let myVariable;
console.log(myVariable); // undefined
function doNothing() {
// watch paint dry
return;
}
let weekendPlans = doNothing();
console.log(weekendPlans); // undefined
let person = {
firstName: "Isaac",
lastName: "Newton"
}
console.log(person.title); // undefined
В своем коде вы, скорее всего, не станете присваивать undefined чему-либо. Вы уделите время проверке, не является ли значение или что-либо еще undefined. Такую проверку можно выполнить несколькими способами. Первый из них очень прост, но практически всегда работает:
if (myVariable === undefined) {
// делает что-нибудь
}
Оборотная сторона этого подхода связана с истинной природой undefined. Держитесь крепче: undefined — это глобальная переменная, которая определяется за нас автоматически. Это означает, что потенциально мы можем ее переопределить, например, на true или что-либо другое, что нам нужно. Если undefined будет переопределена, то нарушит работу кода в случае проверки только с оператором === или ==. Чтобы избежать подобного безобразия, наиболее безопасным способом выполнения проверки на undefined будет использование typeof и затем уже оператора ===:
let myVariable;
if (typeof myVariable === "undefined") {
console.log("Define me!!!");
}
Это гарантирует выполнение проверки на undefined и возвращение верного ответа.
NULL == UNDEFINED, но NULL!== UNDEFINED
Продолжая тему странности == и ===: если вы когда-нибудь проверите null == undefined, то ответом будет true. Если же вы используете ===, то есть null === undefined, то ответом будет false.
Причина в том, что == производит приведение, присваивая значениям такие типы, какие JS посчитает целесообразными. Используя ===, вы проверяете и тип, и значение. Это уже полноценная проверка, которая определяет, что undefined и null на деле являются двумя разными вещами.
Монету в шляпу шестиглазому (то есть Тревору Маккаули) за то, что указал на это!
КОРОТКО О ГЛАВНОМ
Я неспроста отложил напоследок эти встроенные типы. null и undefined — наименее интересные члены коллектива, но при этом зачастую самые недопонятые. Умение использовать null, а также обнаруживать его и undefined — это очень важные навыки, которыми следует овладеть. Иначе вы рискуете столкнуться с ошибками, которые будет очень сложно обнаружить.
Если у вас появились вопросы о null и undefined или вы просто хотите пообщаться с самыми дружелюбно настроенными разработчиками на планете, пишите на https://forum.kirupa.com.