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

Объекты семейства Intl создаются относительно дорого. При каждом вызове конструктора движок Jav * aScript:

  • анализирует локаль;
  • подбирает правила форматирования;
  • загружает ICU-данные;
  • строит внутренние таблицы;
  • нормализует параметры;
  • подготавливает алгоритмы форматирования.

Создание экземпляров:

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

может становиться заметной нагрузкой в:

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

Плохая практика:

const prices = [10, 20, 30]

const result = prices.map(price => {
  return new Intl.NumberFormat("ru-RU", {
    style: "currency",
    currency: "RUB"
  }).format(price)
})

Форматтер создаётся заново для каждого элемента массива.

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

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

const prices = [10, 20, 30]

const result = prices.map(price => formatter.format(price))

Создание форматтера происходит один раз.


Стоимость создания форматтера

Даже если метод .format() работает быстро, конструктор Intl.* может быть существенно тяжелее.

Условная модель затрат:

Операция Стоимость
Создание Intl.NumberFormat высокая
Вызов .format() низкая
Повторное использование оптимально

В горячих участках кода разница становится критичной.

Пример проблемного рендера:

function renderRows(rows) {
  return rows.map(row => {
    return {
      date: new Intl.DateTimeFormat("ru-RU").format(row.date),
      amount: new Intl.NumberFormat("ru-RU").format(row.amount)
    }
  })
}

При 10 000 строк:

  • создаётся 20 000 форматтеров;
  • возрастает нагрузка на GC;
  • ухудшается время рендера;
  • появляются микрофризы.

Оптимизированная версия:

const dateFormatter = new Intl.DateTimeFormat("ru-RU")

const numberFormatter = new Intl.NumberFormat("ru-RU")

function renderRows(rows) {
  return rows.map(row => {
    return {
      date: dateFormatter.format(row.date),
      amount: numberFormatter.format(row.amount)
    }
  })
}

Базовое кэширование

Самый простой способ — вынести форматтер в область выше.

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

export function formatPrice(value) {
  return currencyFormatter.format(value)
}

Такой подход особенно эффективен:

  • в utility-модулях;
  • в singleton-сервисах;
  • в frontend-приложениях;
  • в backend API;
  • в CLI-инструментах.

Кэширование по локали

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

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

function formatPrice(value, locale) {
  const formatter = new Intl.NumberFormat(locale)

  return formatter.format(value)
}

Каждый вызов создаёт новый объект.

Оптимизированный вариант:

const cache = new Map()

function getFormatter(locale) {
  if (!cache.has(locale)) {
    cache.set(
      locale,
      new Intl.NumberFormat(locale)
    )
  }

  return cache.get(locale)
}

function formatPrice(value, locale) {
  return getFormatter(locale).format(value)
}

Кэширование с учётом опций

Разные опции создают разные форматтеры.

Например:

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

Это разные конфигурации.

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

  • локаль;
  • набор опций;
  • тип форматтера.

Пример:

const cache = new Map()

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

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

  return cache.get(key)
}

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

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

console.log(formatter.format(1500))

Проблемы JSON.stringify

Использование JSON.stringify() для генерации ключа не всегда безопасно.

Проблемный пример:

const a = {
  style: "currency",
  currency: "USD"
}

const b = {
  currency: "USD",
  style: "currency"
}

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

Результат:

JSON.stringify(a)
JSON.stringify(b)

может дать разные строки.

Более надёжный подход:

function stableKey(locale, options = {}) {
  const sorted = Object.entries(options)
    .sort(([a], [b]) => a.localeCompare(b))

  return JSON.stringify([locale, sorted])
}

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

Практичный паттерн — единый registry.

const formatterCache = new Map()

function getFormatter(type, locale, options = {}) {
  const key = JSON.stringify([
    type,
    locale,
    options
  ])

  if (formatterCache.has(key)) {
    return formatterCache.get(key)
  }

  let formatter

  switch (type) {
    case "number":
      formatter = new Intl.NumberFormat(locale, options)
      break

    case "date":
      formatter = new Intl.DateTimeFormat(locale, options)
      break

    case "relative":
      formatter = new Intl.RelativeTimeFormat(locale, options)
      break

    default:
      throw new Error("Unknown formatter")
  }

  formatterCache.set(key, formatter)

  return formatter
}

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

const formatter = getFormatter(
  "date",
  "ru-RU",
  {
    dateStyle: "long"
  }
)

