Кэширование форматтеров

Библиотеки интернационализации в JavaScript опираются на встроенные API ECMAScript Internationalization API, которые предоставляют форматирование чисел, дат, валют и текста с учётом локали. Однако создание экземпляров форматтеров вроде Intl.NumberFormat, Intl.DateTimeFormat и особенно компиляция ICU-шаблонов в FormatJS — операции относительно дорогие по времени выполнения. При высокой частоте рендеров интерфейса это становится заметным узким местом, и ключевым способом оптимизации выступает кэширование форматтеров.

Каждый экземпляр Intl-форматтера включает разбор локали, нормализацию опций и подготовку внутренних таблиц правил форматирования. Например:

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

При повторном создании таких объектов без кэша происходит дублирование вычислений. В условиях React-приложений, где компоненты могут перерендериваться десятки и сотни раз, это приводит к накоплению лишней нагрузки.

В FormatJS ситуация усложняется тем, что помимо нативных форматтеров используются ICU-шаблоны сообщений, которые компилируются в функции форматирования.

Кэширование в FormatJS: базовые механизмы

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

В библиотеке react-intl кэширование реализовано через фабрику createIntlCache:

import { createIntl, createIntlCache } fr om 'react-intl';

const cache = createIntlCache();

const intl = createIntl(
  {
    locale: 'ru',
    messages: {
      greeting: 'Привет'
    }
  },
  cache
);

Объект cache используется для хранения:

  • скомпилированных ICU-форматтеров сообщений;
  • экземпляров Intl.* форматтеров;
  • промежуточных структур, связанных с локалью.

Главная идея заключается в том, что повторное использование одного и того же cache-объекта предотвращает повторную компиляцию сообщений и пересоздание форматтеров при одинаковых параметрах.

Принцип локально-ориентированного кэширования

Ключ к эффективному кэшированию — комбинация locale + options.

Для Intl.NumberFormat:

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 имеет ограничения:

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

Нормализация ключей кэша

Для повышения стабильности ключей применяются детерминированные стратегии:

function stableKey(locale, options = {}) {
  const sortedOptions = Object.keys(options)
    .sort()
    .reduce((acc, key) => {
      acc[key] = options[key];
      return acc;
    }, {});

  return `${locale}:${JSON.stringify(sortedOptions)}`;
}

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

Кэширование MessageFormat в FormatJS

FormatJS компилирует ICU-сообщения в функции, которые затем используются для форматирования строк:

import { MessageFormat } fr om 'intl-messageformat';

const msg = new MessageFormat('ru', 'Привет, {name}!');
const formatted = msg.format({ name: 'Иван' });

Компиляция шаблона — наиболее затратная операция. Поэтому кэширование экземпляров MessageFormat даёт значительный прирост производительности.

const messageCache = new Map();

function getMessageFormatter(locale, message) {
  const key = `${locale}:${message}`;

  if (messageCache.has(key)) {
    return messageCache.get(key);
  }

  const formatter = new MessageFormat(locale, message);
  messageCache.set(key, formatter);

  return formatter;
}

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

Ограничение памяти и LRU-кэширование

При неограниченном кэшировании возникает риск роста потребления памяти. Для контроля используется LRU (Least Recently Used) стратегия:

class LRUCache {
  constructor(lim it = 500) {
    this.lim it = limit;
    this.cache = new Map();
  }

  get(key) {
    if (!this.cache.has(key)) return null;

    const value = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, value);

    return value;
  }

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

    this.cache.set(key, value);

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

Применение LRU особенно важно для серверного рендеринга, где количество уникальных комбинаций локалей и сообщений может быть большим.

Кэширование в условиях React-рендеринга

В React-приложениях FormatJS часто используется через IntlProvider. Каждый рендер может теоретически приводить к пересозданию контекста, если cache не стабилен.

import { IntlProvider, createIntlCache } from 'react-intl';

const cache = createIntlCache();

function App({ locale, messages }) {
  return (
    <IntlProvider locale={locale} messages={messages} defaultLocale="en" onEr ror={() => {}} key={locale} _formatters={cache}>
      ...
    </IntlProvider>
  );
}

Ключевой момент — cache должен быть создан один раз на жизненный цикл приложения или на конкретную сессию локали. Пересоздание cache при каждом рендере уничтожает смысл оптимизации.

SSR и изоляция кэша

На серверной стороне кэширование требует изоляции между запросами. Общий глобальный кэш может привести к утечкам данных между пользователями.

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

function createRequestIntl(locale, messages) {
  const cache = createIntlCache();

  return createIntl(
    {
      locale,
      messages
    },
    cache
  );
}

Каждый запрос получает собственный cache-экземпляр, что обеспечивает безопасность и предсказуемость.

Кэширование Intl-форматтеров в зависимости от локали

Важное наблюдение: форматтеры зависят не только от локали, но и от окружения выполнения (Node.js версия ICU, браузер).

Поэтому кэш должен учитывать как минимум:

  • locale (ru-RU, en-US)
  • тип форматтера (NumberFormat, DateTimeFormat)
  • options (precision, style, timeZone)
  • иногда окружение (server/client)

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

function formatKey(type, locale, options) {
  return `${type}:${locale}:${stableStringify(options)}`;
}

Слабые коллекции и автоматическое управление памятью

Для уменьшения риска утечек можно использовать WeakMap, если ключом выступает объект конфигурации:

const formatterCache = new WeakMap();

function getFormatter(options) {
  if (formatterCache.has(options)) {
    return formatterCache.get(options);
  }

  const formatter = new Intl.NumberFormat(options.locale, options);
  formatterCache.set(options, formatter);

  return formatter;
}

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

Кэширование в связке с форматированием дат и времени

Intl.DateTimeFormat является одним из самых дорогих форматтеров из-за сложности календарных правил:

const dateCache = new Map();

function getDateFormatter(locale, options) {
  const key = `${locale}:${JSON.stringify(options)}`;

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

  return dateCache.get(key);
}

При частом отображении списков событий или сообщений чата такой кэш даёт значительное снижение нагрузки на main thread.

Комбинированные стратегии кэширования

На практике используется несколько уровней:

  • кэш ICU-компиляции сообщений;
  • кэш Intl форматтеров;
  • кэш промежуточных вычислений (например, plural rules);
  • мемоизация на уровне React-компонентов.

Комбинированный подход:

const intlCache = createIntlCache();
const formatterCache = new Map();

function getIntl(locale, messages) {
  const key = locale;

  if (!formatterCache.has(key)) {
    formatterCache.set(
      key,
      createIntl({ locale, messages }, intlCache)
    );
  }

  return formatterCache.get(key);
}

Ограничения и потенциальные проблемы кэширования

Чрезмерное кэширование приводит к ряду проблем:

  • рост памяти при множестве уникальных сообщений;
  • устаревание данных при динамическом изменении локали;
  • сложность отладки из-за скрытых зависимостей;
  • возможные коллизии ключей при нестрогой сериализации.

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

Поведение при смене локали

При смене языка важно разделять кэш по пространствам локалей:

const localeCaches = new Map();

function getLocaleCache(locale) {
  if (!localeCaches.has(locale)) {
    localeCaches.set(locale, {
      intl: createIntlCache(),
      formatters: new Map()
    });
  }

  return localeCaches.get(locale);
}

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