Создание пользовательских форматтеров

Стандартный набор методов Intl покрывает большинство задач локализации: форматирование чисел, дат, валют, списков, относительного времени и единиц измерения. Однако в крупных приложениях прямое использование Intl.NumberFormat, Intl.DateTimeFormat или других объектов быстро приводит к дублированию кода, сложностям поддержки и несогласованности интерфейса.

Пользовательский форматтер — это абстракция над Intl API, скрывающая детали локали, настроек и повторяющейся логики.

Базовая проблема прямого использования Intl:

const price = new Intl.NumberFormat('ru-RU', {
  style: 'currency',
  currency: 'RUB'
}).format(1500)

const shortDate = new Intl.DateTimeFormat('ru-RU', {
  dateStyle: 'short'
}).format(new Date())

При увеличении количества экранов подобный код начинает повторяться десятки раз.

Более масштабируемый подход:

const formatters = {
  money(value) {
    return new Intl.NumberFormat('ru-RU', {
      style: 'currency',
      currency: 'RUB'
    }).format(value)
  },

  shortDate(value) {
    return new Intl.DateTimeFormat('ru-RU', {
      dateStyle: 'short'
    }).format(value)
  }
}

Использование:

formatters.money(4500)
formatters.shortDate(new Date())

Проблема производительности

Создание экземпляров Intl является сравнительно дорогой операцией. Особенно это заметно при рендеринге таблиц, списков и больших наборов данных.

Неэффективный вариант:

function formatPrice(value) {
  return new Intl.NumberFormat('ru-RU', {
    style: 'currency',
    currency: 'RUB'
  }).format(value)
}

При каждом вызове создаётся новый объект форматтера.

Правильный подход — кеширование:

const moneyFormatter = new Intl.NumberFormat('ru-RU', {
  style: 'currency',
  currency: 'RUB'
})

function formatPrice(value) {
  return moneyFormatter.format(value)
}

Фабрика форматтеров

При работе с несколькими локалями удобнее создавать фабрику форматтеров.

Пример:

function createFormatters(locale) {
  const currency = new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: 'USD'
  })

  const date = new Intl.DateTimeFormat(locale, {
    dateStyle: 'long'
  })

  return {
    currency(value) {
      return currency.format(value)
    },

    date(value) {
      return date.format(value)
    }
  }
}

Использование:

const ru = createFormatters('ru-RU')
const en = createFormatters('en-US')

ru.currency(1200)
en.currency(1200)

Универсальный кеш форматтеров

В больших приложениях создают централизованный реестр.

Пример универсального кеша:

const formatterCache = new Map()

function getNumberFormatter(locale, options) {
  const key = JSON.stringify([locale, options])

  if (!formatterCache.has(key)) {
    formatterCache.set(
      key,
      new Intl.NumberFormat(locale, options)
    )
  }

  return formatterCache.get(key)
}

Использование:

const formatter = getNumberFormatter('ru-RU', {
  style: 'currency',
  currency: 'RUB'
})

formatter.format(5000)

Создание форматтера валют

Форматирование валют почти всегда требует дополнительных правил.

Базовый вариант:

function createCurrencyFormatter(locale, currency) {
  const formatter = new Intl.NumberFormat(locale, {
    style: 'currency',
    currency
  })

  return value => formatter.format(value)
}

Использование:

const rub = createCurrencyFormatter('ru-RU', 'RUB')
const usd = createCurrencyFormatter('en-US', 'USD')

rub(1000)
usd(1000)

Форматтер с настройками округления

function createMoneyFormatter(locale, currency) {
  const formatter = new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
    minimumFractionDigits: 2,
    maximumFractionDigits: 2
  })

  return value => formatter.format(value)
}

Пользовательские правила отображения валют

Иногда требуется скрывать копейки или центы.

Пример:

function formatMoney(value, locale, currency) {
  const hasFraction = value % 1 !== 0

  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
    minimumFractionDigits: hasFraction ? 2 : 0,
    maximumFractionDigits: 2
  }).format(value)
}

Форматтер процентов

function createPercentFormatter(locale) {
  const formatter = new Intl.NumberFormat(locale, {
    style: 'percent',
    maximumFractionDigits: 1
  })

  return value => formatter.format(value)
}

Использование:

const percent = createPercentFormatter('ru-RU')

percent(0.25)

Форматтер компактных чисел

Intl.NumberFormat поддерживает сокращённую запись.

const compact = new Intl.NumberFormat('ru-RU', {
  notation: 'compact'
})

compact.format(1500000)

Пользовательская обёртка:

function createCompactFormatter(locale) {
  const formatter = new Intl.NumberFormat(locale, {
    notation: 'compact',
    maximumFractionDigits: 1
  })

  return value => formatter.format(value)
}

Форматтер дат

Базовая фабрика:

