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

Наиболее распространённая ошибка при работе с Intl API — создание новых экземпляров форматтеров внутри часто вызываемых функций, циклов или компонентов интерфейса.

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

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

При каждом вызове функции создаётся новый объект Intl.NumberFormat. Для единичных операций это незаметно, однако при обработке больших массивов данных, рендеринге таблиц или частом обновлении интерфейса нагрузка становится существенной.

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

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

function formatPrice(price) {
  return priceFormatter.format(price);
}

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


Почему создание форматтеров дорогостоящее

При создании объектов Intl движок JavaScript выполняет несколько тяжёлых операций:

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

Особенно дорого обходятся:

  • Intl.DateTimeFormat
  • Intl.NumberFormat
  • Intl.Collator

Менее затратны:

  • Intl.PluralRules
  • Intl.ListFormat
  • Intl.RelativeTimeFormat

Однако даже они не должны создаваться без необходимости.


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

Простое кэширование

const formatters = {};

function getCurrencyFormatter(locale, currency) {
  const key = `${locale}-${currency}`;

  if (!formatters[key]) {
    formatters[key] = new Intl.NumberFormat(locale, {
      style: 'currency',
      currency
    });
  }

  return formatters[key];
}

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

const formatter = getCurrencyFormatter('en-US', 'USD');

console.log(formatter.format(1500));

Кэширование через Map

Map удобнее обычного объекта при большом количестве ключей.

const formatterCache = new Map();

function getFormatter(locale, currency) {
  const key = `${locale}:${currency}`;

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

  return formatterCache.get(key);
}

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

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

Кэширование сложных конфигураций

Проблема появляется, когда объект настроек содержит множество параметров.

Плохой вариант:

new Intl.DateTimeFormat(locale, options);

где options каждый раз создаётся заново.

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

const cache = new Map();

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

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

  return cache.get(key);
}

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

const formatter = getDateFormatter('ru-RU', {
  dateStyle: 'long',
  timeStyle: 'short'
});

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

Если набор локалей известен заранее, форматтеры лучше создавать при инициализации приложения.

const formatters = {
  ru: new Intl.NumberFormat('ru-RU'),
  en: new Intl.NumberFormat('en-US'),
  de: new Intl.NumberFormat('de-DE')
};

Это особенно эффективно:

  • в SPA-приложениях;
  • на сервере Node.js;
  • в SSR;
  • в аналитических системах;
  • при обработке больших объёмов данных.

Избегание создания Intl внутри циклов

Одна из самых дорогих ошибок производительности.

Неэффективно:

const prices = [100, 200, 300];

const result = prices.map(price => {
  return new Intl.NumberFormat('ru-RU').format(price);
});

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

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

const result = prices.map(price => {
  return formatter.format(price);
});

Производительность Intl.DateTimeFormat

Intl.DateTimeFormat — один из самых тяжёлых объектов во всём API.

Проблемный код:

messages.forEach(message => {
  const formatted = new Intl.DateTimeFormat(
    'ru-RU',
    {
      dateStyle: 'short',
      timeStyle: 'short'
    }
  ).format(message.date);

  console.log(formatted);
});

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

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

messages.forEach(message => {
  console.log(
    formatter.format(message.date)
  );
});

Разница может быть заметна даже на нескольких тысячах элементов.


Производительность Intl.Collator

Intl.Collator особенно важен при сортировке.

Неэффективно:

users.sort((a, b) => {
  return a.name.localeCompare(
    b.name,
    'ru-RU'
  );
});

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

Лучше использовать отдельный collator:

const collator = new Intl.Collator('ru-RU');

users.sort((a, b) => {
  return collator.compare(a.name, b.name);
});

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


Сортировка больших объёмов данных

Для таблиц и поисковых систем использование Intl.Collator практически обязательно.

const collator = new Intl.Collator('ru', {
  sensitivity: 'base',
  numeric: true
});

products.sort((a, b) => {
  return collator.compare(a.title, b.title);
});

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

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

Переиспользование options-объектов

Даже создание объектов конфигурации влияет на производительность.

Плохо:

function formatDate(date) {
  return formatter.format(date, {
    year: 'numeric',
    month: 'long'
  });
}

Лучше:

const options = {
  year: 'numeric',
  month: 'long'
};

const formatter = new Intl.DateTimeFormat(
  'ru-RU',
  options
);

Причины:

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

Оптимизация в React

Типичная ошибка:

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

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

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

Оптимизация через useMemo:

import { useMemo } from 'react';

function Price({ value }) {
  const formatter = useMemo(() => {
    return new Intl.NumberFormat(
      'ru-RU',
      {
        style: 'currency',
        currency: 'RUB'
      }
    );
  }, []);

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

Оптимизация в Vue

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

export default {
  methods: {
    format(price) {
      return formatter.format(price);
    }
  }
};

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


Оптимизация на сервере Node.js

На сервере стоимость создания Intl особенно заметна из-за большого количества запросов.

Плохой вариант:

app.get('/prices', (req, res) => {
  const formatter = new Intl.NumberFormat('ru-RU');

  res.send(
    formatter.format(1000000)
  );
});

Лучше:

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

app.get('/prices', (req, res) => {
  res.send(
    formatter.format(1000000)
  );
});

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

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

class FormatterPool {
  constructor() {
    this.cache = new Map();
  }

  number(locale, options) {
    const key = JSON.stringify([
      'number',
      locale,
      options
    ]);

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

    return this.cache.get(key);
  }

  date(locale, options) {
    const key = JSON.stringify([
      'date',
      locale,
      options
    ]);

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

    return this.cache.get(key);
  }
}

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

const pool = new FormatterPool();

const formatter = pool.number(
  'ru-RU',
  {
    style: 'currency',
    currency: 'RUB'
  }
);

Ограничение роста кэша

Бесконтрольное кэширование может привести к утечкам памяти.

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

getFormatter(locale, dynamicOptions);

где dynamicOptions постоянно меняются.

Решения:

  • ограничение размера кэша;
  • LRU-кэш;
  • TTL-кэш;
  • очистка редко используемых форматтеров.

Использование 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);
}

