Suspense и асинхронная загрузка

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

Библиотека i18next поддерживает асинхронную загрузку переводов через HTTP backend, а интеграция react-i18next тесно связана с механизмом Suspense из React.


Принцип асинхронной загрузки переводов

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

  1. Проверить наличие переводов в памяти.
  2. Выполнить HTTP-запрос за отсутствующими ресурсами.
  3. Дождаться загрузки.
  4. Обновить интерфейс.

Пример структуры файлов:

public/
└── locales/
    ├── en/
    │   ├── common.json
    │   └── dashboard.json
    └── ru/
        ├── common.json
        └── dashboard.json

Каждый namespace загружается отдельно.


Установка backend-модуля

Для загрузки переводов по HTTP используется пакет:

npm install i18next-http-backend

Для React-проектов дополнительно обычно применяется:

npm install react-i18next

Базовая конфигурация асинхронной загрузки

Файл инициализации:

import i18n from 'i18next';
import Backend from 'i18next-http-backend';
import { initReactI18next } from 'react-i18next';

i18n
  .use(Backend)
  .use(initReactI18next)
  .init({
    lng: 'ru',

    fallbackLng: 'en',

    ns: ['common'],
    defaultNS: 'common',

    backend: {
      loadPath: '/locales/{{lng}}/{{ns}}.json'
    },

    interpolation: {
      escapeValue: false
    }
  });

export default i18n;

Что происходит в этой конфигурации

  • Backend подключает механизм HTTP-загрузки.
  • loadPath задаёт шаблон URL.
  • {{lng}} заменяется текущим языком.
  • {{ns}} заменяется namespace.
  • Переводы загружаются только при необходимости.

Suspense в react-i18next

Когда компонент использует переводы, которых ещё нет в памяти, react-i18next может временно приостановить рендеринг.

Для этого используется Suspense.

Пример:

import React, { Suspense } from 'react';
import ReactDOM from 'react-dom/client';

import './i18n';
import App from './App';

const root = ReactDOM.createRoot(
  document.getElementById('root')
);

root.render(
  <Suspense fallback={<div>Загрузка переводов...</div>}>
    <App />
  </Suspense>
);

Как работает Suspense

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

  1. Компонент вызывает useTranslation.

  2. react-i18next проверяет наличие namespace.

  3. Если namespace отсутствует:

    • запускается загрузка;
    • выбрасывается Promise;
    • React активирует Suspense fallback.
  4. После завершения загрузки интерфейс перерисовывается.


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

Пример компонента:

import { useTranslation } from 'react-i18next';

function Dashboard() {
  const { t } = useTranslation('dashboard');

  return (
    <div>
      <h1>{t('title')}</h1>
      <p>{t('description')}</p>
    </div>
  );
}

export default Dashboard;

Если namespace dashboard ещё не загружен:

/locales/ru/dashboard.json

будет выполнен HTTP-запрос.


Lazy loading namespace

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

Например:

import { lazy, Suspense } from 'react';

const AdminPage = lazy(() => import('./AdminPage'));

function App() {
  return (
    <Suspense fallback={<div>Загрузка...</div>}>
      <AdminPage />
    </Suspense>
  );
}

Если внутри AdminPage используется:

useTranslation('admin')

то одновременно могут загружаться:

  • JS-код страницы;
  • namespace переводов.

Это уменьшает размер первоначального бандла.


Комбинация React.lazy и i18next

Частая архитектура:

route chunk
    +
translation namespace

Для каждого маршрута:

  • отдельный JS bundle;
  • отдельные переводы.

Пример:

/dashboard
    dashboard.js
    dashboard.json

/settings
    settings.js
    settings.json

Такой подход особенно полезен в:

  • крупных SPA;
  • административных панелях;
  • enterprise-приложениях;
  • микрофронтендах.

Отключение Suspense

Иногда Suspense использовать неудобно:

  • SSR;
  • старые React-приложения;
  • собственные механизмы загрузки;
  • нестандартный lifecycle.

В этом случае Suspense можно отключить:

i18n.init({
  react: {
    useSuspense: false
  }
});

Ручная обработка загрузки

При отключённом Suspense состояние загрузки нужно обрабатывать самостоятельно.

Пример:

import { useTranslation } from 'react-i18next';

function Profile() {
  const { t, ready } = useTranslation('profile');

  if (!ready) {
    return <div>Загрузка...</div>;
  }

  return (
    <div>
      <h1>{t('title')}</h1>
    </div>
  );
}

Поле ready

