JavaScript. Особенности и типичные ошибки использования.

Речкунов Денис Знание языка программирования JavaScript становится все более необходимым с развитием web-технологий. Технология AJAX, механизмы шаблонизации на клиенте, развитие стандарта HTML5 с новыми API для JavaScript, появление node.js как серверной платформы на JavaScript – все это способствует продвижению JavaScript в индустрии разработки программного обеспечения. Несмотря на существование множества языков, которые компилируются в JavaScript, большая часть разработчиков все же предпочитают использовать сам JavaScript из-за простоты в отладке и внешней схожести с уже известными им языками программирования. Язык программирования JavaScript был создан в большой спешке и поэтому некоторые вещи в нем можно считать не совсем продуманными или даже ошибочными, к примеру, существует множество статей с примерами неожиданных проблем с динамической типизацией. Внешнее сходство языка с другими языками, такими как Java могут сыграть злую шутку с начинающим JavaScript разработчиком и увеличить порог вхождения в язык, если новичка вовремя не проинструктировать об известных проблемах и особенностях в языке. Именно в качестве такого инструктажа в этой статье приведен обзор типичных ошибок при использовании языка JavaScript.

Область видимости

Первое и, наверное, основное отличие языка JavaScript от других это область видимости переменных, которая задается здесь функцией, а не блоком кода. Отсюда классическая ошибка с переназначением переменной в цикле.
Типичная ошибка с переменной цикла Типичная ошибка с переменной цикла
В примере видно: сначала определена переменная key и ей присвоено какое-то значение. Затем в цикле определена переменная с тем же именем, однако, цикл не задает свою область видимости переменных и перекрывает определенную ранее переменную key, и когда разработчик решит использовать значение key после цикла, он получит имя последнего свойства объекта obj. Еще не менее важная проблема это глобальные переменные. Как и в любом языке программирования, в JavaScript глобальные переменные использовать строго не рекомендуется по очевидным причинам, и когда вы определяете переменные, не заключая их в какую-нибудь функцию, они всегда будут являться глобальными, то есть становиться свойствами в вашем хост-объекте (в браузере это window, в node.js это process и т.д.). Еще один способ случайно определить глобальную переменную это объявить ее без ключевого слова var, поэтому будьте крайне внимательны, чтобы все ваши переменные были объявлены через var. Как же избежать глобальных переменных? Ответ прост, необходимо заключить ваш модуль в функцию, для чего есть известный подход – использование “Immediate Function”.
Использование Immediate Function Использование Immediate Function
Сам подход означает определение функции с кодом вашего модуля и ее вызов после определения. В таком случае все, что вам нужно передать из глобальной области видимости лучше передавать параметрами вызова, чтобы не создавать лишних замыканий, которые несут накладные расходы, к тому же передача в параметры «вещественных» типов данных, таких как числа и строки в JavaScript происходит с копированием, что обезопасит от нежелательного изменения данных другими библиотеками или модулями.

Undeclared и Undefined

В JavaScript есть один нюанс в обращении к неопределенным переменным и свойствам объекта. Часто начинающие разработчики видят, что чтение неопределенного свойства объекта лишь возвращает undefined и не вызывает никаких ошибок и складывается ошибочное мнение, что так происходит и с неопределенными локальными переменными, что совсем не является истиной. Если вы попробуете обратиться к несуществующей переменной, то получите исключение ReferenceError, как продемонстрировано в примере ниже.
Разница Undeclared и Undefined Разница Undeclared и Undefined

Hoisting

Hoisting или эффект поднятия переменной является одной из самых неочевидных особенностей языка, заключается он в следующем: где бы вы не определили переменную внутри функции, даже перед return, она будет определена так, будто вы это сделали в самом начале функции, однако сначала она будет иметь значение undefined до того момента, пока далее в коде ей не будет присвоено значение. Пример демонстрирует типичную ошибку, вызванную эффектом поднятия переменной.
Ошибка вызвана эффектом поднятия переменной Ошибка вызвана эффектом поднятия переменной
Есть практика, помогающая избежать подобных проблем – определять переменные в начале функции самостоятельно, чтобы эффект поднятия стал очевидным, что демонстрирует пример ниже. Однако есть мнение, что это противоречит правилу, утверждающему, что переменные необходимо определять как можно ближе к месту их использования. Здесь уже придется сделать выбор самостоятельно, что вы считает важнее в данной ситуации.
Пример для исключения проблем с Hoisting Пример для исключения проблем с Hoisting

Коварная ссылка “this”

