Как получить отдельные части форматированного числа для кастомного отображения

Разделяйте форматированные числа на компоненты, чтобы применять свой стиль и строить сложные интерфейсы

Введение

Метод format() возвращает полностью отформатированную строку, например "$1,234.56" или "1.5M". Это удобно для простого отображения, но вы не сможете по-разному стилизовать отдельные части. Например, нельзя сделать символ валюты жирным, выделить цветом дробную часть или добавить кастомную разметку к определённым компонентам.

В JavaScript есть метод formatToParts(), который решает эту проблему. Вместо одной строки он возвращает массив объектов, каждый из которых представляет отдельную часть форматированного числа. У каждой части есть тип, например currency, integer или decimal, и значение, например $, 1234 или .. Эти части можно обработать, чтобы применить свой стиль, построить сложные макеты или интегрировать форматированные числа в насыщенные интерфейсы.

Почему сложно кастомизировать форматированные строки

Когда вы получаете строку вроде "$1,234.56", сложно понять, где заканчивается символ валюты и начинается число. В разных локалях символы располагаются по-разному, используются разные разделители. Надёжно распарсить такие строки — задача непростая, ведь для этого придётся повторять логику форматирования, которая уже реализована в Intl API.

Представьте дашборд, где суммы денег отображаются с символом валюты другого цвета. Если использовать format(), придётся:

  1. Определять, какие символы — это символ валюты
  2. Учитывать пробелы между символом и числом
  3. Обрабатывать разные позиции символа в разных локалях
  4. Аккуратно парсить строку, чтобы не сломать само число

Этот подход ненадёжен и подвержен ошибкам. Любое изменение правил форматирования локали ломает вашу логику парсинга.

Метод formatToParts() решает эту проблему, предоставляя компоненты отдельно. Вы получаете структурированные данные, которые точно указывают, какая часть за что отвечает, независимо от локали.

Использование formatToParts для получения компонентов числа

Метод formatToParts() работает так же, как format(), за исключением возвращаемого значения. Вы создаёте форматтер с теми же опциями, затем вызываете formatToParts() вместо format().

const formatter = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD"
});

const parts = formatter.formatToParts(1234.56);
console.log(parts);

В результате получается массив объектов:

[
  { type: "currency", value: "$" },
  { type: "integer", value: "1" },
  { type: "group", value: "," },
  { type: "integer", value: "234" },
  { type: "decimal", value: "." },
  { type: "fraction", value: "56" }
]

Каждый объект содержит свойство type, определяющее, что представляет эта часть, и свойство value, содержащее саму строку. Части идут в том же порядке, в каком они появляются в отформатированном выводе.

Это можно проверить, объединив все значения вместе:

const formatted = parts.map(part => part.value).join("");
console.log(formatted);
// Output: "$1,234.56"

Объединённые части дают точно такой же результат, как и вызов format().

Понимание типов частей

Свойство type определяет каждый компонент. Разные параметры форматирования создают разные типы частей.

Для базового форматирования чисел:

const formatter = new Intl.NumberFormat("en-US");
const parts = formatter.formatToParts(1234.56);
console.log(parts);
// [
//   { type: "integer", value: "1" },
//   { type: "group", value: "," },
//   { type: "integer", value: "234" },
//   { type: "decimal", value: "." },
//   { type: "fraction", value: "56" }
// ]

Тип integer обозначает целую часть числа. Несколько частей integer появляются, когда число разделено группировочными разделителями. Тип group обозначает разделитель тысяч. Тип decimal — это десятичная точка. Тип fraction — это цифры после запятой.

Для форматирования валюты:

const formatter = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "EUR"
});

const parts = formatter.formatToParts(1234.56);
console.log(parts);
// [
//   { type: "currency", value: "€" },
//   { type: "integer", value: "1" },
//   { type: "group", value: "," },
//   { type: "integer", value: "234" },
//   { type: "decimal", value: "." },
//   { type: "fraction", value: "56" }
// ]

Тип currency появляется до или после числа в зависимости от конвенций локали.

Для процентов:

const formatter = new Intl.NumberFormat("en-US", {
  style: "percent"
});

const parts = formatter.formatToParts(0.1234);
console.log(parts);
// [
//   { type: "integer", value: "12" },
//   { type: "percentSign", value: "%" }
// ]

Тип percentSign обозначает символ процента.

Для компактной записи:

const formatter = new Intl.NumberFormat("en-US", {
  notation: "compact"
});

const parts = formatter.formatToParts(1500000);
console.log(parts);
// [
//   { type: "integer", value: "1" },
//   { type: "decimal", value: "." },
//   { type: "fraction", value: "5" },
//   { type: "compact", value: "M" }
// ]

Тип compact обозначает индикатор разряда, например K, M или B.

Применение кастомных стилей к частям числа

