Singleton для форматтеров

Проблема повторного создания форматтеров Intl

Объекты семейства IntlIntl.NumberFormat, Intl.DateTimeFormat, Intl.Collator, Intl.RelativeTimeFormat, Intl.ListFormat — относятся к числу наиболее «тяжёлых» объектов в JavaScript-экосистеме. Их создание сопровождается загрузкой и подготовкой локализационных данных ICU, разбором опций форматирования и построением внутренних структур, оптимизированных для последующих операций.

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

Синглтон-подход в контексте Intl чаще всего реализуется не как единственный глобальный экземпляр, а как кэширование по ключу конфигурации: локаль + набор опций.


Создание форматтера включает несколько этапов:

  • нормализацию локали (BCP 47 tags)
  • выбор подходящих правил ICU
  • построение внутренних таблиц форматирования
  • предкомпиляцию шаблонов вывода
  • инициализацию функций форматирования

Даже для простых случаев:

new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'KZT' })

объект несёт ощутимую стоимость инициализации.

При этом сам процесс форматирования после создания — относительно дешёвый. Возникает дисбаланс: дорого создавать, дешево использовать. Именно он и делает кэширование естественным решением.


Синглтон как кэш форматтеров

Вместо классического singleton-объекта применяется «логический синглтон» — хранилище экземпляров, где каждый уникальный набор параметров соответствует одному экземпляру форматтера.

Базовая идея:

  • одинаковые параметры → один и тот же форматтер
  • разные параметры → разные экземпляры
  • повторное использование → отсутствие повторной инициализации ICU

Ключевая проблема: идентификация конфигурации

Основная сложность заключается в том, чтобы корректно и стабильно определить ключ кэша.

Простейший подход:

const cache = new Map();

function getNumberFormatter(locale, options) {
  const key = locale + '|' + JSON.stringify(options || {});
  if (cache.has(key)) return cache.get(key);

  const formatter = new Intl.NumberFormat(locale, options);
  cache.set(key, formatter);
  return formatter;
}

Однако такой подход содержит несколько скрытых проблем:

Нестабильность JSON.stringify

Порядок ключей в объектах не гарантирует стабильность:

{ style: 'currency', currency: 'KZT' }
{ currency: 'KZT', style: 'currency' }

логически идентичны, но сериализуются по-разному.


Нормализация опций

Для устойчивого ключа требуется нормализация:

function stableStringify(obj) {
  if (!obj) return '';
  return Object.keys(obj)
    .sort()
    .map(k => `${k}:${obj[k]}`)
    .join('|');
}

Это уже снижает риск дубликатов в кэше.


Расширенный кэш: форматтеры по типам

Практическая архитектура чаще разделяет кэши по типам Intl.

NumberFormat

const numberCache = new Map();

function getNumberFormatter(locale, options) {
  const key = `${locale}|${stableStringify(options)}`;

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

  return numberCache.get(key);
}

DateTimeFormat

const dateCache = new Map();

function getDateFormatter(locale, options) {
  const key = `${locale}|${stableStringify(options)}`;

  if (!dateCache.has(key)) {
    dateCache.set(key, new Intl.DateTimeFormat(locale, options));
  }

  return dateCache.get(key);
}

Особенность DateTimeFormat заключается в высокой вариативности опций (weekday, month, hour12, timeZone), что увеличивает кардинальность ключей и размер кэша.


Collator

Intl.Collator используется для сортировки строк с учётом локали:

const collatorCache = new Map();

function getCollator(locale, options) {
  const key = `${locale}|${stableStringify(options)}`;

  if (!collatorCache.has(key)) {
    collatorCache.set(key, new Intl.Collator(locale, options));
  }

  return collatorCache.get(key);
}

Особенность коллатора — высокая стоимость создания при больших локалях и сложных правилах сортировки (например, de, tr, ja).


Множественные локали и нормализация

Локаль может быть задана массивом:

['ru-RU', 'en-US']

