Мемоизация результатов

Интернационализация в JavaScript связана с интенсивным использованием объектов семейства Intl:

  • Intl.NumberFormat
  • Intl.DateTimeFormat
  • Intl.RelativeTimeFormat
  • Intl.PluralRules
  • Intl.ListFormat

Создание экземпляров этих объектов является сравнительно дорогой операцией. При большом количестве повторяющихся форматирований — особенно в React-приложениях — постоянное создание новых formatter-объектов приводит к:

  • лишним аллокациям памяти;
  • нагрузке на garbage collector;
  • деградации производительности интерфейса;
  • повторным вычислениям одинаковых конфигураций локали.

FormatJS решает эту проблему через систему мемоизации.


Что такое мемоизация

Мемоизация — это механизм кеширования результатов функции на основании входных параметров.

Если функция вызывается повторно с теми же аргументами, вместо нового вычисления возвращается ранее сохранённый результат.

Упрощённая схема:

function memoize(fn) {
  const cache = new Map();

  return (...args) => {
    const key = JSON.stringify(args);

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

    const result = fn(...args);

    cache.set(key, result);

    return result;
  };
}

В контексте FormatJS кешируются:

  • formatter-объекты;
  • разобранные ICU-сообщения;
  • конфигурации локалей;
  • результаты компиляции шаблонов.

Проблема повторного создания formatter-объектов

Без кеширования:

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

  return formatter.format(value);
}

При каждом вызове:

formatPrice(100);
formatPrice(200);
formatPrice(300);

создаются новые экземпляры Intl.NumberFormat.

В больших приложениях это становится серьёзной проблемой.


Мемоизация в ядре FormatJS

FormatJS использует внутренний кеш formatter-объектов.

Пример:

import {createIntl, createIntlCache} from '@formatjs/intl';

const cache = createIntlCache();

const intl = createIntl(
  {
    locale: 'ru',
    messages: {}
  },
  cache
);

createIntlCache() создаёт общий кеш для всех formatter-объектов.


Что именно хранится в кеше

FormatJS кеширует:

Тип formatter Класс Intl
Форматирование чисел Intl.NumberFormat
Форматирование дат Intl.DateTimeFormat
Относительное время Intl.RelativeTimeFormat
Плюрализация Intl.PluralRules
Списки Intl.ListFormat
Сообщения ICU Internal message cache

createIntlCache

Назначение

createIntlCache создаёт централизованный объект хранения formatter-экземпляров.

import {createIntlCache} from '@formatjs/intl';

const cache = createIntlCache();

Структура кеша скрыта внутри библиотеки.

Фактически объект содержит несколько внутренних Map.


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

import {createIntl, createIntlCache} from '@formatjs/intl';

const cache = createIntlCache();

const intl = createIntl(
  {
    locale: 'en',
    messages: {}
  },
  cache
);

Теперь при вызовах:

intl.formatNumber(1000);

intl.formatNumber(2000);

intl.formatNumber(3000);

FormatJS повторно использует один и тот же Intl.NumberFormat.


Внутренний механизм кеширования

FormatJS строит ключ кеша из:

  • локали;
  • типа formatter;
  • набора options.

Например:

intl.formatNumber(1000, {
  style: 'currency',
  currency: 'USD'
});

создаёт ключ примерно такого вида:

en-number-style:currency-currency:USD

При повторном вызове:

intl.formatNumber(2000, {
  style: 'currency',
  currency: 'USD'
});

используется уже существующий formatter.


Поведение без createIntlCache

Кеш можно не передавать:

const intl = createIntl({
  locale: 'en',
  messages: {}
});

Однако в этом случае:

  • formatter-объекты будут создаваться повторно;
  • производительность снизится;
  • возрастёт нагрузка на память.

В production-приложениях кеш почти всегда обязателен.


Повторное использование кеша

Один кеш может использоваться несколькими экземплярами intl.

const cache = createIntlCache();

const intlEn = createIntl(
  {
    locale: 'en',
    messages: {}
  },
  cache
);

const intlRu = createIntl(
  {
    locale: 'ru',
    messages: {}
  },
  cache
);

FormatJS автоматически разделяет formatter-объекты по локалям.


Кеширование ICU MessageFormat

Стоимость парсинга ICU

ICU-сообщения требуют:

  1. парсинга строки;
  2. построения AST;
  3. компиляции шаблона;
  4. подготовки formatter-функций.

Сообщение:

Hello, {name}!

не используется напрямую.

Сначала оно преобразуется во внутреннее представление.


Пример ICU-сообщения