Пожалуй, самый основной источник ошибок у JavaScript разработчиков это “this”. Дело в том, что this в JavaScript не смотря на то же название, что и в Java или других языках выполняет совсем другую функцию. Ссылка “this” указывает на тот объект, в котором в данный момент исполняется код, а не на тот, в котором вы напишите это ключевое слово. То есть если вы используются this в callback’e переданном в метод объекта, то он будет указывать на тот объект, которому принадлежит метод, а не на тот где вы определили callback. То же самое касается и обработчиков событий в браузере (onclick, onload и т.д.), в них this будет указывать на элемент DOM, в котором возникло событие. Отсюда очередная типичная ошибка – использование this в callback’ах или обработчиках событий. Пример ниже демонстрирует проблему.
Проблема с this в callback'e Проблема с “this” в callback’e
Для решения этой проблемы есть два распространенных подхода. Первый работает даже в самых старых браузерах без дополнительных средств – это сохранение значения this в переменную и использование в callback’e этой переменной через замыкание, как показывает пример ниже.
Решение через сохранение this Решение через сохранение this
Второй подход работает в относительно новых браузерах без дополнительных средств, а в старых может быть реализован определением одной функции, как это сделать можно прочитать здесь https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Function/bind. В новых версиях JavaScript уже есть встроенное решение проблемы, это метод bind() у каждой функции, в которую вы можете передать объект, в контексте которого вы хотите в дальнейшем вызывать функцию. В дополнение к этому, вы можете далее перечислить параметры, которые всегда будут предшествовать переданным параметрам при вызове, таким образом получить так называемый механизм частичного применения. Стоит иметь в виду, что bind() не изменяет саму функцию, а возвращают обертку над ней для вызова в контексте объекта.
Использование bind() Использование bind()

Асинхронные вызовы в циклах

Переменная цикла в JavaScript это просто переменная, в которую на каждой итерации присваивается очередное значение свойства объекта. Исходя из этого, использовать ее внутри итерации цикла при асинхронных вызовах небезопасно. Когда ваш асинхронный вызов сработает, переменная цикла уже будет иметь значение равное имени последнего свойства объекта, а не того, что было при вызове в итерации.
Пример ошибки с асинхронным вызовом в цикле Пример ошибки с асинхронным вызовом в цикле
Если же у вас есть непреодолимая потребность использовать значение переменной цикла асинхронно, вы можете решить эту задачу с помощью Immediate Function, как показано в примере ниже.
Как можно использовать переменную цикла асинхронно Как можно использовать переменную цикла асинхронно
Как уже упоминалось, в JavaScript строчные переменные передаются с копированием значения, поэтому создав область видимости и передав туда переменную цикла, вы получите ее значение в области видимости, а не ссылку на переменную.

Циклы

В JavaScript с циклами не все так просто. Дело в том, что JavaScript код, который пишет разработчик, исполняется в одном потоке (кроме WebWorkers). А в браузере вся активность страницы останавливается, если она не может выполнить обработчик события, какого-либо элемента, например нажатия на кнопку или прокрутки страницы. Отсюда следует, что длительный цикл остановит все ваше приложение, так как функции стоящие в очереди на исполнение не будут выполняться в цикле событий виртуальной машины JavaScript. Поэтому использовать циклы в JavaScript можно лишь для строго ограниченного и малого числа итераций. Если вам необходимо сделать длительный цикл, например, отрисовку кадров в HTML5 игре или математические расчеты на веб-сервисе node.js, то вы обязаны планировать итерации через функцию setTimeout, как показано в примере ниже.
Планирование итераций Планирование итераций
Использование такого подхода означает, что очередная итерация цикла ставится в общую очередь функций на исполнение и дает возможность исполнения других функции также попадающих в эту очередь, включая callbacks и обработчики событий. Напрашивается вопрос, почему бы просто не использовать setInterval, к чему такие сложности? К сожалению, функция setInterval не дожидается завершения своего callback’a и ставит его в очередь выполнения функций через заданный интервал снова, что очевидно может привести к переполнению очереди при длительной работе кода в callback’e.

Структуры данных

В JavaScript любой объект представлен хеш-таблицей, а каждое свойство объекта, включая имя метода это ключ хеш-таблицы, поэтому доступ к свойствам происходит сравнительно быстро. Но если вам необходимо хранить последовательный набор данных не по ключам, то используйте массивы, так как скорость последовательного перебора элементов в нем значительно выше, если же вам необходимы два метода доступа к объектам (по ключу и последовательно), то никто не запрещает добавить один и тот же объект в хеш-таблицу и массив. Практически для всех операций работы с массивами можно использовать метод splice(), настоятельно рекомендую ознакомиться с документацией по этому методу, так как он позволяет удалять заданный диапазон элементов, вставлять вместо заданного диапазона другой массив, добавлять массив в начало или конец другого и т.д. В дополнение ко всему, массивы в JavaScript имеют встроенную реализацию стека и очереди, что тоже может пригодиться. Есть одно распространенное заблуждение, что элементы массива можно удалять оператором delete, это срого неверно. При использовании это оператора элемент из массива не удаляется, а просто его значение становится undefined, что образует некие «дыры» в массивах и может вызвать некоторые ошибки при обработке массива.

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