Флаг ready показывает:

  • загружены ли namespace;
  • готовы ли переводы к использованию.

Разница между Suspense и ready

Подход Поведение
Suspense React автоматически приостанавливает рендер
ready Разработчик вручную управляет загрузкой

Suspense уменьшает количество boilerplate-кода, но требует поддержки React Suspense.


Предзагрузка namespace

Иногда необходимо заранее загрузить переводы.

Для этого используется:

i18n.loadNamespaces(['dashboard', 'profile']);

Пример:

await i18n.loadNamespaces('dashboard');

После этого компонент сможет отрендериться без fallback.


Предзагрузка языка

Можно заранее загрузить другой язык:

await i18n.loadLanguages(['en']);

Это полезно:

  • перед переключением языка;
  • при hover на переключателе;
  • во время idle-состояния приложения.

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

Пример:

import i18n from './i18n';

async function changeLanguage(lang) {
  await i18n.changeLanguage(lang);
}

Если переводы отсутствуют:

  • backend загрузит JSON;
  • интерфейс обновится автоматически.

Что происходит внутри changeLanguage

Метод:

i18n.changeLanguage('de')

выполняет:

  1. смену текущего языка;
  2. загрузку namespace;
  3. обновление подписчиков;
  4. перерисовку React-компонентов.

Кэширование переводов

После загрузки namespace сохраняются в памяти.

Повторный запрос не выполняется:

useTranslation('dashboard');

если namespace уже был загружен ранее.


Использование нескольких namespace

Пример:

const { t } = useTranslation([
  'common',
  'dashboard',
  'charts'
]);

При первом рендере будут загружены все перечисленные namespace.


Проблема waterfall-загрузки

Плохой сценарий:

Компонент A
    -> загрузка namespace A

После рендера:
Компонент B
    -> загрузка namespace B

Возникает каскад последовательных запросов.


Как избежать waterfall

Лучше заранее объявлять необходимые namespace:

useTranslation([
  'dashboard',
  'charts',
  'widgets'
]);

или предзагружать их:

await i18n.loadNamespaces([
  'dashboard',
  'charts',
  'widgets'
]);

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

Backend может вернуть:

  • 404;
  • 500;
  • timeout;
  • network error.

Можно отслеживать события:

i18n.on('failedLoading', (lng, ns, msg) => {
  console.error(lng, ns, msg);
});

fallbackLng при ошибках

Если язык отсутствует:

/locales/de/common.json -> 404

будет использоваться:

fallbackLng: 'en'

fallbackNS

Можно определить fallback namespace:

i18n.init({
  fallbackNS: 'common'
});

Если ключ отсутствует:

t('save')

библиотека попробует найти его в common.


Suspense boundaries

В больших приложениях полезно создавать несколько boundaries.

Пример:

<Suspense fallback={<PageLoader />}>
  <Dashboard />
</Suspense>

или:

<Suspense fallback={<SidebarLoader />}>
  <Sidebar />
</Suspense>

Это позволяет:

  • изолировать загрузку;
  • избежать блокировки всего интерфейса;
  • улучшить UX.

Глобальный Suspense

Частая архитектура:

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

Недостаток:

  • любой незагруженный namespace блокирует всё приложение.

Локальный Suspense

Более гибкий подход:

<App>
  <Header />

  <Suspense fallback={<WidgetLoader />}>
    <AnalyticsWidget />
  </Suspense>
</App>

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


Suspense и Server Side Rendering

При SSR Suspense требует особой настройки.

Основные проблемы:

  • namespace могут не успеть загрузиться;
  • возможен hydration mismatch;
  • сервер и клиент могут иметь разные ресурсы.

Предварительная загрузка для SSR

На сервере обычно:

await i18n.loadNamespaces([
  'common',
  'dashboard'
]);

Только после этого выполняется render.


serializeInitialI18nStore

Популярный подход SSR:

  1. Сервер загружает переводы.
  2. Store сериализуется в HTML.
  3. Клиент использует готовые данные.

Это позволяет избежать повторной загрузки.


Backend chaining

Иногда используется несколько источников переводов:

  • память;
  • localStorage;
  • CDN;
  • API.

Для этого применяется chained backend.

Пример архитектуры:

localStorage
    ↓
CDN
    ↓
API

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

Пакет:

npm install i18next-localstorage-backend

Позволяет:

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

chained backend

Пример:

import i18n from 'i18next';

import ChainedBackend from 'i18next-chained-backend';
import HttpBackend from 'i18next-http-backend';
import LocalStorageBackend from 'i18next-localstorage-backend';