function createDateFormatter(locale, options = {}) {
  const formatter = new Intl.DateTimeFormat(locale, options)

  return value => formatter.format(value)
}

Использование:

const longDate = createDateFormatter('ru-RU', {
  dateStyle: 'full'
})

longDate(new Date())

Предустановленные стили форматирования

Практика крупных проектов — создание единого словаря форматов.

const dateFormats = {
  short: {
    dateStyle: 'short'
  },

  medium: {
    dateStyle: 'medium'
  },

  full: {
    dateStyle: 'full'
  }
}

Создание форматтера:

function createNamedDateFormatter(locale, preset) {
  return new Intl.DateTimeFormat(
    locale,
    dateFormats[preset]
  )
}

Форматирование времени

function createTimeFormatter(locale) {
  const formatter = new Intl.DateTimeFormat(locale, {
    timeStyle: 'short'
  })

  return value => formatter.format(value)
}

Форматирование даты и времени одновременно

const formatter = new Intl.DateTimeFormat('ru-RU', {
  dateStyle: 'medium',
  timeStyle: 'short'
})

formatter.format(new Date())

Форматтер относительного времени

Intl.RelativeTimeFormat используется для отображения строк вроде:

  • «5 минут назад»
  • «через 2 дня»

Создание форматтера:

function createRelativeFormatter(locale) {
  const formatter = new Intl.RelativeTimeFormat(locale, {
    numeric: 'auto'
  })

  return {
    minutes(value) {
      return formatter.format(value, 'minute')
    },

    days(value) {
      return formatter.format(value, 'day')
    }
  }
}

Использование:

const relative = createRelativeFormatter('ru-RU')

relative.minutes(-5)
relative.days(2)

Автоматическое определение единицы времени

function formatRelativeDate(date, locale = 'ru-RU') {
  const diff = date - Date.now()

  const minutes = Math.round(diff / 60000)
  const hours = Math.round(diff / 3600000)
  const days = Math.round(diff / 86400000)

  const formatter = new Intl.RelativeTimeFormat(locale, {
    numeric: 'auto'
  })

  if (Math.abs(minutes) < 60) {
    return formatter.format(minutes, 'minute')
  }

  if (Math.abs(hours) < 24) {
    return formatter.format(hours, 'hour')
  }

  return formatter.format(days, 'day')
}

Форматтер списков

Intl.ListFormat позволяет корректно объединять элементы списка.

const formatter = new Intl.ListFormat('ru-RU', {
  style: 'long',
  type: 'conjunction'
})

formatter.format(['JavaScript', 'TypeScript', 'Rust'])

Результат:

JavaScript, TypeScript и Rust

Пользовательская фабрика:

function createListFormatter(locale, options) {
  const formatter = new Intl.ListFormat(locale, options)

  return items => formatter.format(items)
}

Форматтер единиц измерения

function createUnitFormatter(locale, unit) {
  const formatter = new Intl.NumberFormat(locale, {
    style: 'unit',
    unit
  })

  return value => formatter.format(value)
}

Использование:

const kilometers = createUnitFormatter(
  'ru-RU',
  'kilometer'
)

kilometers(15)

Форматирование диапазонов

Intl.DateTimeFormat поддерживает диапазоны дат.

const formatter = new Intl.DateTimeFormat('ru-RU', {
  dateStyle: 'medium'
})

formatter.formatRange(
  new Date('2025-01-01'),
  new Date('2025-01-10')
)

Пользовательский форматтер диапазонов

function createRangeFormatter(locale, options) {
  const formatter = new Intl.DateTimeFormat(
    locale,
    options
  )

  return (start, end) =>
    formatter.formatRange(start, end)
}

Работа с formatToParts

Метод formatToParts() позволяет получить структуру форматированной строки.

Пример:

const formatter = new Intl.NumberFormat('ru-RU', {
  style: 'currency',
  currency: 'RUB'
})

formatter.formatToParts(1500)

Результат:

[
  { type: 'integer', value: '1' },
  { type: 'group', value: ' ' },
  { type: 'integer', value: '500' },
  { type: 'literal', value: ',' },
  { type: 'fraction', value: '00' },
  { type: 'literal', value: ' ' },
  { type: 'currency', value: '₽' }
]

Создание кастомного отображения валюты

function formatMoneyParts(value) {
  const formatter = new Intl.NumberFormat('ru-RU', {
    style: 'currency',
    currency: 'RUB'
  })

  const parts = formatter.formatToParts(value)

  return parts
    .map(part => {
      if (part.type === 'currency') {
        return `<strong>${part.value}</strong>`
      }

      return part.value
    })
    .join('')
}

Использование formatToParts для стилизации

Пример разделения целой и дробной части:

