Глючный код JavaScript: 10 самых распространенных ошибок, которые допускают разработчики JavaScript

Опубликовано: 2022-03-11

Сегодня JavaScript лежит в основе практически всех современных веб-приложений. В частности, за последние несколько лет наблюдается распространение широкого спектра мощных библиотек и фреймворков на основе JavaScript для разработки одностраничных приложений (SPA), графики и анимации и даже серверных платформ JavaScript. JavaScript действительно стал повсеместным в мире разработки веб-приложений, и поэтому становится все более важным навыком для освоения.

На первый взгляд JavaScript может показаться довольно простым. И действительно, встроить базовые функции JavaScript в веб-страницу — довольно простая задача для любого опытного разработчика программного обеспечения, даже если он новичок в JavaScript. Тем не менее, язык значительно более нюансирован, мощен и сложен, чем можно было бы изначально предположить. Действительно, многие тонкости JavaScript приводят к ряду общих проблем, которые мешают ему работать — 10 из которых мы обсуждаем здесь — которые важно знать и избегать в стремлении стать мастером-разработчиком JavaScript.

Распространенная ошибка №1: неверные ссылки на this

Однажды я услышал, как комик сказал:

На самом деле я не здесь, потому что что здесь, кроме там, без «т»?

Эта шутка во многом характеризует тип путаницы, которая часто возникает у разработчиков в отношении ключевого слова this в JavaScript. Я имею в виду, this действительно так или это что-то совсем другое? Или это не определено?

По мере того, как методы кодирования JavaScript и шаблоны проектирования с годами становились все более изощренными, наблюдалось соответствующее увеличение количества самоссылающихся областей внутри обратных вызовов и замыканий, которые являются довольно распространенным источником путаницы «это/то».