Помимо прочего в JavaScript достаточно непривычная модель наследования – прототипная. Каждый объект в JavaScript имеет прототип (который тоже является объектом и находится по свойству __proto__), и если вы спрашиваете у объекта предоставить какое-либо свойство или метод и объект его не имеет, это свойство будет запрошено у его прототипа, далее при необходимости у его прототипа и т.д. В итоге иерархию наследования можно представить как Linked List состоящий из хеш-таблиц, по которому проходит интерпретатор, проверяя, есть ли заданный ключ в очередной хеш-таблице и если он его нашел – возвращает, если нет – возвращает undefined. В конце цепочие прототипов всегда оказывается пустой объект, который будет означать конец поиска. На рисунке ниже представлена схема наследования объектов в JavaScript.
Схема прототипного наследования Схема прототипного наследования
Кроме прототипов существует еще понятие функции-конструктора, это функция, которая создает новый объект, указывая ему прототип, конструктор, с которого объект был создан и при необходимости инициализирует свойства объекта, причем первые два действия делаются автоматически на уровне языка при использовании оператора new. Ниже приведен пример определения функции-конструктора и прототипа, который она будет назначать своим объектам.
Пример функции-конструктора Пример функции-конструктора
Обычно в прототипе указывают значения по умолчанию для различных свойств объекта, однако и здесь необходимо быть осторожным, так как если вы в качестве значения укажете объекты или массивы, то их изменения в одном объекте, созданном с этим прототипом, будут отражаться на всех остальных объектах, созданных с этим прототипом. Если вы хотите этого избежать инициализируйте объекты и массивы в самом конструкторе, а не в прототипе. Разумеется, если вы присвоите в объект значение по свойству с именем, которое совпадает с именем в прототипе, то это не изменит прототип а просто создаст то же самое свойство в самом объекте, то есть, как бы перекроет значение прототипа в самом объекте. На примере ниже представлен объект, созданный с этой функции-конструктора.
Структура объекта созданного конструктором Структура объекта созданного конструктором
Также существует способ создания цепочки прототипов или многоярусного прототипного наследования, для этого есть известная реализация функции наследования, указанная ниже.
Функция выстраивания цепочки прототипов Функция выстраивания цепочки прототипов
Стоит заметить, что использовать эту функцию необходимо до заполнения свойств ctor.prototype, иначе эта функция их очистит. Этот способ выстраивания цепочки прототипов является абсолютно корректным и позволяет использовать оператор instanceof с созданными объектами. Далее краткий пример использования функции и объекта, который получился при использовании полученного конструктора.
Использование функции inherits Использование функции inherits
 
Полученный объект, используя полученный конструктор Полученный объект, используя полученный конструктор Полученный объект, используя полученный конструктор

Guideline

В JavaScript, как и в Java, принято использовать Camel Casing для переменных, Upper Casing для констант и Pascal Casing для функций-конструкторов. Также достаточно важным моментом является использование так называемых египетских скобок при оформлении блоков кода или объектов, пример ниже показывает достаточно серьезную ошибку при использовании открывающей скобки с новой строки, как это привыкли делать, например, C#-программисты.
Пример ошибки при неиспользовании египетских скобок Пример ошибки при неиспользовании египетских скобок
В таком случае интерпретатор JavaScript решит, что вы пропустили точку с запятой после слова return, нейтрализует эту ошибку, и ваш метод будет возвращать undefined. Для комментирования и документации кода есть аналогичный с JavaDoc инструмент JSDoc, который имеет тот же формат и включает дополнительные теги, специфичные для JavaScript. С помощью этого инструмента есть возможность генерировать документацию в формате HTML с использованием шаблонов или в другой необходимой форме. Очень важным помощником JavaScript разработчика является JSLint – это анализатор возможных семантических ошибок и стиля кодирования, который встроен в некоторые IDE, такие как WebStorm. Все иллюстрации к этой статье, сделанные с WebStorm, имеют подсветку проблемных участков кода, которую обеспечивает JSLint и выдает подробные диагностики по решению проблем. Также есть online версия валидатора, для тех, кто предпочитает IDE, неподдерживающие JSLin

Полезная литература и материалы

Заключение

Несмотря на подводные камни в языке JavaScript и некоторые сложности восприятия, в настоящее время он достаточно распространен, и его начинают использовать все больше новых разработчиков при развитии веб-технологий, как клиентских, так и серверных. Внешнее сходство с известными для разработчика языками может вызвать ложное чувство, что в язык нет необходимости глубоко погружаться при написании на нем кода, но, как показывает этот краткий обзор, эта необходимость все же существует. Видео доклада: Если статья оказалась полезной, не забывайте нажимать Like