Сначала JavaScript может показаться довольно простым языком программирования. Однако он гораздо более сложный, чем можно предположить на первый взгляд. Многие тонкости JavaScript приводят к ряду распространенных проблем и мешают коду вести себя так, как задумано. В этой статье мы рассмотрим типичные сложности, с которыми сталкиваются JavaScript-разработчики.
Проблема №1. Неправильные ссылки на this
Среди разработчиков JavaScript часто возникает путаница с ключевым словом this.
По мере того как методы написания кода и шаблоны проектирования JavaScript становились всё более сложными, наблюдается увеличение количества самоссылочных областей видимости внутри колбэков и замыканий, которые являются довольно распространённым источником проблем с ключевым словом this, вызывая ошибки в JavaScript.
Рассмотрим следующий пример кода:
Выполнение данного кода приводит к следующей ошибке:
Почему? Всё дело в контексте. Ошибка возникает потому, что при вызове setTimeout() вы на самом деле вызываете window.setTimeout(). В результате анонимная функция, передаваемая в setTimeout(), определяется в контексте объекта window, у которого нет метода clearBoard().
Традиционное решение, совместимое со старыми браузерами, состоит в том, чтобы сохранить ссылку на this в переменной, которая затем будет унаследована замыканием, например:
Альтернативно, в новых браузерах можно использовать метод bind(), чтобы передать правильную ссылку:
Проблема №2. Предположение о наличии блочной области видимости
Как обсуждалось в нашем руководстве по найму JavaScript-разработчиков, распространённым источником путаницы среди JavaScript-разработчиков (а значит, и частой причиной ошибок) является предположение, что в JavaScript создаётся новая область видимости для каждого блока кода. Хотя это верно для многих других языков, в JavaScript это не так. Рассмотрим следующий пример кода:
Если вы подумали, что вызов console.log() выведет либо undefined, либо вызовет ошибку, вы ошиблись. Хотите верьте, хотите нет, но результатом будет 10. Почему?
В большинстве других языков приведённый выше код привёл бы к ошибке, так как "жизнь" (то есть область видимости) переменной i была бы ограничена циклом for. В JavaScript же это не так, и переменная i остаётся доступной даже после завершения цикла, сохраняя своё последнее значение после выхода из цикла. (Такое поведение называется подъёмом переменных, или variable hoisting.)
Поддержка блочных областей видимости в JavaScript возможна с помощью ключевого слова let. Ключевое слово let уже давно поддерживается браузерами и серверными движками JavaScript, такими как Node.js. Если это новость для вас, стоит потратить время на изучение областей видимости, прототипов и других важных аспектов.
Проблема №3. Создание утечек памяти
Утечки памяти в JavaScript практически неизбежны, если вы сознательно не пишете код, чтобы их избегать. Существует множество способов возникновения таких утечек, и мы рассмотрим два из самых распространенных примеров.
Пример утечки памяти 1: «Зависшие» ссылки на устаревшие объекты
Примечание: Этот пример относится только к устаревшим движкам JavaScript — современные имеют сборщики мусора (GC), которые достаточно умны, чтобы справляться с такими случаями.
Рассмотрим следующий код:
Если запустить этот код и отслеживать использование памяти, вы обнаружите значительную утечку — 1 мегабайт каждую секунду! Даже ручной сборщик мусора не помогает. Похоже, что при каждом вызове replaceThing утечка возникает из-за longStr. Но почему?
Давайте разберемся подробнее:
Каждый объект theThing содержит собственный объект longStr размером 1 МБ. Каждую секунду, при вызове replaceThing, сохраняется ссылка на предыдущий объект theThing в переменной priorThing. Но, на первый взгляд, это не должно быть проблемой, так как каждый раз предыдущий объект priorThing теряет свою ссылку (когда priorThing переназначается на theThing). Кроме того, он используется только в основном теле функции replaceThing и в функции unused, которая, фактически, никогда не используется.
Так почему же возникает утечка памяти?
Чтобы понять это, нужно глубже разобраться в работе JavaScript. Замыкания обычно реализуются так, что каждая функция ссылается на объект, представляющий её лексическую область видимости. Если обе функции внутри replaceThing используют priorThing, важно, чтобы они обе получили один и тот же объект, даже если priorThing переназначается снова и снова, чтобы обе функции имели общую лексическую область. Но как только переменная используется в замыкании, она попадает в общую лексическую область, используемую всеми замыканиями в этом контексте. Этот нюанс и приводит к этой сложной утечке памяти.
Пример утечки памяти 2: Циклические ссылки
Рассмотрим следующий фрагмент кода:
Здесь функция onClick имеет замыкание, которое сохраняет ссылку на element (через element.nodeName). При назначении onClick элементу element.click создается циклическая ссылка: element ? onClick ? element ? onClick ? element…
Интересно, что даже если элемент удален из DOM, эта циклическая ссылка предотвратит сборку мусора для element и onClick, что приведет к утечке памяти.
Как избежать утечек памяти: Основы
Управление памятью в JavaScript (и, в частности, сборка мусора) в основном основано на концепции достижимости объектов.
Следующие объекты считаются достижимыми и известны как "корни":
- Объекты, на которые есть ссылки из текущего стека вызовов (то есть все локальные переменные и параметры в функциях, которые в данный момент выполняются, а также все переменные в области видимости замыкания).
- Все глобальные переменные.
Объекты остаются в памяти до тех пор, пока они доступны из любого из корней через прямую ссылку или цепочку ссылок.
В браузерах есть сборщик мусора, который очищает память, занятую недостижимыми объектами. Иными словами, объекты удаляются из памяти только в том случае, если сборщик мусора считает, что они недостижимы. К сожалению, легко столкнуться с "зомби"-объектами, которые больше не используются, но сборщик мусора все еще считает их достижимыми.
Проблема №4. Путаница с равенством
Одним из удобств JavaScript является автоматическое приведение любого значения, используемого в логическом контексте, к булевому типу. Однако в некоторых случаях это может быть столь же запутанным, сколь и удобным. Например, следующие выражения часто вызывают трудности у многих разработчиков на JavaScript:
Что касается последних двух выражений, несмотря на то, что они пустые (что могло бы заставить вас думать, что они будут оценены как false), как {} (объект), так и [] (массив) на самом деле являются объектами. В соответствии со спецификацией ECMA-262, любое объектное значение в JavaScript будет приведено к булевому значению true.
Как показывают эти примеры, правила приведения типов иногда могут быть крайне запутанными. Соответственно, если явно не требуется приведение типов, обычно рекомендуется использовать операторы === и !== (вместо == и !=) для избежания непреднамеренных побочных эффектов приведения типов. (Операторы == и != автоматически выполняют приведение типов при сравнении, тогда как === и !== сравнивают без приведения типов.)
Поскольку речь идет о приведении типов и сравнении, стоит упомянуть, что сравнение NaN с чем-либо (даже с самим NaN!) всегда возвращает false. Следовательно, нельзя использовать операторы равенства (==, ===, !=, !==) для проверки, является ли значение NaN. Вместо этого используйте встроенную глобальную функцию isNaN():
Проблема №5. Неэффективная манипуляция с DOM
JavaScript делает манипуляции с DOM (т.е. добавление, изменение и удаление элементов) относительно простыми, но не способствует их эффективному выполнению.
Частый пример — код, который добавляет серию DOM-элементов по одному. Добавление DOM-элемента — это затратная операция, и код, который последовательно добавляет несколько элементов, работает неэффективно и может давать сбои.
Одной из эффективных альтернатив при необходимости добавить несколько элементов в DOM является использование document fragments (фрагментов документа), что улучшит производительность и эффективность.
Например:
Кроме явного улучшения эффективности такого подхода, создание прикрепленных DOM-элементов — это ресурсоемкая операция. Однако создание и изменение элементов, пока они отсоединены, а затем их прикрепление к документу значительно повышает производительность.
Проблема №6. Некорректное использование определений функций внутри циклов for
Рассмотрим следующий код:
В приведенном выше коде, если бы у нас было 10 элементов input, при клике на любой из них отображалось бы “Это элемент #10”! Это происходит потому, что к моменту вызова обработчика onclick значение переменной i уже будет равно 10 (для всех элементов).
Вот как можно исправить эту проблему в JavaScript, чтобы добиться желаемого поведения:
В этой исправленной версии кода функция makeHandler выполняется немедленно каждый раз, когда мы проходим через цикл, и каждый раз получает текущее значение i+1 и связывает его с переменной num, которая имеет область видимости. Внешняя функция возвращает внутреннюю функцию (которая также использует эту область видимости для переменной num), и обработчик onclick элемента устанавливается на эту внутреннюю функцию. Это гарантирует, что каждый обработчик onclick получает и использует правильное значение переменной i (через переменную num).
Проблема №7. Неправильное использование прототипного наследования
Необычно большое количество разработчиков JavaScript не полностью понимают и поэтому не используют возможности прототипного наследования.
Вот простой пример:
Это выглядит довольно просто. Если передан параметр name, используется его значение, в противном случае имя устанавливается в ‘default’. Например:
Но что, если мы сделаем так:
Тогда мы получим:
Но разве не было бы лучше, если бы значение вернулось к ‘default’? Это можно легко сделать, если изменить исходный код для использования прототипного наследования следующим образом:
В этой версии объекта BaseObject свойство name наследуется от прототипного объекта, где оно установлено (по умолчанию) на 'default'. Таким образом, если конструктор вызывается без параметра name, имя будет по умолчанию 'default'. Точно так же, если свойство name удаляется из экземпляра BaseObject, то будет выполнен поиск в цепочке прототипов, и свойство name будет получено из прототипного объекта, где его значение по-прежнему 'default'. Таким образом, теперь мы получаем:
Проблема №8. Создание некорректных ссылок на методы экземпляра
Определим простой объект и создадим его экземпляр следующим образом:
Теперь, для удобства, создадим ссылку на метод whoAmI, чтобы мы могли вызывать его просто как whoAmI(), а не как obj.whoAmI():
Чтобы убедиться, что мы сохранили ссылку на функцию, выведем значение нашей новой переменной whoAmI:
Результат:
Пока всё выглядит нормально.
Но обратите внимание на разницу при вызове obj.whoAmI() и нашей ссылки whoAmI():
Что пошло не так? Вызов whoAmI() происходит в глобальном контексте, поэтому this устанавливается в window (или, в строгом режиме, в undefined), а не в экземпляр MyObjectFactory! Другими словами, значение this обычно зависит от контекста вызова.
Стрелочные функции ((params) => {}), в отличие от обычных функций function(params) {}, имеют статическое значение this, которое не зависит от контекста вызова. Это даёт нам обходной путь:
Вы могли заметить, что, хотя вывод совпадает, this является ссылкой на фабрику, а не на экземпляр. Вместо того чтобы дальше исправлять эту проблему, стоит рассмотреть подходы в JavaScript, которые не зависят от this (или даже new), как объяснено в статье "Как JS-разработчику, это то, что не даёт мне спать ночью".
Проблема №9. Передача строки в качестве первого аргумента для setTimeout или setInterval
Для начала, давайте проясним один момент: передача строки в качестве первого аргумента для setTimeout или setInterval сама по себе не является ошибкой. Это вполне законный код JavaScript. Проблема заключается скорее в производительности и эффективности. Часто упускается из виду, что если вы передаете строку в качестве первого аргумента для setTimeout или setInterval, она будет передана конструктору функции для преобразования в новую функцию. Этот процесс может быть медленным и неэффективным, и редко бывает необходим.
Альтернативой передаче строки в качестве первого аргумента для этих методов является передача функции. Рассмотрим пример.
Вот типичное использование setInterval и setTimeout, где передается строка в качестве первого параметра:
Лучший выбор — передать функцию в качестве начального аргумента, например:
Проблема JavaScript № 10: Отсутствие использования «строгого режима»
Как объясняется в нашем Руководстве по найму JavaScript-разработчиков, «строгий режим» (то есть включение 'use strict'; в начале ваших файлов JavaScript) является способом добровольно применить более строгий анализ и обработку ошибок в вашем JavaScript-коде во время выполнения, а также сделать ваш код более безопасным.
Хотя, признаться, отсутствие использования строгого режима не является настоящей «ошибкой», его использование все больше поощряется, а его отсутствие все чаще считается плохим тоном.
Вот некоторые ключевые преимущества строгого режима:
- Упрощает отладку. Ошибки в коде, которые в противном случае были бы проигнорированы или молчали бы, теперь будут генерировать ошибки или выбрасывать исключения, что позволяет быстрее обнаруживать проблемы с JavaScript в вашем коде и направляет вас к их источнику.
- Предотвращает случайные глобальные переменные. Без строгого режима присвоение значения необъявленной переменной автоматически создает глобальную переменную с этим именем. Это одна из самых распространенных ошибок в JavaScript. В строгом режиме попытка сделать это вызывает ошибку.
- Исключает преобразование this. Без строгого режима ссылка на значение this, равное null или undefined, автоматически преобразуется в глобальную переменную globalThis. Это может вызывать множество раздражающих ошибок. В строгом режиме ссылка на значение this, равное null или undefined, вызывает ошибку.
- Не допускает дублирующие имена свойств или значения параметров. Строгий режим выбрасывает ошибку, когда обнаруживает дублирующее имя свойства в объекте (например, var object = {foo: "bar", foo: "baz"};) или дублирующий аргумент функции (например, function foo(val1, val2, val1){};), тем самым обнаруживая почти наверняка ошибку в вашем коде, которую вы могли бы потратить значительное время на её отслеживание.
- Упрощает использование eval(). Есть некоторые различия в поведении eval() в строгом режиме и в нестрогом режиме. Наиболее значительное отличие заключается в том, что в строгом режиме переменные и функции, объявленные внутри eval(), не создаются в содержащей области видимости. (В нестрогом режиме они создаются в содержащей области видимости, что также может быть общим источником проблем в JavaScript.)
- Выбрасывает ошибку при некорректном использовании delete. Оператор delete (используемый для удаления свойств из объектов) не может быть использован для неконфигурируемых свойств объекта. Нестрогий код будет молчаливо терпеть неудачу при попытке удалить неконфигурируемое свойство, в то время как строгий режим выбросит ошибку в таком случае.
Устранение проблем JavaScript с умным подходом
Как и в любой технологии, чем лучше вы понимаете, почему и как JavaScript работает и не работает, тем более надежным будет ваш код, и тем больше вы сможете эффективно использовать настоящую мощь языка. Напротив, недостаток правильного понимания парадигм и концепций JavaScript — это то, где скрывается множество проблем JavaScript. Тщательное знакомство с нюансами и тонкостями языка — это наиболее эффективная стратегия для повышения вашей квалификации и увеличения продуктивности.