Рассмотрим этот пример фрагмента кода:

 Game.prototype.restart = function () { this.clearLocalStorage(); this.timer = setTimeout(function() { this.clearBoard(); // what is "this"? }, 0); };

Выполнение приведенного выше кода приводит к следующей ошибке:

 Uncaught TypeError: undefined is not a function

Почему?

Все дело в контексте. Причина, по которой вы получаете указанную выше ошибку, заключается в том, что когда вы вызываете setTimeout() , вы фактически вызываете window.setTimeout() . В результате анонимная функция, передаваемая в setTimeout() , определяется в контексте объекта window , у которого нет clearBoard() .

Традиционное, совместимое со старыми браузерами решение состоит в том, чтобы просто сохранить вашу ссылку на this в переменной, которая затем может быть унаследована замыканием; например:

 Game.prototype.restart = function () { this.clearLocalStorage(); var self = this; // save reference to 'this', while it's still this! this.timer = setTimeout(function(){ self.clearBoard(); // oh OK, I do know who 'self' is! }, 0); };

Кроме того, в более новых браузерах вы можете использовать метод bind() для передачи правильной ссылки:

 Game.prototype.restart = function () { this.clearLocalStorage(); this.timer = setTimeout(this.reset.bind(this), 0); // bind to 'this' }; Game.prototype.reset = function(){ this.clearBoard(); // ahhh, back in the context of the right 'this'! };

Распространенная ошибка № 2: Думать, что существует область видимости на уровне блоков

Как обсуждалось в нашем Руководстве по найму JavaScript, распространенный источник путаницы среди разработчиков JavaScript (и, следовательно, общий источник ошибок) — это предположение, что JavaScript создает новую область видимости для каждого блока кода. Хотя это верно для многих других языков, это не так для JavaScript. Рассмотрим, например, следующий код:

 for (var i = 0; i < 10; i++) { /* ... */ } console.log(i); // what will this output?

Если вы предполагаете, что вызов console.log() либо выведет undefined , либо выдаст ошибку, вы ошиблись. Хотите верьте, хотите нет, но он выведет 10 . Почему?

В большинстве других языков приведенный выше код привел бы к ошибке, поскольку «жизнь» (т. е. область действия) переменной i была бы ограничена блоком for . Однако в JavaScript это не так, и переменная i остается в области видимости даже после завершения цикла for , сохраняя свое последнее значение после выхода из цикла. (Такое поведение, кстати, известно как подъем переменной).

Однако стоит отметить , что поддержка блочных областей видимости проникает в JavaScript с помощью нового ключевого слова let . Ключевое слово let уже доступно в JavaScript 1.7 и должно стать официально поддерживаемым ключевым словом JavaScript, начиная с ECMAScript 6.

Новичок в JavaScript? Читайте о прицелах, прототипах и многом другом.

Распространенная ошибка № 3: создание утечек памяти

Утечки памяти — это почти неизбежные проблемы JavaScript, если вы сознательно не программируете, чтобы избежать их. Существует множество способов их возникновения, поэтому мы просто выделим пару наиболее распространенных случаев.

Пример утечки памяти 1: висячие ссылки на несуществующие объекты

Рассмотрим следующий код:

 var theThing = null; var replaceThing = function () { var priorThing = theThing; // hold on to the prior thing var unused = function () { // 'unused' is the only place where 'priorThing' is referenced, // but 'unused' never gets invoked if (priorThing) { console.log("hi"); } }; theThing = { longStr: new Array(1000000).join('*'), // create a 1MB object someMethod: function () { console.log(someMessage); } }; }; setInterval(replaceThing, 1000); // invoke `replaceThing' once every second

Если вы запустите приведенный выше код и проследите за использованием памяти, вы обнаружите массовую утечку памяти, утечка полного мегабайта в секунду! И даже ручной GC не помогает. Таким образом, похоже, что мы longStr каждый раз, когда вызывается replaceThing . Но почему?

Давайте рассмотрим вещи более подробно:

Каждый объект theThing содержит собственный объект longStr 1 МБ. Каждую секунду, когда мы вызываем replaceThing , он удерживает ссылку на предыдущий объект theThing в priorThing . Но мы по-прежнему не думаем, что это будет проблемой, так как каждый раз, ранее priorThing будет разыменовываться (когда priorThing сбрасывается с помощью priorThing = theThing; ). И более того, упоминается только в основном теле replaceThing и в функции unused , которая на самом деле никогда не используется.

Итак, мы снова задаемся вопросом, почему здесь происходит утечка памяти!?

Чтобы понять, что происходит, нам нужно лучше понять, как все работает в JavaScript под капотом. Типичный способ реализации замыканий заключается в том, что каждый объект функции имеет ссылку на объект в стиле словаря, представляющий его лексическую область видимости. Если бы обе функции, определенные внутри replaceThing , фактически использовали priorThing , было бы важно, чтобы они обе получали один и тот же объект, даже если priorThing присваивается снова и снова, поэтому обе функции используют одно и то же лексическое окружение. Но как только переменная используется каким-либо замыканием, она попадает в лексическое окружение, разделяемое всеми замыканиями в этой области. И именно этот маленький нюанс приводит к этой грубой утечке памяти. (Более подробная информация об этом доступна здесь.)

Пример утечки памяти 2: Циклические ссылки

Рассмотрим этот фрагмент кода:

 function addClickHandler(element) { element.click = function onClick(e) { alert("Clicked the " + element.nodeName) } }

Здесь onClick имеет замыкание, которое хранит ссылку на element (через element.nodeName ). Кроме того, присваивая onClick element.click , создается циклическая ссылка; то есть: element -> onClick -> element -> onClick -> element

Интересно, что даже если element будет удален из DOM, циклическая ссылка выше предотвратит сбор element и onClick и, следовательно, утечку памяти.

Как избежать утечек памяти: что нужно знать

Управление памятью в JavaScript (и, в частности, сборка мусора) во многом основано на понятии достижимости объекта.

Следующие объекты считаются доступными и известны как «корни»:

  • Объекты, на которые ссылаются в любом месте текущего стека вызовов (то есть все локальные переменные и параметры в функциях, которые в данный момент вызываются, и все переменные в области закрытия)
  • Все глобальные переменные

Объекты хранятся в памяти, по крайней мере, до тех пор, пока они доступны из любого из корней через ссылку или цепочку ссылок.

В браузере есть сборщик мусора (GC), который очищает память, занятую недоступными объектами; т. е. объекты будут удалены из памяти тогда и только тогда , когда GC считает, что они недоступны. К сожалению, довольно легко получить мертвые «зомби-объекты», которые на самом деле больше не используются, но которые GC все еще считает «достижимыми».

Связанный: Лучшие практики и советы по JavaScript от Toptal Developers

Распространенная ошибка № 4: Путаница в отношении равенства

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

 // All of these evaluate to 'true'! console.log(false == '0'); console.log(null == undefined); console.log(" \t\r\n" == 0); console.log('' == 0); // And these do too! if ({}) // ... if ([]) // ...

Что касается последних двух, несмотря на то, что они пусты (что может привести к мысли, что они будут оцениваться как false ), оба {} и [] на самом деле являются объектами, и любой объект будет приведен к логическому значению true в JavaScript, в соответствии со спецификацией ECMA-262.

Как показывают эти примеры, правила приведения типов иногда могут быть ясны как грязь. Соответственно, если явно не требуется приведение типов, обычно лучше использовать === и !== (а не == и != ), чтобы избежать любых непреднамеренных побочных эффектов приведения типов. ( == и != автоматически выполняют преобразование типов при сравнении двух вещей, тогда как === и !== выполняют то же самое сравнение без преобразования типов.)

И полностью в качестве побочного замечания — но поскольку мы говорим о приведении типов и сравнениях — стоит упомянуть, что сравнение NaN с чем- либо (даже NaN !) всегда возвращает false . Поэтому вы не можете использовать операторы равенства ( == , === , != , !== ), чтобы определить, является ли значение NaN или нет. Вместо этого используйте встроенную глобальную isNaN() :

 console.log(NaN == NaN); // false console.log(NaN === NaN); // false console.log(isNaN(NaN)); // true

Распространенная ошибка № 5: неэффективное манипулирование DOM

JavaScript позволяет относительно легко манипулировать DOM (т. е. добавлять, изменять и удалять элементы), но ничего не делает для того, чтобы сделать это более эффективным.

Типичным примером является код, который добавляет ряд элементов DOM по одному. Добавление элемента DOM — дорогостоящая операция. Код, который последовательно добавляет несколько элементов DOM, неэффективен и, скорее всего, не будет работать должным образом.

Одной из эффективных альтернатив, когда необходимо добавить несколько элементов DOM, является использование вместо этого фрагментов документа, что повышает как эффективность, так и производительность.

Например:

 var div = document.getElementsByTagName("my_div"); var fragment = document.createDocumentFragment(); for (var e = 0; e < elems.length; e++) { // elems previously set to list of elements fragment.appendChild(elems[e]); } div.appendChild(fragment.cloneNode(true));

В дополнение к присущей этому подходу повышенной эффективности создание прикрепленных элементов DOM обходится дорого, тогда как их создание и изменение в отсоединенном состоянии, а затем их присоединение дает гораздо более высокую производительность.

Распространенная ошибка № 6: неправильное использование определений функций внутри циклов for

Рассмотрим этот код:

 var elements = document.getElementsByTagName('input'); var n = elements.length; // assume we have 10 elements for this example for (var i = 0; i < n; i++) { elements[i].onclick = function() { console.log("This is element #" + i); }; }

Исходя из приведенного выше кода, если бы было 10 элементов ввода, щелчок по любому из них отобразил бы «Это элемент № 10»! Это связано с тем, что к тому времени, когда onclick вызывается для любого из элементов, описанный выше цикл for будет завершен, и значение i уже будет равно 10 (для всех из них).

Вот как мы можем исправить вышеуказанные проблемы с кодом, чтобы добиться желаемого поведения:

 var elements = document.getElementsByTagName('input'); var n = elements.length; // assume we have 10 elements for this example var makeHandler = function(num) { // outer function return function() { // inner function console.log("This is element #" + num); }; }; for (var i = 0; i < n; i++) { elements[i].onclick = makeHandler(i+1); }

В этой исправленной версии кода makeHandler немедленно выполняется каждый раз, когда мы проходим через цикл, каждый раз получая текущее значение i+1 и привязывая его к переменной num с областью действия. Внешняя функция возвращает внутреннюю функцию (которая также использует эту переменную num с областью действия), и элемент onclick устанавливается на эту внутреннюю функцию. Это гарантирует, что каждый onclick получает и использует правильное значение i (через переменную num с областью действия).

Распространенная ошибка № 7: Неумение правильно использовать наследование прототипов

Удивительно высокий процент разработчиков JavaScript не может полностью понять и, следовательно, полностью использовать возможности прототипного наследования.

Вот простой пример. Рассмотрим этот код:

 BaseObject = function(name) { if(typeof name !== "undefined") { this.name = name; } else { this.name = 'default' } };

Кажется довольно простым. Если вы укажете имя, используйте его, в противном случае установите имя «по умолчанию»; например:

 var firstObj = new BaseObject(); var secondObj = new BaseObject('unique'); console.log(firstObj.name); // -> Results in 'default' console.log(secondObj.name); // -> Results in 'unique'

Но что, если бы мы сделали это:

 delete secondObj.name;

Тогда мы получим:

 console.log(secondObj.name); // -> Results in 'undefined'

Но не лучше ли было бы вернуться к «по умолчанию»? Это можно легко сделать, если мы изменим исходный код, чтобы использовать прототипное наследование следующим образом:

 BaseObject = function (name) { if(typeof name !== "undefined") { this.name = name; } }; BaseObject.prototype.name = 'default';

В этой версии BaseObject наследует свойство name от своего объекта- prototype , где для него установлено (по умолчанию) значение 'default' . Таким образом, если конструктор вызывается без имени, имя по умолчанию будет иметь значение default . Аналогичным образом, если свойство name удаляется из экземпляра BaseObject , затем будет выполняться поиск в цепочке прототипов, и свойство name будет извлечено из объекта- prototype , где его значение по-прежнему равно 'default' . Итак, теперь мы получаем:

 var thirdObj = new BaseObject('unique'); console.log(thirdObj.name); // -> Results in 'unique' delete thirdObj.name; console.log(thirdObj.name); // -> Results in 'default'

Распространенная ошибка № 8: Создание неправильных ссылок на методы экземпляра

Давайте определим простой объект и создадим его экземпляр следующим образом:

 var MyObject = function() {} MyObject.prototype.whoAmI = function() { console.log(this === window ? "window" : "MyObj"); }; var obj = new MyObject();

Теперь для удобства давайте создадим ссылку на метод whoAmI , предположительно, чтобы мы могли получить к нему доступ просто с помощью whoAmI() , а не более длинного obj.whoAmI() :

 var whoAmI = obj.whoAmI;

И чтобы убедиться, что все выглядит солидно, давайте распечатаем значение нашей новой переменной whoAmI :

 console.log(whoAmI);

Выходы:

 function () { console.log(this === window ? "window" : "MyObj"); }

Окей круто. Выглядит хорошо.

Но теперь посмотрите на разницу, когда мы вызываем obj.whoAmI() сравнению с нашей удобной ссылкой whoAmI() :

 obj.whoAmI(); // outputs "MyObj" (as expected) whoAmI(); // outputs "window" (uh-oh!)

Что пошло не так?

Фейк здесь в том, что когда мы делали присваивание var whoAmI = obj.whoAmI; , новая переменная whoAmI определялась в глобальном пространстве имен. В результате его значение this равно window , а не экземпляру obj MyObject !

Таким образом, если нам действительно нужно создать ссылку на существующий метод объекта, мы должны обязательно сделать это в пространстве имен этого объекта, чтобы сохранить значение this . Один из способов сделать это будет, например, следующим образом:

 var MyObject = function() {} MyObject.prototype.whoAmI = function() { console.log(this === window ? "window" : "MyObj"); }; var obj = new MyObject(); obj.w = obj.whoAmI; // still in the obj namespace obj.whoAmI(); // outputs "MyObj" (as expected) obj.w(); // outputs "MyObj" (as expected)

Распространенная ошибка № 9: Предоставление строки в качестве первого аргумента для setTimeout или setInterval

Для начала давайте кое-что здесь проясним: предоставление строки в качестве первого аргумента для setTimeout или setInterval само по себе не является ошибкой. Это совершенно законный код JavaScript. Проблема здесь больше связана с производительностью и эффективностью. Что редко объясняется, так это то, что внутри, если вы передаете строку в качестве первого аргумента setTimeout или setInterval , она будет передана конструктору функции для преобразования в новую функцию. Этот процесс может быть медленным и неэффективным, и редко необходим.

Альтернативой передаче строки в качестве первого аргумента этих методов является передача функции . Давайте посмотрим на пример.

Таким образом, здесь было бы довольно типичное использование setInterval и setTimeout с передачей строки в качестве первого параметра:

 setInterval("logTime()", 1000); setTimeout("logMessage('" + msgValue + "')", 1000);

Лучшим выбором было бы передать функцию в качестве начального аргумента; например:

 setInterval(logTime, 1000); // passing the logTime function to setInterval setTimeout(function() { // passing an anonymous function to setTimeout logMessage(msgValue); // (msgValue is still accessible in this scope) }, 1000);

Распространенная ошибка № 10: Неиспользование «строгого режима»

Как объясняется в нашем Руководстве по найму JavaScript, «строгий режим» (т. е. включение 'use strict'; в начале ваших исходных файлов JavaScript) — это способ добровольно применить более строгий синтаксический анализ и обработку ошибок в вашем коде JavaScript во время выполнения, а также как сделать его более безопасным.

Хотя, по общему признанию, неиспользование строгого режима само по себе не является «ошибкой», его использование все чаще поощряется, а его упущение все чаще считается дурным тоном.

Вот некоторые ключевые преимущества строгого режима:

  • Облегчает отладку. Ошибки кода, которые в противном случае были бы проигнорированы или завершились бы скрытым сбоем, теперь будут генерировать ошибки или вызывать исключения, предупреждая вас раньше о проблемах в вашем коде и быстрее направляя вас к их источнику.
  • Предотвращает случайные глобальные переменные. Без строгого режима присвоение значения необъявленной переменной автоматически создает глобальную переменную с таким именем. Это одна из самых распространенных ошибок в JavaScript. В строгом режиме попытка сделать это вызывает ошибку.
  • Устраняет this принуждение . Без строгого режима ссылка на this со значением null или undefined автоматически приводится к глобальному значению. Это может привести к большому количеству подделок головы и ошибок типа «выдергивание волос». В строгом режиме ссылка на this значение null или undefined вызывает ошибку.
  • Запрещает повторяющиеся имена свойств или значения параметров. Строгий режим выдает ошибку при обнаружении повторяющегося именованного свойства в объекте (например, var object = {foo: "bar", foo: "baz"}; ) или повторяющегося именованного аргумента для функции (например, function foo(val1, val2, val1){} ), тем самым обнаруживая то, что почти наверняка является ошибкой в ​​​​вашем коде, на отслеживание которой в противном случае вы могли бы потратить много времени.
  • Делает eval() более безопасным. Существуют некоторые различия в поведении eval() в строгом и нестрогом режимах. Наиболее важно то, что в строгом режиме переменные и функции, объявленные внутри оператора eval() , не создаются в содержащей области (они создаются в содержащей области в нестрогом режиме, что также может быть распространенным источником проблем).
  • Выдает ошибку при неправильном использовании delete . Оператор delete (используемый для удаления свойств объектов) нельзя использовать для ненастраиваемых свойств объекта. Нестрогий код автоматически завершится ошибкой при попытке удалить ненастраиваемое свойство, тогда как строгий режим в таком случае выдаст ошибку.

Заворачивать

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

Тщательное ознакомление с нюансами и тонкостями языка является наиболее эффективной стратегией для повышения уровня владения языком и повышения производительности. Избегание многих распространенных ошибок JavaScript поможет вам, когда ваш JavaScript не работает.

Связанный: Обещания JavaScript: руководство с примерами