3 JavaScript-ловушки с объяснением
Три ловушки JavaScript: ошибки с плавающей точкой, проверки NaN и await в циклах, с правильными способами исправления.
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 (математика с плавающей точкой)
Discover how at OpenReplay.com.
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() (без приведения) |
|---|---|---|
NaN | true | true |
'NaN' | true | false |
'' | false | false |
[] | false | false |
'45' | false | false |
undefined | true | false |
Используйте 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 мог бы выполнить параллельно.
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