12k
All articles

3 JavaScript-ловушки с объяснением

Три ловушки JavaScript: ошибки с плавающей точкой, проверки NaN и await в циклах, с правильными способами исправления.

OpenReplay Team
OpenReplay Team
3 JavaScript-ловушки с объяснением

0.1 + 0.2 не равно 0.3, NaN не равен самому себе, а await внутри цикла способен превратить быструю страницу в медленную — три поведения JavaScript, которые выглядят как баги, но на самом деле являются точным исполнением требований спецификации языка. В этой статье объясняется механизм каждого из них, а не просто демонстрируется странный вывод в консоли, и предлагается правильное решение для каждого случая. Все три проявляются как реальные пользовательские проблемы: итоговая сумма, отличающаяся на копейку, валидация, пропускающая некорректные данные, страница, загружающаяся медленно без видимой причины. Понимание причин позволяет избежать всех трёх в продакшене и чётко ответить на вопросы о них на собеседовании.

Ключевые выводы

  • 0.1 + 0.2 возвращает 0.30000000000000004, потому что числа в формате IEEE 754 double хранятся в двоичной системе, и ни 0.1, ни 0.2 не имеют точного конечного двоичного представления — каждое из них округляется перед сложением.
  • Для денежных вычислений работайте с целыми числами в копейках (центах), а не с числами с плавающей точкой в рублях (долларах); для общего сравнения чисел с плавающей точкой проверяйте Math.abs(a - b) < tolerance, где допуск соответствует порядку величины ваших чисел, а не используйте Number.EPSILON как универсальный порог.
  • NaN === NaN равно false, потому что спецификация IEEE 754 определяет NaN как неравный любому значению, включая себя — используйте Number.isNaN(), который не выполняет приведение типов, вместо глобального isNaN().
  • await внутри цикла for приостанавливает весь цикл на каждой итерации; запускайте промисы заранее и используйте await Promise.all(...) для параллельного выполнения независимых запросов.

Ловушка №1: Почему 0.1 + 0.2 не равно 0.3 (математика с плавающей точкой)

0.1 + 0.2 возвращает 0.30000000000000004, потому что числа в JavaScript — это числа с плавающей точкой двойной точности по стандарту IEEE 754, хранящиеся в двоичной системе, и ни 0.1, ни 0.2 не имеют точного конечного двоичного представления. Каждый литерал округляется до ближайшего представимого 64-битного значения в момент записи, а сумма двух уже округлённых значений округляется до числа, чуть большего 0.3.

0.1 + 0.2;             // 0.30000000000000004
0.1 + 0.2 === 0.3;     // false
(0.1).toPrecision(20); // "0.10000000000000000555"

Последняя строка всё объясняет: значение, хранящееся для 0.1, никогда не было в точности равно 0.1. Это не особенность JavaScript — это свойство чисел с плавающей точкой двойной точности, общее для Python, Java, C и любого другого языка, использующего тот же формат. Число 0.1 в двоичной системе является бесконечной дробью — так же, как 1/3 в десятичной системе равно 0.333…, — поэтому оно должно быть усечено.

Способ исправления зависит от того, что именно вы вычисляете:

// Деньги: работаем с целыми числами в копейках, форматируем только для отображения
const total = 1010 + 2030;   // 3040 копеек
(total / 100).toFixed(2);    // "30.40"

// Общее сравнение: допуск, соответствующий порядку величины
Math.abs((0.1 + 0.2) - 0.3) < Number.EPSILON; // true

Для валюты храните и выполняйте вычисления в целых числах (копейках, центах), а не в числах с плавающей точкой (рублях, долларах), затем делите и применяйте toFixed(2) на уровне отображения. Для приближённого сравнения используйте допуск. Number.EPSILON работает только для чисел порядка 1 — MDN явно предупреждает, что это не универсальный порог, поэтому масштабируйте допуск в соответствии с величиной сравниваемых значений. Если вы суммируете массив, Math.sumPrecise() стал доступен в Baseline в апреле 2026 года, поэтому он может не работать на старых устройствах; кроме того, он всё равно не может устранить проблему точности 0.1 + 0.2 для отдельных литералов — он лишь предотвращает накопление ошибки при длинном суммировании.

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

Ловушка №2: NaN — единственное значение, не равное самому себе

NaN === NaN равно false, потому что стандарт IEEE 754 определяет NaN («Not-a-Number», нечисло) как неравный любому значению, включая себя, и JavaScript следует этому правилу буквально. Это делает NaN единственным значением в языке, не равным самому себе — факт, который можно использовать как детектор NaN.

NaN === NaN;   // false
NaN == NaN;    // false
typeof NaN;    // "number"

typeof NaN возвращает 'number': NaN — это числовое значение, представляющее неопределённый или невычислимый числовой результат, а не отдельный тип. Настоящая ловушка — глобальная функция isNaN(), которая приводит аргумент к числу перед проверкой, поэтому значения, которые вовсе не являются NaN, могут вести себя неожиданно:

isNaN('');   // false  — '' приводится к 0
isNaN([]);   // false  — [] приводится к 0
isNaN('45'); // false  — '45' приводится к 45
isNaN({});   // true   — {} приводится к NaN

Решение — Number.isNaN(), добавленный в ES2015, который не выполняет приведение типов и возвращает true только для фактического значения NaN:

Входное значениеisNaN() (с приведением)Number.isNaN() (без приведения)
NaNtruetrue
'NaN'truefalse
''falsefalse
[]falsefalse
'45'falsefalse
undefinedtruefalse