intl.formatMessage(
  {
    id: 'welcome',
    defaultMessage: 'Hello, {name}!'
  },
  {
    name: 'John'
  }
);

FormatJS сохраняет результат компиляции сообщения в кеше.

При повторном вызове:

intl.formatMessage(
  {
    id: 'welcome',
    defaultMessage: 'Hello, {name}!'
  },
  {
    name: 'Kate'
  }
);

повторный парсинг уже не выполняется.


Кеширование в React Intl

В react-intl мемоизация играет особенно важную роль.

Причина — частые re-render’ы компонентов.


Проблема без мемоизации

function Price({value}) {
  const formatter = new Intl.NumberFormat('en', {
    style: 'currency',
    currency: 'USD'
  });

  return <span>{formatter.format(value)}</span>;
}

При каждом рендере:

  • создаётся новый formatter;
  • увеличивается нагрузка на GC;
  • ухудшается производительность списка компонентов.

Как React Intl использует кеш

IntlProvider внутри использует memoized intl-object.

<IntlProvider locale="en" messages={messages}>
  <App />
</IntlProvider>

Библиотека переиспользует:

  • formatter-объекты;
  • compiled messages;
  • plural rules.

createIntlCache в React

Для ручного управления:

import {
  createIntl,
  createIntlCache,
  RawIntlProvider
} from 'react-intl';

const cache = createIntlCache();

const intl = createIntl(
  {
    locale: 'en',
    messages
  },
  cache
);

function App() {
  return (
    <RawIntlProvider value={intl}>
      <Main />
    </RawIntlProvider>
  );
}

Такой подход часто используется:

  • в SSR;
  • в unit-тестах;
  • в microfrontend-архитектуре.

SSR и мемоизация

Особенности серверного рендеринга

На сервере:

  • одновременно обрабатываются множество запросов;
  • создаются тысячи formatter-объектов;
  • GC-нагрузка становится критичной.

Мемоизация существенно уменьшает количество объектов.


SSR-кеш

Пример:

const cache = createIntlCache();

export function createRequestIntl(locale, messages) {
  return createIntl(
    {
      locale,
      messages
    },
    cache
  );
}

Общий кеш может использоваться между запросами.


Потенциальные проблемы SSR-кеша

Нельзя сохранять данные пользователя внутри formatter options.

Плохой пример:

intl.formatDate(date, {
  timeZone: user.timezone
});

Если options сильно различаются:

  • кеш разрастается;
  • память расходуется неэффективно.

Размер кеша

FormatJS не реализует автоматическую очистку кеша.

Это означает:

  • кеш растёт по мере появления новых комбинаций options;
  • уникальные конфигурации остаются в памяти.

Пример проблемы

intl.formatNumber(value, {
  minimumFractionDigits: random()
});

Каждый вызов создаёт новый formatter.

Кеш становится бесполезным.


Правильное использование options

Хороший подход

const currencyOptions = {
  style: 'currency',
  currency: 'USD'
};

intl.formatNumber(price, currencyOptions);

Плохой подход

intl.formatNumber(price, {
  style: 'currency',
  currency: dynamicCurrency(),
  minimumFractionDigits: random()
});

Стабильность объектов options

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

Особенно это важно в React.


Антипаттерн

<Component
  options={{
    style: 'currency',
    currency: 'USD'
  }}
/>

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


useMemo

const options = useMemo(
  () => ({
    style: 'currency',
    currency: 'USD'
  }),
  []
);

Мемоизация и useIntl

useIntl возвращает стабильный объект intl.

const intl = useIntl();

Однако formatter options всё ещё требуют аккуратного обращения.


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

FormatJS кеширует также Intl.ListFormat.

intl.formatList(['JavaScript', 'TypeScript', 'Rust']);

Formatter создаётся один раз для конкретной локали и options.


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

intl.formatRelativeTime(-1, 'day');

Используется кешированный Intl.RelativeTimeFormat.


Кеширование plural rules

intl.formatPlural(5);

Внутри используется Intl.PluralRules.

Эти объекты тоже являются дорогостоящими.


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

Без кеша

Типичный сценарий:

  • 10 000 рендеров;
  • 10 000 formatter-объектов;
  • большое количество GC-пауз.

С кешем

  • formatter создаётся один раз;
  • повторно используется;
  • память расходуется значительно эффективнее.

Benchmark-сценарий

Без мемоизации:

console.time('without-cache');

for (let i = 0; i < 100000; i++) {
  new Intl.NumberFormat('en').format(i);
}

console.timeEnd('without-cache');

С мемоизацией:

console.time('with-cache');

const formatter = new Intl.NumberFormat('en');

