Ленивая загрузка данных

Библиотека Globalize работает поверх стандарта CLDR и использует набор JSON-данных для локализации:

  • правила форматирования дат;
  • числа и валюты;
  • множественные формы;
  • сообщения;
  • часовые пояса;
  • единицы измерения.

Полная загрузка всех локалей и всех CLDR-пакетов резко увеличивает размер клиентского JavaScript-приложения. Особенно заметно это в многоязычных интерфейсах, где используются десятки языков и регионов.

Ленивая загрузка (lazy loading) позволяет:

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

Проблема полной загрузки CLDR

Типичная ошибка — импортировать все данные сразу:

import Globalize fr om "globalize";

import likelySubtags fr om "cldr-data/supplemental/likelySubtags.json";
import numberingSystems fr om "cldr-data/supplemental/numberingSystems.json";

import enNumbers fr om "cldr-data/main/en/numbers.json";
import frNumbers from "cldr-data/main/fr/numbers.json";
import deNumbers from "cldr-data/main/de/numbers.json";
import ruNumbers from "cldr-data/main/ru/numbers.json";

Globalize.load(
    likelySubtags,
    numberingSystems,
    enNumbers,
    frNumbers,
    deNumbers,
    ruNumbers
);

Подход приводит к нескольким проблемам:

Проблема Последствие
Большой bundle Медленная загрузка
Дублирование данных Рост памяти
Ненужные локали Лишний сетевой трафик
Долгий parse JSON Увеличение TTI
Плохой cache split Низкая эффективность CDN

Архитектура ленивой загрузки

Обычно приложение строится вокруг следующих принципов:

  1. Базовые supplemental-данные загружаются один раз.
  2. Данные конкретной локали загружаются динамически.
  3. Создание экземпляра Globalize выполняется после загрузки CLDR.
  4. Локали кэшируются.
  5. Повторные запросы исключаются.

Схема:

Приложение
    ↓
Определение locale
    ↓
Проверка кэша
    ↓
Динамический import()
    ↓
Globalize.load()
    ↓
Создание formatter
    ↓
Использование locale

Базовая организация файлов

Структура проекта:

src/
    i18n/
        loader.js
        cache.js
        locales/
            en.js
            fr.js
            ru.js

Разделение supplemental и locale data

Supplemental-данные нужны почти всегда:

import Globalize from "globalize";

import likelySubtags from "cldr-data/supplemental/likelySubtags.json";
import plurals from "cldr-data/supplemental/plurals.json";
import numberingSystems from "cldr-data/supplemental/numberingSystems.json";

Globalize.load(
    likelySubtags,
    plurals,
    numberingSystems
);

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

Локализованные данные загружаются отдельно.


Динамический import локали

Простейшая реализация:

async function loadLocale(locale) {
    const data = await import(
        `cldr-data/main/${locale}/numbers.json`
    );

    Globalize.load(data.default);
}

Современные bundler-системы:

  • Webpack
  • Vite
  • Rollup

автоматически создают отдельные chunks.


Полная загрузка локали

Обычно одной секции недостаточно. Для корректной работы нужны:

  • numbers;
  • currencies;
  • ca-gregorian;
  • timeZoneNames;
  • dateFields;
  • units.

Пример:

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

    modules.forEach(module => {
        Globalize.load(module.default);
    });
}

Инициализация Globalize после загрузки

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

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

const globalize = new Globalize("fr");

await loadLocale("fr");

Правильно:

await loadLocale("fr");

const globalize = new Globalize("fr");

Асинхронный locale manager

Практический вариант:

import Globalize from "globalize";

const loadedLocales = new Set();

export async function setLocale(locale) {
    if (!loadedLocales.has(locale)) {
        await loadLocale(locale);

        loadedLocales.add(locale);
    }

    return new Globalize(locale);
}

Предотвращение повторных запросов

Даже с кэшем возможна проблема race condition.

Пример:

await Promise.all([
    setLocale("de"),
    setLocale("de"),
    setLocale("de")
]);

Без защиты выполнятся три сетевых запроса.

Решение:

const loadingPromises = new Map();

async function loadLocale(locale) {
    if (loadingPromises.has(locale)) {
        return loadingPromises.get(locale);
    }

    const promise = internalLoad(locale);

    loadingPromises.set(locale, promise);

    await promise;

    loadingPromises.delete(locale);
}

Реализация internalLoad

async function internalLoad(locale) {
    const modules = await Promise.all([
        import(`cldr-data/main/${locale}/numbers.json`),
        import(`cldr-data/main/${locale}/currencies.json`)
    ]);

    modules.forEach(module => {
        Globalize.load(module.default);
    });
}

Ленивая загрузка сообщений

Кроме CLDR часто используются message catalogs.

Структура:

messages/
    en.json
    fr.json
    ru.json

Загрузка:

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

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