Используйте Number.isNaN(), когда нужно проверить «является ли это именно значением NaN», и Number.isFinite(), когда нужно убедиться, что значение является «настоящим конечным числом» — он отклоняет NaN, Infinity и нечисловые значения без приведения типов. Стоит также развеять один распространённый миф: parseInt("032") возвращает 32, а не 26. Автоматическое распознавание строк с ведущим нулём как восьмеричных чисел было удалено в ECMAScript 5, поэтому широко тиражируемое утверждение о 26 — это пережиток эпохи до 2011 года. Основание системы счисления всё равно следует передавать явно, но по правильной причине.

Когда валидация срабатывает некорректно из-за того, что isNaN('') приводит пустую строку к 0, проблемное значение в отчёте об ошибке зачастую выглядит «пустым»; воспроизведение реальных нажатий клавиш и состояния поля показывает, что именно прошло через проверку.

Ловушка №3: await в цикле сериализует запросы и замедляет интерфейс

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

// Последовательно: каждый await блокирует следующую итерацию
async function getUsers(ids) {
  const users = [];
  for (const id of ids) {
    users.push(await fetchUser(id)); // ждёт ~1.5с каждый раз
  }
  return users;
}

Решение — запустить все промисы сразу, а затем ожидать их вместе с помощью Promise.all(), который запускает запросы параллельно и завершается, когда все они выполнены:

async function getUsers(ids) {
  return Promise.all(ids.map(fetchUser));
}

При смоделированной фиксированной задержке в 1.5 секунды на запрос (иллюстрирующей сериализацию, а не реальные сетевые задержки) разница масштабируется с размером пакета:

Подход3 запроса10 запросов
await в цикле (последовательно)~4.5с~15с
Promise.all (параллельно)~1.5с~1.5с

Важная оговорка: Promise.all завершается с ошибкой, как только хотя бы один промис отклоняется, отбрасывая результаты остальных. Когда одна ошибка не должна прерывать весь пакет, используйте вместо него Promise.allSettled(), который ожидает завершения каждого промиса и сообщает о каждом как о fulfilled (выполненном) или rejected (отклонённом). Promise.allSettled появился в ES2020 и является базовым во всех современных браузерах с начала 2023 года, поэтому в современных окружениях полифилл не нужен. Используйте последовательное выполнение намеренно только тогда, когда каждый запрос действительно зависит от результата предыдущего.

Страница, которая «просто медленно» загружается у некоторых пользователей, сложно поддаётся обнаружению при ревью кода; запись сессии наглядно показывает запросы, отправляющиеся один за другим вместо параллельного выполнения, что сразу указывает на цикл с await.

Что делать дальше

Все три поведения соответствуют спецификации — именно поэтому они проходят ревью кода и добираются до пользователей: двоичные числа с плавающей точкой округляются, потому что так предписывает IEEE 754, NaN не равен себе, потому что стандарт определяет это именно так, а await сериализует выполнение, потому что именно это означает приостановка исполнения. Защита от них проста и механистична: целые числа в копейках и допуски, масштабированные по величине, для чисел с плавающей точкой; Number.isNaN() и Number.isFinite() для числовых проверок; Promise.all или Promise.allSettled для независимых асинхронных операций. Проверьте собственные вычисления с валютой, защитные механизмы валидации и циклы получения данных на соответствие этим трём паттернам — и класс «невозможных» багов, которые они порождают, перестанет попадать в продакшен.

Часто задаваемые вопросы

В чём разница между глобальным isNaN() и Number.isNaN()?

Глобальный isNaN() приводит аргумент к числу перед проверкой, поэтому isNaN('') и isNaN([]) оба возвращают false, потому что '' и [] приводятся к 0, тогда как isNaN('NaN') возвращает true, потому что 'NaN' приводится к значению NaN. Number.isNaN(), добавленный в ES2015, не выполняет приведения типов и возвращает true только для фактического значения NaN. Используйте Number.isNaN(), когда вам нужно именно обнаружить NaN.

Проблема с плавающей точкой 0.1 + 0.2 возникает только в JavaScript?

Нет. Она возникает в любом языке, использующем числа с плавающей точкой двойной точности по стандарту IEEE 754, включая Python, Java, C, C++ и Ruby. Ни 0.1, ни 0.2 не имеют точного конечного двоичного представления, поэтому каждое из них округляется при хранении, а их сумма округляется до 0.30000000000000004. Это свойство самого формата двоичных чисел с плавающей точкой, а не баг JavaScript, поэтому те же решения — целые числа в копейках и сравнение с допуском — применимы во всех языках.

Когда следует использовать Promise.allSettled вместо Promise.all?

Используйте Promise.allSettled, когда одна ошибка не должна прерывать весь пакет. Promise.all завершается с ошибкой, как только хотя бы один промис отклоняется, и отбрасывает результаты остальных, поэтому он подходит для операций по принципу «всё или ничего». Promise.allSettled ожидает завершения каждого промиса независимо от результата и возвращает массив, сообщающий о каждом как о fulfilled или rejected — это то, что нужно при получении нескольких независимых ресурсов, когда частичный успех приемлем. Он появился в ES2020 и не требует полифилла в современных окружениях.

Бывают ли случаи, когда await внутри цикла оправдан?

Да, когда каждая итерация действительно зависит от результата предыдущей — например, при постраничном обходе API, где следующий запрос требует курсора, возвращённого последним ответом, или при намеренном ограничении частоты запросов во избежание перегрузки сервера. В таких случаях последовательное выполнение является ожидаемым поведением. Ловушка актуальна только для независимых запросов, не зависящих друг от друга, где ожидание в цикле излишне сериализует работу, которую Promise.all мог бы выполнить параллельно.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.