for (let i = 0; i < 100000; i++) {
  formatter.format(i);
}

console.timeEnd('with-cache');

Разница может составлять десятки раз.


Кеширование и garbage collector

Главная выгода мемоизации — уменьшение краткоживущих объектов.

Без кеша:

  • создаются тысячи formatter-объектов;
  • GC постоянно очищает память;
  • интерфейс может подтормаживать.

С кешем:

  • количество аллокаций резко уменьшается;
  • снижается давление на GC;
  • рендеринг становится стабильнее.

Архитектурные рекомендации

Создание кеша один раз

Правильно:

const cache = createIntlCache();

Неправильно:

function render() {
  const cache = createIntlCache();
}

Singleton-подход

Часто кеш создаётся как singleton.

// intl-cache.js

import {createIntlCache} from '@formatjs/intl';

export const intlCache = createIntlCache();

Разделение кешей

Иногда используют отдельные кеши:

  • для клиента;
  • для SSR;
  • для тестов.

Мемоизация в unit-тестах

Без общего кеша тесты могут работать медленнее.

Пример:

const cache = createIntlCache();

beforeEach(() => {
  intl = createIntl(
    {
      locale: 'en',
      messages: {}
    },
    cache
  );
});

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

Intl.DateTimeFormat особенно дорог в создании.

intl.formatDate(date, {
  year: 'numeric',
  month: 'long',
  day: 'numeric'
});

FormatJS кеширует formatter по:

  • locale;
  • calendar;
  • numbering system;
  • timeZone;
  • options.

TimeZone как фактор роста кеша

Проблемный сценарий:

intl.formatDate(date, {
  timeZone: userTimeZone
});

Если timezone уникален для каждого пользователя:

  • число formatter-объектов быстро растёт;
  • кеш может занимать значительный объём памяти.

Стратегии ограничения кеша

Ограничение набора options

Лучший способ контроля памяти — стандартизация formatter-конфигураций.


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

const SHORT_DATE = {
  year: 'numeric',
  month: 'short',
  day: 'numeric'
};

Внутренние memoize-функции FormatJS

Внутри библиотеки используются специализированные memoization utility-функции.

Они:

  • сериализуют options;
  • создают deterministic keys;
  • используют Map;
  • повторно используют formatter instances.

Почему обычный object-cache хуже

Пример:

const cache = {};

Недостатки:

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

Почему используется Map

Map предоставляет:

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

Влияние на React Concurrent Rendering

В React concurrent mode количество рендеров возрастает.

Без мемоизации:

  • formatter-объекты создаются особенно часто;
  • производительность ухудшается сильнее.

FormatJS минимизирует эти издержки.


Кеширование и hydration

Во время hydration React повторно выполняет рендер компонентов.

Кеш formatter-объектов позволяет избежать повторного создания Intl-инстансов.


Форматирование сообщений и AST

FormatJS компилирует ICU-сообщения в AST.

Пример:

{count, plural,
  one {# item}
  other {# items}
}

Парсинг таких шаблонов является вычислительно затратным.


Предкомпиляция сообщений

Для максимальной производительности используют предварительную компиляцию сообщений.

Пакет:

@formatjs/cli

позволяет:

  • компилировать ICU заранее;
  • уменьшать runtime-затраты;
  • снижать нагрузку на memoization layer.

Runtime-мемоизация против precompilation

Runtime

Плюсы:

  • простота;
  • гибкость.

Минусы:

  • парсинг выполняется в runtime.

Precompiled messages

Плюсы:

  • быстрый startup;
  • меньше runtime-вычислений;
  • меньше pressure на cache.

Минусы:

  • усложнение build pipeline.

Диагностика проблем кеширования

Симптомы плохой мемоизации:

  • рост памяти;
  • лаги интерфейса;
  • частые GC pauses;
  • profiler показывает большое число Intl.*.

Профилирование

В Chrome DevTools можно увидеть:

  • количество Intl.NumberFormat;
  • число allocations;
  • GC activity.

Типичные ошибки

Создание intl на каждом рендере

Плохо:

function App() {
  const intl = createIntl(...);
}

Создание cache на каждом рендере

Плохо:

function App() {
  const cache = createIntlCache();
}

Уникальные formatter options

Плохо:

intl.formatNumber(value, {
  minimumFractionDigits: Math.random()
});

Оптимальная архитектура

const cache = createIntlCache();

const intl = createIntl(
  {
    locale: 'en',
    messages
  },
  cache
);
  • кеш создаётся один раз;
  • options стандартизированы;
  • formatter-объекты переиспользуются;
  • ICU-шаблоны компилируются только один раз.