Совместная загрузка CLDR и messages

async function initializeLocale(locale) {
    await Promise.all([
        loadLocale(locale),
        loadMessages(locale)
    ]);

    Globalize.locale(locale);
}

Переключение языка во время работы

Типичная схема:

async function changeLanguage(locale) {
    await initializeLocale(locale);

    renderApplication();
}

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

Популярный вариант:

import { useEffect, useState } from "react";

export function useGlobalize(locale) {
    const [globalize, setGlobalize] = useState(null);

    useEffect(() => {
        async function init() {
            await initializeLocale(locale);

            setGlobalize(new Globalize(locale));
        }

        init();
    }, [locale]);

    return globalize;
}

Обработка состояния загрузки

Пока locale не загружена:

function App() {
    const globalize = useGlobalize("fr");

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

    return (
        <div>
            {globalize.formatNumber(1000)}
        </div>
    );
}

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

В экосистеме React возможна интеграция с Suspense:

const localePromiseCache = new Map();

function load(locale) {
    if (!localePromiseCache.has(locale)) {
        localePromiseCache.set(
            locale,
            initializeLocale(locale)
        );
    }

    throw localePromiseCache.get(locale);
}

Разделение bundle по локалям

Webpack автоматически создаёт chunks:

import(
    /* webpackChunkName: "locale-[request]" */
    `cldr-data/main/${locale}/numbers.json`
);

Результат:

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

Предзагрузка вероятной локали

Иногда полезно preload следующего языка.

Например:

loadLocale("en");

сразу после выбора "ru" как fallback.


Стратегия fallback locale

Если локаль отсутствует:

async function safeLoadLocale(locale) {
    try {
        await loadLocale(locale);
    } catch {
        await loadLocale("en");
    }
}

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

Пользовательские locale часто отличаются:

en-US
en-us
EN_us

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

function normalizeLocale(locale) {
    return locale.toLowerCase();
}

Более корректный вариант:

Intl.getCanonicalLocales(locale)[0];

Загрузка locale по Accept-Language

Определение браузерной локали:

const locale = navigator.language;

Пример:

async function bootstrap() {
    const locale = navigator.language;

    await initializeLocale(locale);

    startApplication();
}

Ограничение списка поддерживаемых локалей

Нельзя доверять любой locale из браузера.

const supportedLocales = [
    "en",
    "fr",
    "de",
    "ru"
];

Проверка:

function resolveLocale(locale) {
    if (supportedLocales.includes(locale)) {
        return locale;
    }

    return "en";
}

Ленивая загрузка formatter-ов

Создание formatter-ов тоже может быть дорогим.

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

const formatter = globalize.numberFormatter();

при каждом render.

Правильнее:

const formatterCache = new Map();

function getNumberFormatter(locale) {
    if (!formatterCache.has(locale)) {
        const globalize = new Globalize(locale);

        formatterCache.set(
            locale,
            globalize.numberFormatter()
        );
    }

    return formatterCache.get(locale);
}

Кэширование date formatter

const dateFormatterCache = new Map();

function getDateFormatter(locale) {
    if (!dateFormatterCache.has(locale)) {
        const globalize = new Globalize(locale);

        dateFormatterCache.set(
            locale,
            globalize.dateFormatter({
                datetime: "medium"
            })
        );
    }

    return dateFormatterCache.get(locale);
}

Очистка кэша

Для SPA с большим количеством языков память может расти.

Пример LRU-кэша:

class LocaleCache {
    constructor(lim it = 5) {
        this.lim it = lim it;
        this.cache = new Map();
    }

    set(locale, value) {
        if (this.cache.size >= this.lim it) {
            const firstKey =
                this.cache.keys().next().value;

            this.cache.delete(firstKey);
        }

        this.cache.set(locale, value);
    }

    get(locale) {
        return this.cache.get(locale);
    }
}

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

CLDR-файлы часто выносятся на CDN.

Пример:

async function fetchLocale(locale) {
    const response = await fetch(
        `/locales/${locale}/numbers.json`
    );

    return response.json();
}

Параллельная загрузка данных

Последовательный вариант:

await loadNumbers(locale);
await loadCurrencies(locale);
await loadDates(locale);

Неэффективен.

Лучше:

await Promise.all([
    loadNumbers(locale),
    loadCurrencies(locale),
    loadDates(locale)
]);

Обработка сетевых ошибок

async function loadJson(url) {
    const response = await fetch(url);

    if (!response.ok) {
        throw new Error(
            `Failed to load ${url}`
        );
    }

    return response.json();
}

Retry-механизм

async function retry(fn, retries = 3) {
    for (let i = 0; i < retries; i++) {
        try {
            return await fn();
        } catch (error) {
            if (i === retries - 1) {
                throw error;
            }
        }
    }
}

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

await retry(() => loadLocale("fr"));