Основной кейс для formatToParts() — это применение разных стилей к разным компонентам. Можно обработать массив parts и обернуть определённые типы в HTML-элементы.

Выделение символа валюты жирным:

const formatter = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD"
});

const parts = formatter.formatToParts(1234.56);
const html = parts
  .map(part => {
    if (part.type === "currency") {
      return `<strong>${part.value}</strong>`;
    }
    return part.value;
  })
  .join("");

console.log(html);
// Output: "<strong>$</strong>1,234.56"

Этот подход работает с любым языком разметки. Можно генерировать HTML, JSX или любой другой формат, обрабатывая массив parts.

Отдельное оформление дробной части:

const formatter = new Intl.NumberFormat("en-US", {
  minimumFractionDigits: 2
});

const parts = formatter.formatToParts(1234.5);
const html = parts
  .map(part => {
    if (part.type === "decimal" || part.type === "fraction") {
      return `<span class="text-gray-500">${part.value}</span>`;
    }
    return part.value;
  })
  .join("");

console.log(html);
// Output: "1,234<span class="text-gray-500">.50</span>"

Этот паттерн часто встречается в ценниках, где дробная часть отображается мельче или светлее.

Цветовое выделение отрицательных чисел

В финансовых приложениях отрицательные числа часто выделяют красным. С помощью formatToParts() можно обнаружить минус и применить нужный стиль.

const formatter = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD"
});

function formatWithColor(number) {
  const parts = formatter.formatToParts(number);
  const hasMinusSign = parts.some(part => part.type === "minusSign");

  const html = parts
    .map(part => part.value)
    .join("");

  if (hasMinusSign) {
    return `<span class="text-red-600">${html}</span>`;
  }

  return html;
}

console.log(formatWithColor(-1234.56));
// Output: "<span class="text-red-600">-$1,234.56</span>"

console.log(formatWithColor(1234.56));
// Output: "$1,234.56"

Этот подход надёжно определяет отрицательные числа во всех локалях, даже если используются разные символы или позиции для минуса.

Кастомные отображения чисел с разными стилями

В сложных интерфейсах часто комбинируют несколько правил оформления. Можно применять разные классы или элементы к разным типам частей одновременно.

const formatter = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD"
});

function formatCurrency(number) {
  const parts = formatter.formatToParts(number);

  return parts
    .map(part => {
      switch (part.type) {
        case "currency":
          return `<span class="currency-symbol">${part.value}</span>`;
        case "integer":
          return `<span class="integer">${part.value}</span>`;
        case "group":
          return `<span class="group">${part.value}</span>`;
        case "decimal":
          return `<span class="decimal">${part.value}</span>`;
        case "fraction":
          return `<span class="fraction">${part.value}</span>`;
        case "minusSign":
          return `<span class="minus">${part.value}</span>`;
        default:
          return part.value;
      }
    })
    .join("");
}

console.log(formatCurrency(1234.56));
// Output: "<span class="currency-symbol">$</span><span class="integer">1</span><span class="group">,</span><span class="integer">234</span><span class="decimal">.</span><span class="fraction">56</span>"

Такой детальный контроль позволяет точно стилизовать каждую часть. Затем можно использовать CSS для оформления каждого класса по-своему.

Все доступные типы частей

Свойство type может принимать такие значения в зависимости от используемых опций форматирования:

  • integer: Целая часть числа
  • fraction: Дробная часть
  • decimal: Разделитель дробной части
  • group: Разделитель тысяч
  • currency: Символ валюты
  • literal: Пробелы или другой текст, добавленный форматированием
  • percentSign: Символ процента
  • minusSign: Индикатор отрицательного числа
  • plusSign: Индикатор положительного числа (если установлен signDisplay)
  • unit: Строка единицы измерения для форматирования единиц
  • compact: Индикатор разряда в компактной записи (K, M, B)
  • exponentInteger: Значение показателя степени в научной записи
  • exponentMinusSign: Минус в показателе степени
  • exponentSeparator: Символ, разделяющий мантиссу и показатель степени
  • infinity: Обозначение бесконечности
  • nan: Обозначение не-числа (NaN)
  • unknown: Неопознанные токены

Не каждая опция форматирования создаёт все типы частей. Получаемые части зависят от числового значения и настроек форматтера.

Научная запись создаёт части, связанные с показателем степени:

const formatter = new Intl.NumberFormat("en-US", {
  notation: "scientific"
});

const parts = formatter.formatToParts(1234);
console.log(parts);
// [
//   { type: "integer", value: "1" },
//   { type: "decimal", value: "." },
//   { type: "fraction", value: "234" },
//   { type: "exponentSeparator", value: "E" },
//   { type: "exponentInteger", value: "3" }
// ]

Особые значения создают определённые типы частей:

const formatter = new Intl.NumberFormat("en-US");

console.log(formatter.formatToParts(Infinity));
// [{ type: "infinity", value: "∞" }]

