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

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

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

В экосистеме FormatJS ленивая инициализация позволяет создавать объекты интернационализации только в момент фактического использования.

Наиболее часто лениво инициализируются:

  • IntlShape;
  • словари переводов;
  • polyfill-модули;
  • formatter cache;
  • locale-data;
  • React-компоненты с переводами;
  • асинхронные namespace-пакеты.

Базовая идея ленивой инициализации

Стандартная инициализация:

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

const cache = createIntlCache()

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

Объект intl создаётся сразу после загрузки модуля.

Ленивая инициализация переносит создание объекта в момент обращения:

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

const cache = createIntlCache()

let intl = null

function getIntl() {
  if (!intl) {
    intl = createIntl({
      locale: 'ru',
      messages: messagesRu
    }, cache)
  }

  return intl
}

Теперь объект будет создан только при первом вызове getIntl().


Lazy Singleton Pattern

На практике наиболее распространён подход с singleton-экземпляром.

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

const cache = createIntlCache()

let instance

export function getIntl(locale, messages) {
  if (!instance) {
    instance = createIntl(
      {
        locale,
        messages
      },
      cache
    )
  }

  return instance
}

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

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

Недостатки

  • сложность при SSR;
  • проблемы в multi-locale окружении;
  • риск утечки состояния между запросами.

Ленивая загрузка переводов

Одна из главных причин использования lazy initialization — динамическая загрузка message catalog.

Статический импорт

import ruMessages from './locales/ru.json'
import enMessages from './locales/en.json'

Недостаток очевиден: оба файла попадают в основной bundle.


Динамический импорт сообщений

Современный подход:

async function loadMessages(locale) {
  const messages = await import(
    `./locales/${locale}.json`
  )

  return messages.default
}

Теперь webpack, Vite или Rollup создают отдельные chunks.

Результат

  • уменьшается стартовый bundle;
  • локали загружаются по требованию;
  • снижается потребление памяти;
  • ускоряется initial load.

Асинхронная инициализация intl

После динамической загрузки сообщений объект intl создаётся асинхронно.

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

const cache = createIntlCache()

let intl

export async function initIntl(locale) {
  const messages = await loadMessages(locale)

  intl = createIntl(
    {
      locale,
      messages
    },
    cache
  )

  return intl
}

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

await initIntl('ru')

Кэширование инициализированных локалей

При переключении языков повторная загрузка переводов создаёт ненужные сетевые запросы.

Кэш локалей

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

const cache = createIntlCache()

const intlMap = new Map()

export async function getIntl(locale) {
  if (intlMap.has(locale)) {
    return intlMap.get(locale)
  }

  const messages = await loadMessages(locale)

  const intl = createIntl(
    {
      locale,
      messages
    },
    cache
  )

  intlMap.set(locale, intl)

  return intl
}

Особенности

  • локаль инициализируется только один раз;
  • повторные обращения работают мгновенно;
  • formatter cache переиспользуется.

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

В React-приложениях библиотека чаще всего используется через IntlProvider.

Стандартный вариант:

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

При таком подходе сообщения должны быть загружены заранее.


Асинхронный IntlProvider

Пример ленивой загрузки локалей:

import {useEffect, useState} from 'react'
import {IntlProvider} from 'react-intl'

export function I18nProvider({locale, children}) {
  const [messages, setMessages] = useState(null)

  useEffect(() => {
    import(`./locales/${locale}.json`)
      .then(module => {
        setMessages(module.default)
      })
  }, [locale])

  if (!messages) {
    return <div>Loading...</div>
  }

  return (
    <IntlProvider
      locale={locale}
      messages={messages}
    >
      {children}
    </IntlProvider>
  )
}

React Suspense и lazy initialization

В современных React-приложениях lazy initialization часто комбинируется с Suspense.

const messagesPromiseMap = new Map()

function loadMessages(locale) {
  if (!messagesPromiseMap.has(locale)) {
    messagesPromiseMap.set(
      locale,
      import(`./locales/${locale}.json`)
    )
  }

  return messagesPromiseMap.get(locale)
}

