Динамическая подгрузка локалей

При использовании библиотеки Globalize одной из главных проблем становится объём CLDR-данных. Полный набор локалей, календарей, валют, правил форматирования чисел и сообщений может занимать сотни килобайт. Если загружать всё сразу, увеличивается:

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

Особенно критично это для:

  • мобильных приложений;
  • SPA с большим количеством локалей;
  • мультиязычных e-commerce систем;
  • административных панелей;
  • приложений с lazy-loading маршрутов.

Поэтому в реальных проектах применяется динамическая подгрузка локалей.


Базовая схема динамической загрузки

Обычно приложение:

  1. стартует с одной базовой локалью;
  2. определяет язык пользователя;
  3. загружает необходимые CLDR-данные только при необходимости;
  4. создаёт экземпляр Globalize после загрузки данных.

Простейшая схема:

import Globalize from "globalize";

async function loadLocale(locale) {
    const data = await fetch(`/cldr/${locale}.json`)
        .then(r => r.json());

    Globalize.load(data);

    return new Globalize(locale);
}

Теперь локаль загружается только в момент обращения.


Почему нельзя просто подключить все локали

Наивный подход:

import "./cldr/en.json";
import "./cldr/fr.json";
import "./cldr/de.json";
import "./cldr/ja.json";

создаёт несколько проблем:

Рост размера бандла

Каждая локаль содержит:

  • правила pluralization;
  • данные календарей;
  • валютные данные;
  • timezone-описания;
  • форматирование чисел;
  • unit patterns.

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

Замедление старта приложения

CLDR-данные должны:

  • загрузиться;
  • распарситься;
  • пройти через Globalize.load().

Чем больше локалей — тем дольше инициализация.

Неэффективное использование памяти

Большинство пользователей используют только одну локаль за сессию.


Структура CLDR-данных

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

Обычно используются:

cldr-data/
├── main/
│   ├── en/
│   ├── fr/
│   ├── de/
│   └── ru/
├── supplemental/

Категории:

main

Содержит локализованные данные:

main/en/numbers.json
main/en/ca-gregorian.json
main/en/currencies.json

supplemental

Содержит общие правила:

supplemental/likelySubtags.json
supplemental/numberingSystems.json
supplemental/plurals.json

Supplemental-данные обычно загружаются один раз при старте приложения.


Разделение на обязательные и ленивые данные

Хорошая практика — делить данные на:

Базовые

Загружаются сразу:

Globalize.load(
    likelySubtags,
    plurals,
    numberingSystems
);

Локальные

Подгружаются по требованию:

async function loadLocaleData(locale) {
    const files = await Promise.all([
        import(`./cldr/main/${locale}/numbers.json`),
        import(`./cldr/main/${locale}/currencies.json`),
        import(`./cldr/main/${locale}/ca-gregorian.json`)
    ]);

    files.forEach(file => {
        Globalize.load(file.default);
    });
}

Использование dynamic import()

Современный стандартный способ — динамический импорт.

Пример

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

    const numbers = await import(
        `./cldr/main/${locale}/numbers.json`
    );

    Globalize.load(numbers.default);
    Globalize.loadMessages({
        [locale]: messages.default
    });

    Globalize.locale(locale);
}

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

  • автоматическое code splitting;
  • отдельные чанки;
  • загрузка только нужного языка;
  • поддержка webpack/vite/rollup.

Поддержка webpack

В Webpack динамические импорты автоматически создают чанки.

Пример

async function loadMessages(locale) {
    return import(
        /* webpackChunkName: "locale-[request]" */
        `./messages/${locale}.json`
    );
}

Будут созданы файлы:

locale-en.js
locale-fr.js
locale-ru.js

Поддержка Vite

В Vite удобно использовать import.meta.glob.

Пример

const localeFiles = import.meta.glob(
    "./messages/*.json"
);

async function loadMessages(locale) {
    const loader = localeFiles[
        `./messages/${locale}.json`
    ];

    const module = await loader();

    return module.default;
}

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

После появления lazy loading вся инициализация становится асинхронной.

Неправильный вариант

Globalize.locale("fr");

const formatter = Globalize.numberFormatter();

Если данные ещё не загружены — возникнет ошибка.


Правильный вариант

async function initLocale(locale) {
    await loadLocaleData(locale);

    Globalize.locale(locale);

    return {
        number: Globalize.numberFormatter(),
        date: Globalize.dateFormatter()
    };
}

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

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

Пример кэша

const loadedLocales = new Set();