i18n
  .use(ChainedBackend)
  .init({
    backend: {
      backends: [
        LocalStorageBackend,
        HttpBackend
      ],

      backendOptions: [
        {
          expirationTime: 7 * 24 * 60 * 60 * 1000
        },

        {
          loadPath: '/locales/{{lng}}/{{ns}}.json'
        }
      ]
    }
  });

Оптимизация размера переводов

Большие JSON-файлы ухудшают производительность.

Рекомендуется:

  • разбивать namespace;
  • избегать огромного common.json;
  • разделять переводы по страницам;
  • хранить редко используемые тексты отдельно.

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

i18next умеет загружать namespace параллельно.

Пример:

useTranslation([
  'common',
  'dashboard',
  'charts'
]);

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


HTTP batching

Для уменьшения числа запросов используется batching backend.

Пример:

/locales/resources.json?lng=ru&ns=common,dashboard,charts

Это снижает:

  • latency;
  • overhead HTTP;
  • нагрузку на сервер.

Event lifecycle загрузки

Основные события:

i18n.on('loaded', handler);

i18n.on('failedLoading', handler);

i18n.on('languageChanged', handler);

Событие loaded

Пример:

i18n.on('loaded', (loaded) => {
  console.log(loaded);
});

Содержит информацию о загруженных ресурсах.


Race conditions при переключении языка

Быстрое переключение:

ru -> en -> de -> fr

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

Современные версии i18next корректно обрабатывают такие сценарии, но backend должен поддерживать отмену или игнорирование устаревших ответов.


Suspense и Concurrent Rendering

В React 18 Suspense тесно связан с concurrent rendering.

Это позволяет:

  • не блокировать UI;
  • показывать fallback частично;
  • выполнять плавные переходы.

startTransition и смена языка

Пример:

import { startTransition } from 'react';

function switchLanguage(lang) {
  startTransition(() => {
    i18n.changeLanguage(lang);
  });
}

React помечает обновление как некритичное.


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

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

const [loading, setLoading] = useState(false);

async function changeLang(lang) {
  setLoading(true);

  await i18n.changeLanguage(lang);

  setLoading(false);
}

UX-проблемы Suspense

Неправильное использование может вызывать:

  • мигание интерфейса;
  • исчезновение контента;
  • резкие переключения;
  • layout shift.

Минимизация visual flicker

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

  • предзагрузка namespace;
  • локальные Suspense boundaries;
  • skeleton loaders;
  • сохранение предыдущего UI;
  • transition-анимации.

Skeleton вместо простого fallback

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

fallback={<div>Loading...</div>}

Лучше:

fallback={<DashboardSkeleton />}

Асинхронная загрузка в микрофронтендах

Каждый микрофронтенд может:

  • иметь собственный i18next instance;
  • загружать свои namespace;
  • использовать собственный backend.

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

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

Debug режим

Для анализа загрузки полезен debug:

i18n.init({
  debug: true
});

В консоли будут отображаться:

  • запросы;
  • namespace;
  • fallback;
  • события загрузки.

Проверка загрузки namespace

Можно проверить наличие ресурсов:

i18n.hasResourceBundle('ru', 'dashboard');

Добавление переводов вручную

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

i18n.addResourceBundle(
  'ru',
  'dashboard',
  {
    title: 'Панель'
  }
);

После этого namespace считается загруженным.


Частичная загрузка переводов

Некоторые backend-системы поддерживают:

  • загрузку только нужных ключей;
  • сегментированные namespace;
  • streaming ресурсов.

Это особенно актуально для очень больших приложений.


Архитектура production-проектов

Типичная структура:

src/
├── i18n/
│   ├── index.js
│   ├── backends/
│   ├── detectors/
│   └── config/
│
├── locales/
│   ├── en/
│   ├── ru/
│   └── de/
│
└── pages/
    ├── dashboard/
    ├── profile/
    └── settings/

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

Использовать namespace по страницам

Хорошо:

dashboard.json
profile.json
settings.json

Плохо:

translations.json

Избегать глобального Suspense

Лучше локализовать boundaries.


Предзагружать критические namespace

Например:

  • navigation;
  • auth;
  • layout.

Кэшировать переводы

Особенно важно для:

  • мобильных сетей;
  • слабых соединений;
  • enterprise SPA.

Использовать fallbackLng

Это предотвращает пустой UI при ошибках.


Контролировать размер JSON

Очень большие translation-файлы ухудшают:

  • Time To Interactive;
  • hydration;
  • cold start;
  • memory usage.