Вес 0.7000000000000001 кг: откуда в JavaScript берутся такие числа
- среда, 21 октября 2026 г. в 00:00:04
Вес роутера 0.7000000000000001 кг на М.Видео. Штемпель 0.30000000000000004×2.5 см на Wildberries. Эти числа никто не вводил руками: где-то значение посчитали и забыли округлить.


Если вы хоть раз выкатывали в прод что-то вроде
0.1 + 0.2;
то знаете, какие отзывы бывают после этого.
Разберёмся, откуда берутся такие числа:
0.30000000000000004 0.7000000000000001
Ровно они и попали в карточки выше.
Постараюсь объяснить понятно, а не исчерпывающе. Цель в том, чтобы разобраться мог и дизайнер, и тестировщик, и бэкенд-разработчик, а не только JS-разработчик.
Вот что JavaScript спокойно возвращает:
1.1 * 3 // 3.3000000000000003 0.57 * 100 // 56.99999999999999 4.35 * 100 // 434.99999999999994 0.1 + 0.7 // 0.7999999999999999
Возьмём обычный калькулятор:
10 / 3 = 3.333333333333...
1/3 нельзя точно записать конечным числом цифр в десятичной системе.
На это никто не обижается: мы к этому привыкли.
Внутри компьютера происходит то же самое. Просто проблема не с 1/3.
Обычный Number в JavaScript хранится в формате IEEE 754 binary64. На самом базовом уровне это значит, что число представлено битами:
0 1
Например:
0 → 0 1 → 1 2 → 10 3 → 11 4 → 100 5 → 101 6 → 110 7 → 111 8 → 1000
С небольшими целыми всё просто: они представляются точно. А что с дробями?
Для дробной части есть простой алгоритм.
Берём число и умножаем на 2. Целую часть записываем как очередной бит.
0.25 × 2 = 0.5 → 0 0.5 × 2 = 1.0 → 1
Дробная часть стала 0, останавливаемся:
0.25₁₀ = 0.01₂
Почему умножаем именно на 2? Потому что умножение на основание системы просто сдвигает запятую. В десятичной системе 3.14 × 10 = 31.4: цифра перепрыгнула через запятую. Умножение на два делает то же самое в двоичной системе. Каждый шаг выталкивает очередной бит, и мы его записываем.
Здесь начинается самое интересное.
0.1 × 2 = 0.2 → 0 0.2 × 2 = 0.4 → 0 0.4 × 2 = 0.8 → 0 0.8 × 2 = 1.6 → 1 0.6 × 2 = 1.2 → 1 0.2 × 2 = 0.4 → 0 0.4 × 2 = 0.8 → 0 0.8 × 2 = 1.6 → 1 ...
Мы снова и снова попадаем на одни и те же значения. Получается:
0.1₁₀ = 0.0001100110011001100110011...₂
И это 1100 повторяется бесконечно.
С этого и начинается вся проблема.
JavaScript может показать число в двоичном виде:
(0.1).toString(2);
Получим:
0.0001100110011001100110011001100110011001100110011001101
Важная оговорка: toString(2) не показывает буквально 64 бита, которые лежат в памяти. Это строковое представление числа в двоичной системе.
Но проблему оно показывает отлично: в двоичной системе 0.1 никогда не заканчивается.
(0.1).toFixed(20);
Получаем:
"0.10000000000000000555"
То есть 0.1 это не совсем 0.1. Точнее, математическое 0.1 нельзя точно представить в используемом формате, поэтому JavaScript хранит ближайшее представимое значение.
С другими числами та же история:
(0.2).toFixed(20); // "0.20000000000000001110" (0.3).toFixed(20); // "0.29999999999999998890"
А теперь самое интересное:
(0.1 + 0.2).toFixed(20); // "0.30000000000000004441"
Посмотрите, что происходит по шагам. Оба слагаемых округлились вверх, каждое чуть больше реального значения:
0.1 → 0.10000000000000000555 0.2 → 0.20000000000000001110
Складываются именно эти значения binary64. Операция идёт по правилам арифметики с плавающей точкой, и результат тоже должен быть представим в binary64:
0.1 0.0001100110011001100110011001100110011001100110011001101 0.2 0.0011001100110011001100110011001100110011001100110011010 --------------------------------------------------------- sum 0.0100110011001100110011001100110011001100110011001100111
Чтобы точно записать эту промежуточную сумму, нужно больше значащих битов, чем позволяет binary64. Поэтому результат округляется до ближайшего представимого значения:
0.1 + 0.2 → 0.30000000000000004441
А настоящее 0.3 хранится наоборот, чуть меньше, чем нужно:
0.3 → 0.29999999999999998890
(0.1 + 0.2) === 0.3 // false
Вот и вся история. Оба слагаемых чуть великоваты, сумма округлилась ещё выше, и всё это сравнивается с числом, которое само чуть меньше нужного.
Ещё одна оговорка: toFixed(20) тоже не показывает содержимое памяти. Он просто форматирует уже сохранённое значение с 20 знаками после запятой.
Теперь главное. Формат, в котором JavaScript хранит числа, конечен: ровно 64 бита на число. А наша двоичная дробь бесконечна.
Если интересно, как эти 64 бита устроены внутри, подробности есть на MDN: Number encoding.
Справедливый вопрос: кто решил, что числа надо хранить именно так, и почему все с этим согласились?
До IEEE 754 разные компьютеры и производители могли использовать разные форматы и разные правила работы с плавающей точкой. Вот как это описывает IEEE: форматы зависели от производителя, а индустрия раскололась на двоичный, восьмеричный и шестнадцатеричный лагеря. Одна и та же программа давала разные ответы на разных машинах, а арифметические библиотеки приходилось подгонять под каждую модель. Переносимых численных вычислений просто не существовало.
За это взялся математик из Беркли Уильям Кэхэн. Intel пришла к нему за внутренним корпоративным стандартом, а он убедил руководство разрабатывать арифметику с прицелом на будущий международный стандарт. Так появился IEEE 754, принятый в 1985 году. В 1989 году Кэхэн получил за это премию Тьюринга. В формулировке так прямо и сказано: за работу по созданию стандарта IEEE 754.
Не сравнивайте числа с плавающей точкой через ===.
// это никогда не будет true if (total === 0.3) { // сюда мы не попадём } // а так работает const almostEqual = (a, b) => Math.abs(a - b) <= Number.EPSILON * Math.max(1, Math.abs(a), Math.abs(b)); almostEqual(0.1 + 0.2, 0.3); // true
Деньги, если бизнес-логика позволяет, удобно хранить в минимальных единицах, например в копейках. Целые числа до Number.MAX_SAFE_INTEGER представляются и сравниваются безопасно.
const toKopecks = (rub) => Math.round(rub * 100); toKopecks(0.1) + toKopecks(0.2); // 30
Считайте в целых, а форматируйте для пользователя только при выводе.
Форматируйте через Intl.NumberFormat.
new Intl.NumberFormat("ru-RU", { style: "currency", currency: "RUB", }).format(0.1 + 0.2); // "0,30 ₽"
И не доверяйте toFixed на границах округления. Это самая коварная ловушка, потому что она выглядит как решение:
(1.005).toFixed(2); // "1.00" ← ожидали 1.01 (2.675).toFixed(2); // "2.67" ← ожидали 2.68
Причина та же, что и всегда: 1.005 внутри Number чуть меньше математического 1.005. toFixed() форматирует уже сохранённое значение и округляет по своим правилам. Представление числа он не исправляет.
Представим, что API уже отдал нам вот это:
Вес: 0.7000000000000001 кг
И мы просто выводим это на страницу.
Фронтенд может поставить перед выводом санитайзер, который ищет подозрительно длинные дробные числа и возвращает их в нормальный вид:
const fmt = new Intl.NumberFormat("en-US", { maximumSignificantDigits: 12, useGrouping: false, }); function sanitizeText(text) { return text.replace(/(?<![\d.])\d+\.\d+(?![.\d])/g, (value) => { const [, fraction] = value.split("."); if (fraction.length < 12) { return value; } const number = Number(value); if (!Number.isFinite(number)) { return value; } return fmt.format(number); }); }
Проверяем:
sanitizeText("Вес: 0.7000000000000001 кг"); // "Вес: 0.7 кг" sanitizeText("Штемпель с набором для, 0.30000000000000004×2.5 см синий"); // "Штемпель с набором для, 0.3×2.5 см синий" sanitizeText("версия 1.2.3 вышла"); // "версия 1.2.3 вышла" (не тронуто)
Но это не решение. В лучшем случае экстренная заплатка.
Такой санитайзер нельзя использовать как универсальную обработку чисел. Фронтенд понятия не имеет, что значит вот это:
1.234567890123456789
Это может быть артефакт плавающей точки. А может быть реальное число, где важна каждая цифра.
Поэтому чинить нужно там, где число вычисляется.
Фронтенд может спрятать улики. Но лучше найти виновника.
Изначально я написал этот текст на английском для LinkedIn, для Хабра адаптировал примеры под наши магазины.