Порядок имеет значение, но часто бизнес-логика считает набор локалей множеством, а не последовательностью.

Для корректного кэширования требуется нормализация:

function normalizeLocale(locale) {
  if (Array.isArray(locale)) {
    return [...locale].sort().join(',');
  }
  return locale;
}

Глобальный реестр синглтонов

При масштабировании приложений вводится единый слой управления форматтерами:

const formatterRegistry = {
  number: new Map(),
  date: new Map(),
  collator: new Map(),
};

Унифицированный доступ:

function getFormatter(type, locale, options) {
  const cache = formatterRegistry[type];
  const key = `${normalizeLocale(locale)}|${stableStringify(options)}`;

  if (!cache.has(key)) {
    let instance;

    if (type === 'number') {
      instance = new Intl.NumberFormat(locale, options);
    } else if (type === 'date') {
      instance = new Intl.DateTimeFormat(locale, options);
    } else if (type === 'collator') {
      instance = new Intl.Collator(locale, options);
    }

    cache.set(key, instance);
  }

  return cache.get(key);
}

Поведение памяти и рост кэша

Синглтон-кэш решает проблему повторного создания, но создаёт другую — накопление экземпляров.

Особенности:

  • количество комбинаций локаль × опции может быть большим
  • динамические интерфейсы могут порождать тысячи уникальных ключей
  • кэш без ограничений может расти бесконечно

Ограниченные кэши (LRU-подход)

Для контроля памяти применяется ограничение размера:

class LRUCache {
  constructor(limit = 50) {
    this.limit = limit;
    this.map = new Map();
  }

  get(key) {
    if (!this.map.has(key)) return undefined;

    const value = this.map.get(key);
    this.map.delete(key);
    this.map.set(key, value);
    return value;
  }

  set(key, value) {
    if (this.map.has(key)) {
      this.map.delete(key);
    }

    this.map.set(key, value);

    if (this.map.size > this.limit) {
      const firstKey = this.map.keys().next().value;
      this.map.delete(firstKey);
    }
  }
}

Такой подход позволяет удерживать баланс между производительностью и памятью.


Особенности неизменяемости Intl-объектов

Экземпляры Intl:

  • неизменяемы после создания
  • потокобезопасны в рамках JS-движка
  • могут безопасно переиспользоваться

Это делает их идеальными кандидатами для синглтон-кэша.


Частые ошибки при реализации кэширования

Игнорирование локали по умолчанию

new Intl.NumberFormat()

эквивалентно default locale, но при кэшировании может быть потеряна различимость между явным и неявным значением.


Смешивание типов форматтеров в одном кэше

const cache = new Map();

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


Нестабильные ключи

Использование JSON.stringify без нормализации:

  • разный порядок ключей
  • вложенные объекты
  • undefined значения

Оптимизация через предварительное создание

В некоторых системах форматтеры создаются заранее:

const defaultFormatters = {
  ruNumber: new Intl.NumberFormat('ru-RU'),
  ruDate: new Intl.DateTimeFormat('ru-RU'),
};

Этот подход фиксирует набор локалей и опций, полностью устраняя накладные расходы на runtime-инициализацию, но снижает гибкость.


Комбинация с фреймворками

В UI-библиотеках синглтон-кэш форматтеров часто размещается:

  • на уровне модуля
  • в сервисах i18n
  • внутри dependency injection контейнеров

Это позволяет избежать повторной инициализации при каждом рендере компонентов.


Итоговая модель поведения

Синглтон для Intl-форматтеров фактически представляет собой:

  • детерминированный кэш экземпляров
  • ключ = нормализованная локаль + нормализованные опции
  • значение = неизменяемый объект форматтера
  • стратегия контроля памяти через лимиты или сегментацию

Такая модель превращает дорогостоящую операцию инициализации ICU в однократный шаг, а последующее использование — в лёгкий вызов метода форматирования без дополнительных затрат на создание структуры.