console.log(formatter.format(new Date()))

WeakMap для объектных ключей

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

const cache = new WeakMap()

function getFormatter(options) {
  if (!cache.has(options)) {
    cache.set(
      options,
      new Intl.NumberFormat("ru-RU", options)
    )
  }

  return cache.get(options)
}

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

const options = {
  style: "currency",
  currency: "RUB"
}

const formatter = getFormatter(options)

Преимущества:

  • автоматическая очистка памяти;
  • отсутствие ручного удаления;
  • безопасная работа с GC.

Недостаток:

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

LRU-кэширование

В больших системах количество комбинаций локалей и опций может стать огромным.

Например:

  • пользовательские валюты;
  • разные часовые пояса;
  • кастомные календари;
  • динамические настройки UI.

Бесконечный Map способен привести к росту памяти.

Решение — ограниченный кэш.

Простейший LRU:

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

  get(key) {
    if (!this.map.has(key)) {
      return null
    }

    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)
    }
  }
}

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

const cache = new LRUCache(50)

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

  let formatter = cache.get(key)

  if (!formatter) {
    formatter = new Intl.NumberFormat(
      locale,
      options
    )

    cache.set(key, formatter)
  }

  return formatter
}

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

Одна из самых частых ошибок — создание форматтеров прямо внутри компонента.

Плохо:

function Price({ value }) {
  const formatted =
    new Intl.NumberFormat("ru-RU").format(value)

  return <span>{formatted}</span>
}

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

Правильный подход — useMemo.

import { useMemo } fr om "react"

function Price({ value, locale }) {
  const formatter = useMemo(() => {
    return new Intl.NumberFormat(locale)
  }, [locale])

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

Кэширование в Vue

Во Vue аналогичная проблема встречается в computed и template.

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

function formatPrice(value) {
  return new Intl.NumberFormat("ru-RU")
    .format(value)
}

Лучше:

const formatter =
  new Intl.NumberFormat("ru-RU")

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

Либо:

const formatters = new Map()

function getFormatter(locale) {
  if (!formatters.has(locale)) {
    formatters.set(
      locale,
      new Intl.NumberFormat(locale)
    )
  }

  return formatters.get(locale)
}

Кэширование в Node.js

На сервере проблема особенно заметна:

  • большое количество запросов;
  • SSR;
  • API сериализация;
  • генерация отчётов.

Плохой обработчик:

app.get("/report", (req, res) => {
  const formatter =
    new Intl.DateTimeFormat(req.locale)

  const result = data.map(item => {
    return formatter.format(item.date)
  })

  res.json(result)
})

Если запросов тысячи, постоянное создание объектов становится дорогим.

Лучше использовать глобальный кэш:

const cache = new Map()

function getDateFormatter(locale) {
  if (!cache.has(locale)) {
    cache.set(
      locale,
      new Intl.DateTimeFormat(locale)
    )
  }

  return cache.get(locale)
}

Кэширование Intl.Collator

Intl.Collator особенно важен для сортировки.

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

array.sort((a, b) => {
  return new Intl.Collator("ru")
    .compare(a, b)
})

Коллатор создаётся при каждом сравнении.

Правильно:

const collator = new Intl.Collator("ru")

array.sort((a, b) => {
  return collator.compare(a, b)
})

Это критически важно, потому что:

  • сортировка вызывает comparator множество раз;
  • создание Intl.Collator дорого;
  • сложность сортировки усиливает проблему.

Кэширование Intl.RelativeTimeFormat

Без кэша:

function formatMinutes(value) {
  return new Intl.RelativeTimeFormat("ru")
    .format(value, "minute")
}

С кэшем:

const formatter =
  new Intl.RelativeTimeFormat("ru")

function formatMinutes(value) {
  return formatter.format(value, "minute")
}

Кэширование Intl.PluralRules

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

function getPlural(count) {
  return new Intl.PluralRules("ru")
    .select(count)
}

Оптимизация:

const pluralRules =
  new Intl.PluralRules("ru")

function getPlural(count) {
  return pluralRules.select(count)
}

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

Часто используется паттерн factory.

function createNumberFormatter(locale) {
  const cache = new Map()

  return function(options = {}) {
    const key = JSON.stringify(options)

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

    return cache.get(key)
  }
}

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

const ruFormatterFactory =
  createNumberFormatter("ru-RU")

const currencyFormatter =
  ruFormatterFactory({
    style: "currency",
    currency: "RUB"
  })

Предварительная инициализация

В production-системах форматтеры иногда создаются заранее.

Например:

const formatters = {
  currencyRU: new Intl.NumberFormat("ru-RU", {
    style: "currency",
    currency: "RUB"
  }),

  currencyUS: new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD"
  }),

  dateRU: new Intl.DateTimeFormat("ru-RU")
}