Компонент:

function LocaleLoader({locale, children}) {
  const messages = use(loadMessages(locale))

  return (
    <IntlProvider
      locale={locale}
      messages={messages.default}
    >
      {children}
    </IntlProvider>
  )
}

Преимущества Suspense-подхода

  • отсутствие ручного управления loading-state;
  • автоматическая интеграция с concurrent rendering;
  • оптимизация переходов между локалями.

Ленивая загрузка polyfills

Некоторые API Intl отсутствуют в старых браузерах:

  • Intl.RelativeTimeFormat;
  • Intl.DisplayNames;
  • Intl.ListFormat;
  • Intl.PluralRules.

Подключение всех polyfill заранее резко увеличивает размер bundle.


Условная загрузка polyfills

async function ensurePluralRules(locale) {
  if (!Intl.PluralRules) {
    await import('@formatjs/intl-pluralrules/polyfill')
  }

  await import(
    `@formatjs/intl-pluralrules/locale-data/${locale}`
  )
}

Что происходит

  1. Проверяется наличие API.
  2. Polyfill загружается только при необходимости.
  3. Locale-data подключается отдельно.

Lazy initialization formatter cache

Создание formatter-объектов — дорогостоящая операция.

Например:

new Intl.DateTimeFormat('ru')

или:

new Intl.NumberFormat('en')

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


createIntlCache

import {createIntlCache} from '@formatjs/intl'

const cache = createIntlCache()

Кэш содержит:

  • DateTimeFormat;
  • NumberFormat;
  • PluralRules;
  • RelativeTimeFormat.

Почему это важно

Без кэша formatter-объекты создаются заново:

function formatPrice(value) {
  return new Intl.NumberFormat('ru').format(value)
}

С кэшем:

const formatter = new Intl.NumberFormat('ru')

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

Разница особенно заметна:

  • в таблицах;
  • в списках;
  • в realtime UI;
  • при SSR.

Deferred initialization

Иногда переводчики нужны не сразу после запуска приложения.

Например:

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

В таких случаях инициализация переносится до первого открытия модуля.

let adminIntl = null

export async function getAdminIntl() {
  if (!adminIntl) {
    const messages = await import(
      './admin/messages/ru.json'
    )

    adminIntl = createIntl({
      locale: 'ru',
      messages: messages.default
    })
  }

  return adminIntl
}

Lazy namespace loading

Крупные приложения часто разбивают переводы на namespace.

Структура:

locales/
  ru/
    common.json
    dashboard.json
    profile.json

Загрузка namespace

async function loadNamespace(locale, namespace) {
  const module = await import(
    `./locales/${locale}/${namespace}.json`
  )

  return module.default
}

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

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

Предзагрузка переводов

Ленивая инициализация не исключает prefetching.

Например, после авторизации можно заранее загрузить вероятную локаль:

import(
  /* webpackPrefetch: true */
  './locales/ru.json'
)

Разница между preload и prefetch

Механизм Назначение
preload нужен немедленно
prefetch понадобится позже

Ленивая инициализация при SSR

Server-Side Rendering требует осторожности.

Ошибка:

let intl

В Node.js singleton будет общим для всех запросов.


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

export async function createRequestIntl(locale) {
  const messages = await loadMessages(locale)

  return createIntl({
    locale,
    messages
  })
}

Почему это важно

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

Иначе возможны:

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

Lazy hydration

При гидратации React-приложения можно откладывать загрузку редких локалей.

Пример:

const AdminPanel = lazy(() => import('./AdminPanel'))

Внутри:

const messages = await import(
  './locales/admin/ru.json'
)

Эффект

  • уменьшается hydration cost;
  • снижается TTI;
  • основной поток меньше блокируется.

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

Даже при lazy initialization повторное создание formatter-объектов остаётся проблемой.

Пример memoization

const formatterMap = new Map()

