javascript

Округление и форматирование чисел в React: адаптивный подход

  • вторник, 20 января 2026 г. в 00:00:05
https://habr.com/ru/companies/gnivc/articles/985170/

Всем привет, на связи снова я — Дмитрий, 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 и снижение риска ошибок при восприятии данных. А на фронте мы этим и занимаемся =)