Prefetch локалей

Некоторые bundler поддерживают prefetch hints:

import(
    /* webpackPrefetch: true */
    `./locales/${locale}.json`
);

Браузер загрузит chunk в idle-time.


Lazy loading и SSR

На сервере стратегия отличается:

  • locale обычно известна заранее;
  • можно загрузить данные синхронно;
  • важно избежать hydration mismatch.

Пример:

await initializeLocale(request.locale);

const html = renderToString(app);

Передача locale state клиенту

<script>
window.__LOCALE__ = "fr";
</script>

Клиент:

await initializeLocale(window.__LOCALE__);

Гидратация без повторной загрузки

Сервер может встроить locale data:

<script>
window.__CLDR__ = {...};
</script>

Клиент:

Globalize.load(window.__CLDR__);

Tree shaking и Globalize

Модульная архитектура помогает уменьшать bundle.

Например:

import Globalize from "globalize/dist/globalize/number";

Вместо:

import Globalize from "globalize";

Разделение по функциональности

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

Можно загружать отдельно:

async function loadNumberModule(locale) {
    const data = await import(
        `cldr-data/main/${locale}/numbers.json`
    );

    Globalize.load(data.default);
}

И отдельно:

async function loadDateModule(locale) {
    const data = await import(
        `cldr-data/main/${locale}/ca-gregorian.json`
    );

    Globalize.load(data.default);
}

Производительность lazy loading

Ключевые преимущества:

Метрика Улучшение
Initial JS Меньше
Parse time Быстрее
Memory usage Ниже
TTI Лучше
CDN cache Эффективнее

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

Создание formatter до загрузки CLDR

const formatter =
    new Globalize("fr").numberFormatter();

при отсутствии locale data вызовет исключение.


Загрузка неполного набора данных

Например:

numbers.json

без:

likelySubtags
numberingSystems

может привести к ошибкам резолвинга.


Отсутствие кэша

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

await loadLocale("fr");

будет выполняться многократно.


Слишком мелкие chunks

Плохо:

1 chunk = 1 json

Слишком много запросов.

Лучше:

1 locale = 1 chunk

Практическая production-структура

i18n/
    core/
        globalize.js
        cache.js
        manager.js

    locales/
        en/
            cldr.js
            messages.js

        fr/
            cldr.js
            messages.js

Полноценный locale manager

import Globalize from "globalize";

const loaded = new Set();
const loading = new Map();

async function load(locale) {
    if (loaded.has(locale)) {
        return;
    }

    if (loading.has(locale)) {
        return loading.get(locale);
    }

    const promise = (async () => {
        const [
            cldr,
            messages
        ] = await Promise.all([
            import(`./locales/${locale}/cldr.js`),
            import(`./locales/${locale}/messages.js`)
        ]);

        Globalize.load(cldr.default);

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

        loaded.add(locale);
    })();

    loading.set(locale, promise);

    await promise;

    loading.delete(locale);
}

export async function getGlobalize(locale) {
    await load(locale);

    return new Globalize(locale);
}

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

const globalize =
    await getGlobalize("ru");

const price =
    globalize.formatCurrency(100, "RUB");

console.log(price);

Интеграция с роутингом

Для SPA удобно связывать locale с URL:

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

Загрузка:

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

await initializeLocale(locale);

Lazy loading и микрофронтенды

В архитектуре microfrontend каждый модуль может:

  • загружать собственные locale;
  • использовать общий CLDR cache;
  • регистрировать messages независимо.

Важно избегать повторного вызова:

Globalize.load(...)

для одинаковых данных.


Мониторинг производительности

Полезно измерять:

performance.mark("locale-start");

await initializeLocale("fr");

performance.mark("locale-end");

performance.measure(
    "locale-load",
    "locale-start",
    "locale-end"
);

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

Иногда locale-файлы дополнительно:

  • gzip-сжимаются;
  • brotli-сжимаются;
  • минифицируются;
  • агрегируются в бинарные форматы.

Стратегия progressive localization

Крупные приложения иногда используют этапы:

  1. Базовый UI на fallback locale.
  2. Асинхронная загрузка нужной locale.
  3. Мгновенное обновление интерфейса.

Подход особенно полезен при медленном соединении.


Lazy loading и offline cache

В сочетании с Service Worker:

  • locale можно кэшировать;
  • повторно использовать офлайн;
  • обновлять в фоне.

Пример стратегии:

Cache First

для locale chunks.


Рекомендации для production

  • разделять locale chunks;
  • кэшировать formatter-ы;
  • использовать Promise deduplication;
  • загружать только нужные модули;
  • хранить supplemental отдельно;
  • избегать повторного parse JSON;
  • применять prefetch для популярных языков;
  • использовать fallback locale;
  • контролировать размер CLDR;
  • измерять TTI и memory usage.