function formatPrice(value) {
  const formatter = new Intl.NumberFormat('ru-RU', {
    style: 'currency',
    currency: 'RUB'
  })

  const parts = formatter.formatToParts(value)

  let integer = ''
  let fraction = ''
  let currency = ''

  for (const part of parts) {
    if (part.type === 'integer' || part.type === 'group') {
      integer += part.value
    }

    if (part.type === 'fraction') {
      fraction = part.value
    }

    if (part.type === 'currency') {
      currency = part.value
    }
  }

  return `
    <span class="price-main">${integer}</span>
    <span class="price-fraction">${fraction}</span>
    <span class="price-currency">${currency}</span>
  `
}

Композиция форматтеров

Форматтеры удобно комбинировать.

function createFormatService(locale) {
  const money = new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: 'USD'
  })

  const date = new Intl.DateTimeFormat(locale, {
    dateStyle: 'medium'
  })

  const relative = new Intl.RelativeTimeFormat(locale)

  return {
    money: value => money.format(value),

    date: value => date.format(value),

    relativeDays: value =>
      relative.format(value, 'day')
  }
}

Dependency Injection для форматтеров

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

Пример:

class FormatterService {
  constructor(locale) {
    this.locale = locale

    this.moneyFormatter =
      new Intl.NumberFormat(locale, {
        style: 'currency',
        currency: 'USD'
      })
  }

  money(value) {
    return this.moneyFormatter.format(value)
  }
}

Динамическое переключение локали

Проблема:

const formatter = new Intl.NumberFormat(currentLocale)

После изменения локали форматтер останется прежним.

Правильный подход:

class LocaleManager {
  constructor(locale) {
    this.setLocale(locale)
  }

  setLocale(locale) {
    this.locale = locale

    this.numberFormatter =
      new Intl.NumberFormat(locale)
  }

  formatNumber(value) {
    return this.numberFormatter.format(value)
  }
}

Ленивое создание форматтеров

class FormatterRegistry {
  constructor(locale) {
    this.locale = locale
    this.cache = new Map()
  }

  getCurrencyFormatter(currency) {
    const key = `currency:${currency}`

    if (!this.cache.has(key)) {
      this.cache.set(
        key,
        new Intl.NumberFormat(this.locale, {
          style: 'currency',
          currency
        })
      )
    }

    return this.cache.get(key)
  }
}

Создание адаптера локализации

class I18nAdapter {
  constructor(locale) {
    this.locale = locale
  }

  formatPrice(value) {
    return new Intl.NumberFormat(this.locale, {
      style: 'currency',
      currency: 'USD'
    }).format(value)
  }

  formatDate(value) {
    return new Intl.DateTimeFormat(this.locale, {
      dateStyle: 'long'
    }).format(value)
  }
}

Типизация форматтеров в TypeScript

type CurrencyFormatter = (value: number) => string

function createCurrencyFormatter(
  locale: string,
  currency: string
): CurrencyFormatter {
  const formatter = new Intl.NumberFormat(locale, {
    style: 'currency',
    currency
  })

  return value => formatter.format(value)
}

Интерфейс сервиса форматирования

interface Formatters {
  money(value: number): string
  date(value: Date): string
  percent(value: number): string
}

Generic-фабрики

function createFormatter<T>(
  factory: () => T
): () => T {
  let instance: T | null = null

  return () => {
    if (!instance) {
      instance = factory()
    }

    return instance
  }
}

Использование:

const getFormatter = createFormatter(
  () =>
    new Intl.NumberFormat('ru-RU')
)

getFormatter().format(1000)

Обработка ошибок локали

Некорректная локаль может вызвать проблемы в старых окружениях.

Проверка поддержки:

function isLocaleSupported(locale) {
  return Intl.NumberFormat.supportedLocalesOf([
    locale
  ]).length > 0
}

Резервная локаль

function createSafeFormatter(locale) {
  const safeLocale =
    isLocaleSupported(locale)
      ? locale
      : 'en-US'

  return new Intl.NumberFormat(safeLocale)
}

Централизованная система форматирования

Типичная архитектура:

const format = {
  money: createCurrencyFormatter(
    'ru-RU',
    'RUB'
  ),

  percent: createPercentFormatter(
    'ru-RU'
  ),

  date: createDateFormatter('ru-RU', {
    dateStyle: 'medium'
  })
}

Использование:

format.money(5000)
format.percent(0.15)
format.date(new Date())

Основные принципы проектирования пользовательских форматтеров

Единая точка настройки

Все параметры локализации должны находиться централизованно.

Повторное использование экземпляров

Создание объектов Intl должно происходить минимальное количество раз.

Изоляция логики форматирования

Компоненты интерфейса не должны содержать настройки Intl.

Поддержка локалей

Все форматтеры обязаны корректно работать при смене языка и региона.

Масштабируемость

Добавление новых типов форматирования не должно требовать переписывания существующей архитектуры.