Преимущество WeakMap:

  • автоматическая очистка памяти сборщиком мусора.

Массовое форматирование данных

При обработке больших массивов важно минимизировать:

  • создание форматтеров;
  • создание промежуточных строк;
  • создание временных объектов.

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

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

const result = new Array(data.length);

for (let i = 0; i < data.length; i++) {
  result[i] = formatter.format(
    data[i]
  );
}

Такой подход быстрее:

  • map;
  • forEach;
  • функциональных цепочек с промежуточными массивами.

Ленивая инициализация

Иногда форматтеры нужны не всегда.

let formatter = null;

function format(value) {
  if (!formatter) {
    formatter = new Intl.NumberFormat(
      'ru-RU'
    );
  }

  return formatter.format(value);
}

Подход полезен:

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

Оптимизация локалей

Избыточное количество локалей влияет на:

  • размер бандла;
  • использование памяти;
  • время инициализации.

Некоторые библиотеки включают десятки языков автоматически.

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

  • подключать только нужные локали;
  • использовать dynamic import;
  • загружать ICU-данные по требованию.

Full ICU и Small ICU в Node.js

В Node.js существует два режима ICU:

Small ICU

Содержит ограниченный набор локалей.

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

  • меньший размер Node.js;
  • меньше потребление памяти.

Недостатки:

  • неполная локализация.

Full ICU

Поддерживает все локали.

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

  • полноценное форматирование;
  • корректная интернационализация.

Недостатки:

  • больше памяти;
  • дольше инициализация.

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

console.log(
  Intl.DateTimeFormat.supportedLocalesOf([
    'ru',
    'de',
    'fr',
    'zh'
  ])
);

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

Для анализа используется performance.now().

const start = performance.now();

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

const end = performance.now();

console.log(end - start);

Сравнение производительности

Пример бенчмарка:

const numbers = Array.from(
  { length: 100000 },
  (_, i) => i
);

console.time('without-cache');

numbers.forEach(n => {
  new Intl.NumberFormat('ru-RU')
    .format(n);
});

console.timeEnd('without-cache');

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

console.time('with-cache');

numbers.forEach(n => {
  formatter.format(n);
});

console.timeEnd('with-cache');

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


Избегание лишнего форматирования

Не всегда требуется форматировать значения повторно.

Плохо:

render();
render();
render();

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

Лучше кэшировать уже готовые строки:

const formattedPrice =
  formatter.format(price);

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

Эффективная стратегия:

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

Мемоизация форматирования

function memoizeFormat(formatter) {
  const cache = new Map();

  return value => {
    if (!cache.has(value)) {
      cache.set(
        value,
        formatter.format(value)
      );
    }

    return cache.get(value);
  };
}

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

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

const format =
  memoizeFormat(formatter);

console.log(format(1000));

Web Workers и Intl

При тяжёлой обработке данных форматирование можно переносить в Web Worker.

Полезно для:

  • аналитики;
  • больших таблиц;
  • CSV-экспорта;
  • финансовых систем.

Главный поток остаётся отзывчивым.


SSR и гидратация

При серверном рендеринге важно использовать одинаковые локали на сервере и клиенте.

Проблема:

Server: 1 234,56 ₽
Client: ₽1,234.56

Это приводит к ошибкам гидратации.

Решение:

  • синхронизировать locale;
  • передавать настройки клиента;
  • избегать автоопределения локали на разных сторонах.

Intl API и GC

Частое создание объектов Intl увеличивает:

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

Особенно это заметно:

  • в мобильных браузерах;
  • на слабых устройствах;
  • в Electron-приложениях.

Практические рекомендации

Чего следует избегать

  • создания Intl внутри циклов;
  • создания форматтеров в render-функциях;
  • повторного создания одинаковых конфигураций;
  • бесконтрольного роста кэша;
  • постоянного JSON.stringify в горячих участках.

Что считается хорошей практикой

  • переиспользование экземпляров;
  • централизованный пул форматтеров;
  • мемоизация;
  • lazy initialization;
  • использование Intl.Collator для сортировки;
  • предварительное создание популярных форматтеров;
  • профилирование производительности.

Типичная архитектура крупного приложения

class IntlService {
  constructor(locale) {
    this.locale = locale;

    this.number =
      new Intl.NumberFormat(locale);

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

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

    this.collator =
      new Intl.Collator(locale);
  }
}

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

const intl = new IntlService('ru-RU');

intl.currency.format(1500);
intl.date.format(new Date());

Подобный подход:

  • упрощает поддержку;
  • уменьшает нагрузку;
  • стандартизирует форматирование;
  • улучшает масштабируемость проекта.