function getCurrencyFormatter(locale) {
  if (!formatterMap.has(locale)) {
    formatterMap.set(
      locale,
      new Intl.NumberFormat(locale, {
        style: 'currency',
        currency: 'USD'
      })
    )
  }

  return formatterMap.get(locale)
}

Комбинирование lazy initialization и React.memo

const Price = React.memo(function Price({
  value,
  formatter
}) {
  return formatter.format(value)
})

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


Оптимизация переключения локалей

Частая ошибка — полная повторная инициализация приложения.

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

window.location.reload()

Лучше:

const intl = await getIntl(locale)

setIntl(intl)

Стратегия stale-while-revalidate

Иногда переводы обновляются динамически.

Подход:

  1. использовать закэшированные messages;
  2. фоново загружать свежую версию;
  3. обновлять intl после получения новых данных.

Пример:

async function getMessages(locale) {
  const cached = cache.get(locale)

  fetchNewMessages(locale)

  return cached
}

Lazy initialization и code splitting

FormatJS особенно эффективен в сочетании с code splitting.

const Dashboard = lazy(() => import('./Dashboard'))

Внутри dashboard:

import('./locales/dashboard/ru.json')

Результат

Пользователь не загружает:

  • dashboard translations;
  • admin translations;
  • analytics translations;

до фактического открытия разделов.


Частые ошибки

Повторное создание cache

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

function getIntl() {
  const cache = createIntlCache()

  return createIntl(config, cache)
}

Правильно:

const cache = createIntlCache()

Глобальный singleton при SSR

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

global.intl = createIntl(...)

Это приводит к shared-state между запросами.


Загрузка всех locale-data

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

import '@formatjs/intl-relativetimeformat/locale-data/ru'
import '@formatjs/intl-relativetimeformat/locale-data/en'
import '@formatjs/intl-relativetimeformat/locale-data/de'

Лучше:

await import(
  `@formatjs/intl-relativetimeformat/locale-data/${locale}`
)

Архитектура production-решения

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

src/
  i18n/
    cache.js
    loaders.js
    intl.js
    namespaces/

cache.js

export const cache = createIntlCache()

loaders.js

export async function loadMessages(locale) {
  return (
    await import(`../locales/${locale}.json`)
  ).default
}

intl.js

import {createIntl} from '@formatjs/intl'
import {cache} from './cache'

const intlMap = new Map()

export async function getIntl(locale) {
  if (intlMap.has(locale)) {
    return intlMap.get(locale)
  }

  const messages = await loadMessages(locale)

  const intl = createIntl(
    {
      locale,
      messages
    },
    cache
  )

  intlMap.set(locale, intl)

  return intl
}

Производительность и memory footprint

Ленивая инициализация особенно полезна в:

  • enterprise SPA;
  • dashboard-приложениях;
  • SSR-framework;
  • microfrontend-архитектурах;
  • mobile web;
  • low-memory environments.

Основные выигрыши

Оптимизация Эффект
Dynamic import уменьшение bundle
Formatter cache снижение CPU
Namespace loading экономия памяти
Polyfill lazy loading ускорение startup
Deferred initialization снижение hydration cost

Паттерн resource registry

Иногда удобно хранить registry всех локалей.

const localeRegistry = {
  ru: () => import('./locales/ru.json'),
  en: () => import('./locales/en.json'),
  de: () => import('./locales/de.json')
}

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

async function loadMessages(locale) {
  return (await localeRegistry[locale]()).default
}

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

  • контроль доступных локалей;
  • защита от invalid import path;
  • совместимость с bundlers;
  • предсказуемый code splitting.

Lazy initialization в microfrontend-архитектуре

Каждый microfrontend может иметь собственный набор переводов.

export async function initPaymentsI18n(locale) {
  const messages = await import(
    `./payments/${locale}.json`
  )

  return createIntl({
    locale,
    messages: messages.default
  })
}

Особенности

  • независимые bundles;
  • изолированные namespace;
  • автономное обновление переводов;
  • независимый deployment.