async function ensureLocale(locale) {
    if (loadedLocales.has(locale)) {
        return;
    }

    await loadLocaleData(locale);

    loadedLocales.add(locale);
}

Теперь повторная загрузка исключается.


Кэширование formatter-объектов

Создание formatter-объектов в Globalize достаточно дорогое.

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

function formatPrice(value) {
    return Globalize
        .currencyFormatter("USD")(value);
}

Formatter создаётся при каждом вызове.


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

const formatterCache = new Map();

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

    if (!formatterCache.has(key)) {
        Globalize.locale(locale);

        formatterCache.set(
            key,
            Globalize.currencyFormatter("USD")
        );
    }

    return formatterCache.get(key);
}

Предзагрузка популярных локалей

Иногда выгодно заранее подгружать вероятные языки.

Пример

if (navigator.language.startsWith("fr")) {
    preloadLocale("fr");
}

Можно заранее подсказать браузеру будущую загрузку.

<link
    rel="prefetch"
    href="/locales/fr.json"
/>

Браузер загрузит файл в idle-время.


Определение локали пользователя

Чаще всего используется:

const locale = navigator.language;

Например:

en-US
fr-FR
ru-RU

Нормализация локали

CLDR может использовать сокращённые коды.

Пример

function normalizeLocale(locale) {
    return locale.split("-")[0];
}

Результат:

en-US → en
fr-CA → fr
ru-RU → ru

Fallback-локали

Не все локали могут существовать.

Пример

const supported = ["en", "fr", "ru"];

function resolveLocale(locale) {
    const normalized =
        normalizeLocale(locale);

    if (supported.includes(normalized)) {
        return normalized;
    }

    return "en";
}

Загрузка переводов сообщений

Обычно вместе с CLDR загружаются message catalog-файлы.

Пример структуры

messages/
├── en.json
├── fr.json
└── ru.json

Динамическая загрузка

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

    Globalize.loadMessages({
        [locale]: messages.default
    });
}

Полная схема инициализации

import Globalize from "globalize";

const loaded = new Set();

async function setupLocale(locale) {
    locale = resolveLocale(locale);

    if (!loaded.has(locale)) {

        const [
            numbers,
            calendars,
            currencies,
            messages
        ] = await Promise.all([
            import(`./cldr/${locale}/numbers.json`),
            import(`./cldr/${locale}/ca-gregorian.json`),
            import(`./cldr/${locale}/currencies.json`),
            import(`./messages/${locale}.json`)
        ]);

        Globalize.load(
            numbers.default,
            calendars.default,
            currencies.default
        );

        Globalize.loadMessages({
            [locale]: messages.default
        });

        loaded.add(locale);
    }

    Globalize.locale(locale);
}

Обработка ошибок загрузки

Сеть может быть недоступна.

Пример

async function safeLoadLocale(locale) {
    try {
        await setupLocale(locale);
    } catch (error) {

        console.error(
            "Ошибка загрузки локали",
            error
        );

        await setupLocale("en");
    }
}

Индикация состояния загрузки

Пока локаль загружается, интерфейс может показывать placeholder.

Пример

async function changeLanguage(locale) {
    showLoader();

    await setupLocale(locale);

    hideLoader();

    renderApp();
}

Динамическая смена языка

Одно из преимуществ lazy loading — возможность менять язык без перезагрузки страницы.

Пример

languageSelect.addEventListener(
    "change",
    async event => {

        const locale = event.target.value;

        await setupLocale(locale);

        rerender();
    }
);

Интеграция с React

В React локали часто загружаются через context.

Пример

const LocaleContext =
    React.createContext();

Provider

function LocaleProvider({ children }) {

    const [locale, setLocale] =
        useState("en");

    async function changeLocale(next) {
        await setupLocale(next);

        setLocale(next);
    }

    return (
        <LocaleContext.Provider
            value={{
                locale,
                changeLocale
            }}
        >
            {children}
        </LocaleContext.Provider>
    );
}

Интеграция с Vue

В Vue.js используется похожий подход.

const locale = ref("en");

async function setLocale(next) {
    await setupLocale(next);

    locale.value = next;
}

SSR и динамическая локализация

В server-side rendering важно:

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

Пример SSR-подхода

Сервер

await setupLocale(locale);

const html = renderToString(app);

Клиент

await hydrateLocale(window.__LOCALE__);

Избежание race condition

Проблема:

changeLanguage("fr");
changeLanguage("de");

Если fr загрузится позже de, интерфейс может перейти обратно на французский.