console.log(formatter.formatToParts(NaN));
// [{ type: "nan", value: "NaN" }]

Создание доступных отображений чисел

Вы можете использовать formatToParts(), чтобы добавить атрибуты доступности к отформатированным числам. Это помогает скринридерам правильно озвучивать значения.

const formatter = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD"
});

function formatAccessibleCurrency(number) {
  const parts = formatter.formatToParts(number);
  const formatted = parts.map(part => part.value).join("");

  return `<span aria-label="${number} US dollars">${formatted}</span>`;
}

console.log(formatAccessibleCurrency(1234.56));
// Output: "<span aria-label="1234.56 US dollars">$1,234.56</span>"

Это гарантирует, что скринридеры озвучивают и отформатированное значение, и исходное числовое значение с нужным контекстом.

Выделение определённых диапазонов чисел

В некоторых приложениях выделяют числа, попадающие в определённые диапазоны. С помощью formatToParts() можно применять стили в зависимости от значения, сохраняя корректное форматирование.

const formatter = new Intl.NumberFormat("en-US");

function formatWithThreshold(number, threshold) {
  const parts = formatter.formatToParts(number);
  const formatted = parts.map(part => part.value).join("");

  if (number >= threshold) {
    return `<span class="text-green-600 font-bold">${formatted}</span>`;
  }

  return formatted;
}

console.log(formatWithThreshold(1500, 1000));
// Output: "<span class="text-green-600 font-bold">1,500</span>"

console.log(formatWithThreshold(500, 1000));
// Output: "500"

Число получает корректное форматирование для локали, а условное оформление применяется по бизнес-логике.

Когда использовать formatToParts и когда format

Используйте format(), если нужен просто отформатированный текст без дополнительной настройки. Это самый частый случай отображения чисел.

Используйте formatToParts(), если нужно:

  • Применять разные стили к разным частям числа
  • Создавать HTML или JSX с отформатированными числами
  • Добавлять атрибуты или метаданные к отдельным компонентам
  • Встраивать отформатированные числа в сложные макеты
  • Обрабатывать отформатированный вывод программно

Метод formatToParts() немного медленнее, чем format(), потому что создаёт массив объектов, а не одну строку. Эта разница несущественна для обычных приложений, но если вы форматируете тысячи чисел в секунду, format() работает быстрее.

Для большинства приложений выбирайте вариант, исходя из ваших потребностей в стилизации, а не из соображений производительности. Если не нужно настраивать вывод, используйте format(). Если требуется кастомная стилизация или разметка, используйте formatToParts().

Как части сохраняют форматирование, зависящее от локали

Массив parts автоматически поддерживает правила форматирования, зависящие от локали. В разных локалях символы располагаются по-разному и используются разные разделители, но formatToParts() учитывает эти различия.

const usdFormatter = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD"
});

console.log(usdFormatter.formatToParts(1234.56));
// [
//   { type: "currency", value: "$" },
//   { type: "integer", value: "1" },
//   { type: "group", value: "," },
//   { type: "integer", value: "234" },
//   { type: "decimal", value: "." },
//   { type: "fraction", value: "56" }
// ]

const eurFormatter = new Intl.NumberFormat("de-DE", {
  style: "currency",
  currency: "EUR"
});

console.log(eurFormatter.formatToParts(1234.56));
// [
//   { type: "integer", value: "1" },
//   { type: "group", value: "." },
//   { type: "integer", value: "234" },
//   { type: "decimal", value: "," },
//   { type: "fraction", value: "56" },
//   { type: "literal", value: " " },
//   { type: "currency", value: "€" }
// ]

В немецком формате валюта ставится после числа через пробел. Разделитель групп — точка, а десятичный разделитель — запятая. Ваш код стилизации обрабатывает массив parts одинаково для любой локали, а форматирование подстраивается автоматически.

Тип literal обозначает любой пробел или текст, который вставляет форматтер и который не относится к другим категориям. В немецком формате валюты это пробел между числом и символом валюты.

Комбинирование formatToParts с компонентами фреймворков

Современные фреймворки, такие как React, могут использовать formatToParts() для эффективной сборки компонентов.

function CurrencyDisplay({ value, locale, currency }) {
  const formatter = new Intl.NumberFormat(locale, {
    style: "currency",
    currency: currency
  });

  const parts = formatter.formatToParts(value);

  return (
    <span className="currency-display">
      {parts.map((part, index) => {
        if (part.type === "currency") {
          return <strong key={index}>{part.value}</strong>;
        }
        if (part.type === "fraction" || part.type === "decimal") {
          return <span key={index} className="text-sm text-gray-500">{part.value}</span>;
        }
        return <span key={index}>{part.value}</span>;
      })}
    </span>
  );
}

Этот компонент применяет разную стилизацию к разным частям, при этом сохраняет корректное форматирование для любой локали и валюты.