
Всем привет, на связи снова я — Дмитрий, React-разработчик. И сегодня хочу поднять тему, которая на первый взгляд кажется простой, но на практике может доставить немало сюрпризов - округление и форматирование чисел в интерфейсе.
Изначально может показаться, что здесь сложного практически ничего нет: есть toFixed() и toLocaleString(), но практика показывает, что реальные интерфейсы почти никогда в это не укладываются.
Почему? Потому что в разных диапазонах чисел пользователи ждут разного поведения.
Именно с этим сталкиваются разработчики при работе с таблицами, отчётами, финансовыми данными и аналитикой.
В этой статье я предложу функцию, которая решает определенные задачи округления и форматирования, и разберу, как она подбирает количество знаков после запятой в зависимости от величины числа
Ниже опишу требования к форматированию, которые легли в основу функции, и покажу, как они отражаются в коде и в интерфейсе.
Требования к округлению и форматированию
Проверка входного значения
Начнем с проверки входного значения. В самом начале нужно отбросить невалидные значения:
если значение равно null, undefined, NaN или Infinity, возвращается символ "–"
если значение строго равно 0, возвращается строка "0"
Намеренно разделяю проверки, потому что, во-первых, ноль и отсутствие данных — разные состояния, и пользователь должен их различать, во-вторых, технические значения вроде NaN или Infinity не должны попадать в UI — они не несут бизнес-смысла и только путают.
Определение диапазона числа
Если значение валидное, функция анализирует абсолютную величину числа, игнорируя знак. Знак важен для отображения, но не влияет на выбор точности.
Числа от 1 до 9 999 отображаются с двумя знаками после запятой, значение округляется до сотых, например 12.3456 → 12,35
Числа больше 10 000 отображаются без дробной части, число округляется до целого, например 123456.78 → 123 457
Для больших значений дробная часть, как правило, не несёт полезной информации, а только ухудшает читаемость.
Числа меньше единицы
Самая сложная и важная часть логики — работа с малыми значениями.
Для чисел меньше "1 функция":
определяет, сколько нулей идёт после запятой до первой значащей цифры
подбирает такое количество знаков после запятой, чтобы первая значащая цифра обязательно была видна
при этом гарантирует минимум 4 знака после запятой
С точки зрения пользователя это позволяет не превращать малые, но важные значения в 0, сохранить смысл числа, обеспечить визуальную стабильность таблиц и отчётов (именно здесь эта функция будет использоваться)
Округление и преобразование в строку
После того как количество знаков после запятой определено, число должно округлиться до нужной точности и, соответственно, если дробная часть не требуется, число округляется до целого. Также, т.к. мы живем в России, десятичный разделитель заменяется с точки на запятую.
Это делается на уровне форматирования, а не данных, чтобы не искажать бизнес-логику.
Форматирование целой части
Целая часть числа форматируется с пробелами между тысячами: 1234567 → 1 234 567
Такой формат значительно повышает читаемость больших чисел и в целом широко распространен и удобен.
Функция
Итак, у меня получилась следующая функция, давайте посмотрим. Точнее две функции: одна для форматирования, другая - для округления. Полный ее код в конце.
Разбор функции
Форматирование целой части числа с пробелами
Начнем с formatWithSpaces
const formatWithSpaces = (numStr: string): string => numStr.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');Здесь все просто – это регулярное выражение, которое ищет каждую позицию перед группой из трёх цифр и вставляет пробел.
Основная функция форматирования
Функция принимает число, которое нужно отобразить, да она поддерживает варианты number, null, undefined и их же проверят и возвращает отформатированную строку, с которой можно работать в UI.
Первые проверки занимаются именно этим – отсеивают невалидные значения.
if (value == null || value == undefined || !Number.isFinite(value)) return '–';
if (value === 0) return '0';А также вторая строка отдельно обрабатывает ноль, чтобы он всегда отображался как "0", а не пустая строка или "–". Это важно, чтобы пользователь видел разницу между нулём и отсутствием данных.
Вложенная функция для вычисления количества знаков после запятой
const calcFractionDigits = (val: number): number => {
const absVal = Math.abs(val);
if (absVal >= 1 && absVal < 10000) return 2;
if (absVal >= 10000) return 0;
const digits = Math.floor(-Math.log10(absVal));
return Math.max(digits + 1, 4);
};Разберём:
Сначала берём абсолютное значение, потому что знак числа не влияет на количество знаков после запятой. Дальше,если числа от 1 до 9 999, возвращаем 2 знака после запятой, а если 10 000 и больше, то по нашему тз отображаются без дробной части, соответственно возвращаем 0.
И вот тут самое интересное, как вычислить количество нулей после запятой у малых чисел.
Вот эта строчка:
const digits = Math.floor(-Math.log10(absVal));Math.log10(x) — это логарифм числа x по основанию 10, то есть отвечает на вопрос: 10 в какой степени даст x? Это то, что нам нужно:
x | log10(x) | Пояснение |
1 | 0 | 10^0 = 1 |
10 | 1 | 10^1 = 10 |
100 | 2 | 10^2 = 100 |
0.1 | -1 | 10^-1 = 0.1 |
0.01 | -2 | 10^-2 = 0.01 |
0.0012 | -2.9208… | 10^-2.9208 ≈ 0.0012 |
Т.к. для для чисел меньше 1 log10(absVal) будет отрицателен, например:
0.01 → log10(0.01) = -2
0.0012 → log10(0.0012) ≈ -2.9208
Соответственно, нужно взять отрицательное значение, чтобы получить положительное число, отражающее количество нулей после запятой до первой значащей цифры.
0.01 → -(-2) = 2
0.0012 → -(-2.9208) ≈ 2.9208
Дальше нам нужно округлить вниз до целого, для этого, логично, используем Math.floor, это нужно, чтобы определить количество «полных» нулей после запятой до первой значащей цифры.
Например: absVal = 0.12, тогда -log10(absVal) = 0.9208… и Math.floor(-log10(absVal)) = 0 – получается 0 нулей, первая цифра сразу после запятой.
Возьмем еще для примера absVal= 0.0012 его -log10(absVal) = 2.9208…, а Math.floor(-log10(absVal)) = 2. Итого получаем 2 нуля, первая значащая цифра на четвёртой позиции.
Т.е в общем и целом digits = количество нулей после запятой перед первой значащей цифрой.
А дальше это используется так, что после вычисления digits мы добавляем 1 и берём минимум 4 знака, чтобы определить количество знаков после запятой для toFixed.
В этой строчке
return Math.max(digits + 1, 4);Число | digits | digits + 1 | fractionDigits |
0.12 | 0 | 1 | 4 |
0.0012 | 2 | 3 | 4 |
0.0000123 | 4 | 5 | 5 |
Таким образом, первая значащая цифра никогда не теряется, а малые числа не превращаются в ноль.
Определение точности и округление
const fractionDigits = calcFractionDigits(value);
const fixed = fractionDigits === 0
? Math.round(value).toString()
: value.toFixed(fractionDigits);
Вызываем calcFractionDigits и получаем количество знаков после запятой для данного числа. Если дробная часть не нужна (fractionDigits === 0), просто округляем Math.round. Иначе используем toFixed(fractionDigits), чтобы получить строку с нужной точностью.
Разделение целой и дробной части
const [intPart, fracPart] = fixed.split('.');Разделяем число на целую часть (intPart) и дробную часть (fracPart). Это нужно, чтобы отдельно отформатировать целую часть пробелами, а дробную оставить как есть.
Форматирование целой части
const formattedInt = formatWithSpaces(intPart);Применяем функцию, которую рассмотрели в начале, чтобы разделить цифры тысяч пробелами регулярным выражением.
Собираем финальный результат
Осталось все собрать в финальную строку
return fracPart ? `${formattedInt},${fracPart}` : formattedInt;Здесь получается, если есть дробная часть — соединяем с целой через запятую, как принято в России. Если дробной части нет — возвращаем только целую часть. Таким образом, мы получаем читаемое, аккуратное и предсказуемое отображение числа для всех диапазонов.
Полная функция
const formatWithSpaces = (numStr: string): string => numStr.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
const formatAdaptiveNumber = (value?: number | null | undefined): string => {
if (value == null || value == undefined || !Number.isFinite(value)) return '–';
if (value === 0) return '0';
const calcFractionDigits = (val: number): number => {
const absVal = Math.abs(val);
if (absVal >= 1 && absVal < 10000) return 2;
if (absVal >= 10000) return 0;
const digits = Math.floor(-Math.log10(absVal));
return Math.max(digits + 1, 4);
};
const fractionDigits = calcFractionDigits(value);
const fixed = fractionDigits === 0
? Math.round(value).toString()
: value.toFixed(fractionDigits);
const [intPart, fracPart] = fixed.split('.');
const formattedInt = formatWithSpaces(intPart);
return fracPart ? `${formattedInt},${fracPart}` : formattedInt;
};
Я вывел в табличку результаты отработки функции, получилось так:

Возможное улучшение
Функцию можно доработать для поддержки единиц измерения и масштабирования чисел, например, отображать числа в тысячах, миллионах или триллионах, если значения очень большие или автоматически подставлять суффикс и сокращать количество цифр для удобного восприятия. Оставим это на вдохновение, на потом.
Заключение
В этой статье мы разобрали задачу, которая на первый взгляд кажется простой — округление и форматирование чисел, но на практике бывает сложной из-за определенных требований. Эта функция может быть полезна в таблицах, отчётах, финансовых и аналитических интерфейсах, где важна точность, аккуратность и предсказуемость отображения чисел. В итоге, правильное форматирование чисел — это не просто «красиво», это улучшение UX и снижение риска ошибок при восприятии данных. А на фронте мы этим и занимаемся =)