Решение через request id

let requestId = 0;

async function changeLanguage(locale) {

    const id = ++requestId;

    await setupLocale(locale);

    if (id !== requestId) {
        return;
    }

    render();
}

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

Для HTTP-запросов можно отменять старые загрузки.

let controller;

async function fetchLocale(url) {

    if (controller) {
        controller.abort();
    }

    controller = new AbortController();

    const response = await fetch(url, {
        signal: controller.signal
    });

    return response.json();
}

Разделение locale chunks

Хорошая практика — создавать отдельные чанки:

locale-en.js
locale-fr.js
locale-ru.js

а не единый:

locales.js

Это уменьшает сетевой трафик.


Tree shaking и CLDR

Некоторые bundler’ы могут исключать неиспользуемые данные.

Однако CLDR JSON-файлы обычно считаются side-effect ресурсами и редко эффективно tree-shake’ятся. Поэтому lazy loading остаётся главным методом оптимизации.


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

Локали можно хранить отдельно от основного приложения.

Пример

const base =
    "https://cdn.example.com/cldr";

Загрузка

const response = await fetch(
    `${base}/${locale}/numbers.json`
);

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

  • независимое кэширование;
  • CDN edge cache;
  • уменьшение размера deploy;
  • обновление локалей без пересборки приложения.

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

Для locale-файлов полезны:

Cache-Control: public, max-age=31536000

и versioned URLs:

/locales/v3/fr.json

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

Для больших enterprise-приложений локали могут храниться в IndexedDB.

Схема

  1. локаль скачивается;
  2. сохраняется локально;
  3. повторно читается без сети.

Это особенно полезно для PWA.


Предкомпиляция formatter-ов

В экосистеме Globalize Compiler существует подход с предкомпиляцией.

Вместо:

Globalize.numberFormatter()

во время runtime,

форматтер генерируется заранее.

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

  • меньше runtime-логики;
  • быстрее старт;
  • меньше размер runtime-кода.

Dynamic import и маршрутизация

Часто локали загружаются вместе с route chunks.

Пример

const AdminPage = lazy(async () => {

    await setupLocale(currentLocale);

    return import("./AdminPage");
});

Проблемы синхронного API

Некоторые старые архитектуры ожидают:

translate("hello");

без ожидания загрузки.

После внедрения lazy loading требуется:

  • bootstrap-фаза;
  • preload;
  • async initialization;
  • suspense-механизмы.

Suspense в React

<Suspense fallback={<Spinner />}>
    <App />
</Suspense>

Локаль может загружаться через asynchronous boundary.


Хранение текущей локали

Обычно локаль хранится:

  • в localStorage;
  • в cookie;
  • в URL;
  • в state manager.

localStorage

localStorage.setItem(
    "locale",
    "fr"
);

Восстановление

const locale =
    localStorage.getItem("locale")
    || "en";

URL-based локализация

Пример:

/en/products
/fr/products
/ru/products

Локаль извлекается из URL:

const locale =
    location.pathname.split("/")[1];

Lazy loading и microfrontend

В microfrontend-архитектуре каждый модуль может иметь собственные locale chunks.

Важно избегать:

  • дублирования CLDR;
  • повторного Globalize.load();
  • конфликтов локалей.

Централизованный locale manager

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

Пример интерфейса

localeManager.load("fr");

localeManager.set("fr");

localeManager.formatNumber(1000);

Это упрощает:

  • кэширование;
  • preload;
  • fallback;
  • смену языка;
  • интеграцию между модулями.

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

Инициализация до загрузки данных

Globalize.locale("fr");

без Globalize.load().


Отсутствие supplemental-данных

Ошибка:

E_MISSING_CLDR

Повторная загрузка CLDR

Globalize.load(data);

при каждом рендере.


Отсутствие fallback

setupLocale("uk-UA");

при отсутствии данных.


Смешивание sync и async API

const text = translate("hello");

до завершения загрузки локали.


Архитектурная схема production-приложения

Часто используется следующая структура:

src/
├── i18n/
│   ├── localeManager.js
│   ├── loaders/
│   ├── cache/
│   ├── formatters/
│   └── messages/
├── cldr/
└── app/

Рекомендуемая стратегия

Для production-систем оптимальной считается комбинация:

  • минимальный initial bundle;
  • dynamic import локалей;
  • preload популярных языков;
  • formatter cache;
  • fallback locale;
  • CDN-хранение locale chunks;
  • async bootstrap;
  • route-level splitting.

Такой подход обеспечивает:

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