Dependency injection локали

Встроенные механизмы интернационализации в JavaScript основаны на принципе явного или неявного определения локали. Поведение большинства классов IntlIntl.DateTimeFormat, Intl.NumberFormat, Intl.Collator, Intl.RelativeTimeFormat, Intl.PluralRules, Intl.DisplayNames — зависит от значения локали, которое либо передаётся напрямую, либо выводится из окружения выполнения.

Ключевая архитектурная проблема интернационализации заключается в управлении источником локали: она может быть глобальной, контекстной или передаваться как зависимость. Именно последний подход — dependency injection локали — обеспечивает предсказуемость, тестируемость и масштабируемость кода.


Локаль как внешняя зависимость

Локаль в Intl представляет собой строку вида "en", "en-US", "ru", "zh-Hans-CN" и т.д. При создании форматтеров она передаётся как первый аргумент конструктора:

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

Если локаль не указана, используется значение окружения:

const formatter = new Intl.NumberFormat();

В этом случае движок JavaScript определяет локаль из среды выполнения: браузера, операционной системы или переменных окружения Node.js (process.env.LANG, LC_ALL и др.).

Такое поведение превращает локаль в скрытую зависимость, что усложняет контроль над результатом форматирования.


Проблема неявной локали

Неявное получение локали приводит к нескольким архитектурным проблемам:

  • различие результатов в разных окружениях;
  • невозможность воспроизведения поведения в тестах;
  • зависимость от конфигурации сервера или браузера;
  • сложность кеширования форматтеров;
  • нарушение принципа чистых функций.
function formatPrice(value) {
  return new Intl.NumberFormat().format(value);
}

Результат этой функции зависит от внешнего состояния, хотя явно это не отражено в сигнатуре.


Явная передача локали как DI

Базовый уровень dependency injection локали заключается в передаче её через аргументы функций:

function formatPrice(value, locale) {
  return new Intl.NumberFormat(locale).format(value);
}

Теперь зависимость становится явной. Поведение функции определяется только входными параметрами.

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

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

Инъекция локали через объект контекста

При усложнении системы локаль обычно становится частью общего контекста приложения:

function formatPrice(value, context) {
  const { locale } = context;
  return new Intl.NumberFormat(locale).format(value);
}

Контекст может содержать не только локаль, но и валюту, таймзону, формат даты и другие параметры интернационализации.

Такой подход особенно характерен для серверных приложений и API-слоёв, где запрос пользователя несёт информацию о предпочтительной локали.


Контекст запроса в серверных приложениях

В Node.js и backend-средах локаль часто извлекается из HTTP-заголовков:

  • Accept-Language
  • пользовательских настроек
  • JWT или сессии

Далее она передаётся вниз по стеку вызовов:

function handler(req) {
  const locale = req.headers["accept-language"];

  return formatResponse(locale);
}

function formatResponse(locale) {
  return new Intl.DateTimeFormat(locale).format(new Date());
}

Здесь локаль становится частью потока данных, а не глобального состояния.


Локаль как часть DI-контейнера

В более структурированных архитектурах локаль внедряется через контейнер зависимостей:

const container = {
  locale: "ru-RU",
  formatNumber(value) {
    return new Intl.NumberFormat(this.locale).format(value);
  }
};

Или через фабрики:

function createI18n(locale) {
  return {
    formatNumber: (value) =>
      new Intl.NumberFormat(locale).format(value),

    formatDate: (date) =>
      new Intl.DateTimeFormat(locale).format(date)
  };
}

Фабричный подход позволяет создавать изолированные экземпляры интернационализации для разных пользователей.


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

Конструкторы Intl достаточно тяжёлые с точки зрения производительности. Поэтому часто используется кеширование:

const cache = new Map();

function getNumberFormatter(locale) {
  if (!cache.has(locale)) {
    cache.set(locale, new Intl.NumberFormat(locale));
  }
  return cache.get(locale);
}

function formatNumber(value, locale) {
  return getNumberFormatter(locale).format(value);
}

Здесь локаль становится ключом кеша, а форматтер — зависимостью, управляемой через DI-подобный механизм.


Инъекция локали в функциях высшего порядка

Функциональный подход позволяет создавать специализированные функции с зафиксированной локалью:

function createPriceFormatter(locale) {
  const formatter = new Intl.NumberFormat(locale, {
    style: "currency",
    currency: "USD"
  });

  return (value) => formatter.format(value);
}

const formatPrice = createPriceFormatter("en-US");

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


React и контекст локали

В компонентных архитектурах локаль часто распространяется через context:

const LocaleContext = React.createContext("en-US");

function Price({ value }) {
  const locale = React.useContext(LocaleContext);

  const formatter = new Intl.NumberFormat(locale);

  return formatter.format(value);
}

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


Проблема скрытых singleton-форматтеров

Часто встречается анти-паттерн глобальных форматтеров:

const formatter = new Intl.NumberFormat("en-US");

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

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


Тестируемость через инъекцию локали

Явная передача локали упрощает тестирование:

test("formats number in Russian locale", () => {
  const result = formatPrice(1000, "ru-RU");
  expect(result).toBe("1 000");
});

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


Локаль и стратегия fallback

Intl поддерживает механизм выбора ближайшей доступной локали:

new Intl.NumberFormat(["fr-CA", "fr-FR", "en"]).format(1000);

Dependency injection локали часто дополняется логикой нормализации:

  • приведение пользовательского ввода к стандарту BCP 47;
  • fallback при отсутствии поддержки локали;
  • хранение предпочтений пользователя в профиле.

Локаль в многоуровневых системах

В сложных приложениях локаль может изменяться на разных уровнях:

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

DI позволяет формировать цепочку переопределений:

function formatDate(date, { locale = "en-US" } = {}) {
  return new Intl.DateTimeFormat(locale).format(date);
}

Такой подход создаёт гибкую систему приоритизации зависимостей.


Мемоизация с учётом локали

При DI локаль становится ключевым параметром мемоизации:

const memo = new Map();

function format(locale, value) {
  const key = `${locale}:${value}`;

  if (memo.has(key)) return memo.get(key);

  const result = new Intl.NumberFormat(locale).format(value);
  memo.set(key, result);

  return result;
}

Таким образом локаль участвует в построении кэша как часть контракта функции.


Инверсия управления локалью

Dependency injection локали по сути реализует инверсию управления: не функция запрашивает локаль у окружения, а окружение передаёт её функции. Это устраняет скрытые связи и делает поток данных явным.

// плохо: функция сама "знает", где взять локаль
function format(value) {
  return new Intl.NumberFormat(getLocaleFromGlobal()).format(value);
}

// лучше: локаль передана извне
function format(value, locale) {
  return new Intl.NumberFormat(locale).format(value);
}