Преимущества:

  • отсутствие задержек при первом вызове;
  • предсказуемая производительность;
  • меньше runtime-операций.

Недостаток:

  • увеличение startup-time;
  • лишняя память при редком использовании.

Ленивое создание

Альтернатива — lazy initialization.

let formatter = null

function formatPrice(value) {
  if (!formatter) {
    formatter =
      new Intl.NumberFormat("ru-RU")
  }

  return formatter.format(value)
}

Форматтер создаётся только при первом использовании.


Пулы форматтеров

Иногда применяется pooling.

const pool = {
  ru: new Intl.NumberFormat("ru-RU"),
  en: new Intl.NumberFormat("en-US"),
  de: new Intl.NumberFormat("de-DE")
}

function format(locale, value) {
  return pool[locale].format(value)
}

Подход эффективен:

  • при ограниченном наборе локалей;
  • в enterprise-системах;
  • в embedded-окружениях;
  • в high-load backend.

Измерение производительности

Проверять эффективность оптимизации необходимо через benchmark.

Пример:

console.time("without-cache")

for (let i = 0; i < 100000; i++) {
  new Intl.NumberFormat("ru-RU")
    .format(i)
}

console.timeEnd("without-cache")

С кэшем:

const formatter =
  new Intl.NumberFormat("ru-RU")

console.time("with-cache")

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

console.timeEnd("with-cache")

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


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

Избыточное кэширование тоже вредно.

Необязательно кэшировать форматтеры:

  • в одноразовых скриптах;
  • в редко вызываемом коде;
  • в маленьких CLI;
  • в тестах;
  • в простых демо.

Кэширование оправдано, если:

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

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

Кэширование только по локали

Ошибка:

cache.set(locale, formatter)

Проблема:

new Intl.NumberFormat("ru", {
  style: "currency"
})

new Intl.NumberFormat("ru", {
  style: "percent"
})

Форматтеры разные, а ключ одинаковый.


Создание форматтера внутри comparator

Плохо:

items.sort((a, b) => {
  return new Intl.Collator("ru")
    .compare(a, b)
})

Создание форматтера внутри render

Плохо:

function Component() {
  const value =
    new Intl.NumberFormat("ru")
      .format(1000)

  return <div>{value}</div>
}

Бесконечный рост кэша

Опасный пример:

const cache = new Map()

без:

  • ограничения размера;
  • очистки;
  • TTL;
  • LRU-механизма.

Практический production-паттерн

class FormatterCache {
  constructor(lim it = 100) {
    this.limit = limit
    this.cache = new Map()
  }

  buildKey(type, locale, options) {
    const sorted =
      Object.entries(options || {})
        .sort(([a], [b]) =>
          a.localeCompare(b)
        )

    return JSON.stringify([
      type,
      locale,
      sorted
    ])
  }

  get(type, locale, options = {}) {
    const key =
      this.buildKey(type, locale, options)

    if (this.cache.has(key)) {
      const value = this.cache.get(key)

      this.cache.delete(key)
      this.cache.set(key, value)

      return value
    }

    let formatter

    switch (type) {
      case "number":
        formatter =
          new Intl.NumberFormat(
            locale,
            options
          )
        break

      case "date":
        formatter =
          new Intl.DateTimeFormat(
            locale,
            options
          )
        break

      case "relative":
        formatter =
          new Intl.RelativeTimeFormat(
            locale,
            options
          )
        break

      default:
        throw new Error("Unknown formatter")
    }

    this.cache.set(key, formatter)

    if (this.cache.size > this.limit) {
      const oldest =
        this.cache.keys().next().value

      this.cache.delete(oldest)
    }

    return formatter
  }
}

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

const intlCache =
  new FormatterCache(200)

const formatter =
  intlCache.get(
    "number",
    "ru-RU",
    {
      style: "currency",
      currency: "RUB"
    }
  )

console.log(
  formatter.format(150000)
)