Магазин стартует с одного языка, и первые месяцы всё живёт прямо в разметке: надпись на кнопке оплаты записана внутри JSX, письмо покупателю собирается из кусков шаблона, а фраза про сроки доставки повторяется в шести компонентах. Проблемы начинаются в тот день, когда приходит второй язык. Разработчик ищет по проекту русские слова, выносит их в константы, что-то теряет, и кусок интерфейса месяцами остаётся без перевода. Связка react-i18next 17.1.0 и ядра i18next 26.5.4 закрывает задачу иначе: строки переезжают в словари, компонент получает функцию перевода, а переключение языка умещается в одну строку кода. Ниже разобран рабочий контур локализации React-приложения: инициализация, хук useTranslation, русская плюрализация, ленивые словари и типизация ключей.
Инициализация i18next внутри React приложения с чистой конфигурацией
Сначала о терминах, чтобы дальше не путаться. i18next - независимая от фреймворка библиотека: она хранит словари, выбирает язык по приоритетам и подставляет значения в шаблоны. react-i18next - надстройка над ней, которая отдаёт переводы компонентам через контекст, хук и специальный компонент для сложных строк. Ставятся оба пакета одной командой, версии фиксируются явно, чтобы обновление не поменяло поведение незаметно.
npm install i18next@26.5.4 react-i18next@17.1.0
Минимальная конфигурация живёт в файле i18n.ts и выглядит скромнее, чем ожидает новичок:
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
i18n.use(initReactI18next).init({
resources: {
ru: {
translation: {
cart: "Корзина",
pay: "Оплатить",
delivery: "Доставка за 2 дня"
}
},
en: {
translation: {
cart: "Cart",
pay: "Checkout",
delivery: "Delivery in 2 days"
}
}
},
lng: "ru",
fallbackLng: "en",
interpolation: { escapeValue: false }
});
export default i18n;
Файл импортируется один раз в точке входа, например в main.tsx, и дальше о нём никто не вспоминает. Параметр lng задаёт стартовый язык, fallbackLng подстраховывает, когда нужного ключа не нашлось. Настройка interpolation.escapeValue: false отключает экранирование значений внутри библиотеки: React сам защищает разметку от вставок, а двойное экранирование превратило бы кавычки в амперсанд-последовательности на экране. Ещё у ключей есть вложенность: по умолчанию точка в ключе разделяет уровни, поэтому запись "nav.cart" читает поле cart внутри объекта nav. Это удобно, пока словари маленькие.
Хук useTranslation и компонент Trans для сложной разметки
Доступ к переводу внутри функционального компонента выглядит как обычный хук:
import { useTranslation } from "react-i18next";
function CartHeader({ count }: { count: number }) {
const { t, i18n } = useTranslation();
return (
<header>
<h1>{t("cart")}</h1>
<span>{t("delivery")}</span>
<button onClick={() => i18n.changeLanguage("en")}>
EN
</button>
</header>
);
}
Функция t ищет ключ в словаре текущего языка и возвращает строку. Хук подписан на события ядра, поэтому после вызова i18n.changeLanguage("en") интерфейс перерисуется с новыми текстами сам, вручную ничего прокидывать через пропсы не нужно. Такой механизм снимает целую прослойку кода: раньше ради смены языка приходилось поднимать состояние наверх и обновлять дерево целиком.
Когда внутри перевода нужна разметка, на сцену выходит Trans. В словарь пишется строка с псевдотегами, в компоненте описываются соответствия тегов и настоящих элементов:
import { Trans } from "react-i18next";
function PromoBanner() {
return (
<Trans
i18nKey="promoTerms"
components={{ strong: <strong /> }}
/>
);
}
Словарь для вызова хранит текст вида "До конца недели <strong>доставка бесплатна</strong> при заказе от 3000". Перевод и вёрстка остаются раздельными: верстальщик меняет оболочку, переводчик правит словарь, и никто не мешает друг другу. Ординарные строки при этом по-прежнему работают через t, Trans нужен только там, где без тегов никак.
Русская плюрализация со словарными суффиксами one few many other
Счётчик корзины вынуждает склонять: 1 товар, 2 товара, 5 товаров, а 21 товар снова в единственном числе. Перебирать остатки от деления руками - верный способ однажды запутаться. Начиная с версии 21 ядро i18next строит формы по правилам стандарта Unicode через механизм Intl.PluralRules. Для русского языка определены четыре категории: one для чисел вида 1, 21, 31 и похожих, few для 2, 3, 4 и 22-24, many для нуля и диапазона 5-20, other для дробных значений вроде 1.5.
Словарь получает варианты ключа с суффиксами категорий:
{
"items_one": "{{count}} товар ждёт в корзине",
"items_few": "{{count}} товара ждут в корзине",
"items_many": "{{count}} товаров ждут в корзине",
"items_other": "{{count}} товара ждут в корзине"
}
Вызов остаётся единым, библиотека сама выберет форму по значению count:
<span>{t("items", { count: 17 })}</span>
Плейсхолдер {{count}} подставляется автоматически, отдельно передавать значение в интерполяцию не нужно. Тот же механизм умеет учитывать контекст вроде грамматического рода: в словарь добавляются ключи welcome_male и welcome_female, а в компонент передаётся параметр context:
t("welcome", { context: "female" })
Переводчик работает со знакомыми формами слов, а разработчик один раз описывает решётку склонений, после чего счётчики по всему проекту говорят правильно.
Пространства имён и ленивая загрузка словарей по требованию
Когда словарь разрастается за пару тысяч ключей, держать всё в одном бандле расточительно: страницы оформления заказа тянут тексты про личный кабинет, а каталог - инструкцию по возврату. i18next делит словари на пространства имён. В конфигурации задаются списки ns и defaultNS, а хук принимает перечень нужных секций:
const { t } = useTranslation(["checkout", "common"]);
t("summary", { ns: "checkout" });
Второй рычаг - ленивая загрузка. Пакет i18next-resources-to-backend версии 1.2.3 превращает динамические импорты в загрузчик словарей: файлы подхватываются в момент первого обращения к языку или секции и кешируются в памяти.
import resourcesToBackend from "i18next-resources-to-backend";
i18n.use(
resourcesToBackend((language, namespace) =>
import(`./locales/${language}/${namespace}.json`)
)
);
Если словари раздаёт сервер, выручает пакет i18next-http-backend версии 4.0.4: он забирает JSON по сети и кеширует ответы, а путь задаётся шаблоном с подстановкой языка и секции:
import HttpBackend from "i18next-http-backend";
i18n.use(HttpBackend).init({
backend: { loadPath: "/locales/{{lng}}/{{ns}}.json" }
});
Сборка не прячет тексты в бандл, а редакторы контента получают файлы, которые можно менять без пересборки фронтенда.
Третий элемент - определение языка посетителя. Пакет i18next-browser-languagedetector 8.2.1 читает подсказки в порядке, заданном конфигурацией: сначала сохранённый выбор из localStorage, затем языковые настройки браузера. Порядок легко поменять, как и список хранилищ, куда детектор запомнит найденное:
import LanguageDetector from "i18next-browser-languagedetector";
i18n.use(LanguageDetector).use(initReactI18next).init({
supportedLngs: ["ru", "en", "kk"],
fallbackLng: "ru",
detection: {
order: ["localStorage", "navigator"],
caches: ["localStorage"]
}
});
Подключение нового языка после такой настройки сводится к трём шагам:
- положить файлы нового языка в каталог locales, повторив набор пространств имён базового языка;
- добавить код языка в массив supportedLngs, чтобы детектор и типизация увидели новую запись;
- прогнать сборку с проверкой типов и закрыть расхождения между словарями до релиза.
Типизация ключей перевода и защита от опечаток в словарях
Опечатка в ключе перевода - коварный баг: рантайм не падает, на экране просто распечатывается сам ключ, и заметит его только дотошный тестировщик. TypeScript закрывает дыру через расширение интерфейсов i18next. Рядом с конфигурацией создаётся файл i18next.d.ts:
declare module "i18next" {
interface CustomTypeOptions {
defaultNS: "translation";
resources: {
translation: typeof import("./locales/ru/translation.json");
};
}
}
После этого вызов t("cart") подсказывает существующие ключи прямо в редакторе, а несуществующий подчёркивается красным до запуска приложения. Требования к среде прописаны в зависимостях пакета: react-i18next 17.1.0 рассчитан на React не ниже 16.8, ядро i18next не ниже 26.2.0 и TypeScript пятой-седьмой мажорной ветки. Для массивов и объектов в словарях предусмотрен режим returnObjects:
const days = t("weekDays", { returnObjects: true }) as string[];
Так словарь остаётся единственным местом хранения текстов, а код не обрастает дублирующими константами. Проверенный на типах словарь ещё и страховка от рассинхрона: правка ключа в одном языке подсвечивает места, где второй язык отстал.
Поймать пробелы в переводах помогает и сама библиотека. Опция saveMissing: true отправляет каждый отсутствующий ключ в колбек missingKeyHandler, куда нетрудно подключить журнал ошибок или отправку в систему аналитики:
i18n.init({
saveMissing: true,
missingKeyHandler: (lng, ns, key) => {
console.warn(`нет перевода для ключа ${key}`);
}
});
Пустые ключи всплывают в мониторинге до того, как на них пожалуется первый покупатель.
Тестирование компонентов со словарями и моком для функции перевода
В юнит-тестах настоящая библиотека часто бывает лишней: снапшоты ломаются от каждой правки текста, а проверять хочется логику, а не словари. Распространённый приём - подменить модуль перевода моком, который возвращает сам ключ:
jest.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: "ru", changeLanguage: () => {} }
}),
Trans: ({ i18nKey }: { i18nKey: string }) => (
<span>{i18nKey}</span>
)
}));
Снапшот кнопки хранит строку "cart" вместо живого текста, и тест не падает из-за переименования в словаре. Полную интеграцию проверяют отдельно: в тестовой среде инициализируется настоящий i18next с мини-набором ключей, и пара ключевых экранов прогоняется целиком. Команда получает и быстрые модульные тесты, и страховку реальных словарей.
Форматирование дат и чисел плюс выбор между react-i18next и next-intl
Формат денег и дат словари не решают, а без них локализация неполна: русскому покупателю привычно "5 000,00 ₽", а носителю английского - те же деньги с другим порядком и разделителями. Встроенный в платформу объект Intl форматирует значения по локали без единой дополнительной библиотеки:
const { i18n } = useTranslation();
const price = new Intl.NumberFormat(i18n.language, {
style: "currency",
currency: "RUB"
}).format(4990);
const shipped = new Intl.DateTimeFormat(i18n.language, {
dateStyle: "long"
}).format(new Date());
Браузер сам подставит разделители, порядок компонентов даты и знак валюты, а при смене языка достаточно пересчитать значения с новой локалью. Отдельная радость - относительное время: фразы "доставим завтра" и "привезли 2 часа назад" собирает Intl.RelativeTimeFormat:
const rtf = new Intl.RelativeTimeFormat(i18n.language, {
numeric: "auto"
});
rtf.format(1, "day");
rtf.format(-2, "hour");
Для русской локали первый вызов вернёт "завтра", для английской "tomorrow"; второй покажет, сколько времени прошло с отправки заказа. Для сложных составных сообщений с выбором форм по полу и числу существует формат ICU: его подключает пакет i18next-icu версии 2.6.1, когда встроенных механизмов перестаёт хватать.
Когда проект перерастает клиентский React, выбор библиотеки пересматривается. Для Next.js с серверными компонентами и маршрутами с языковыми префиксами создан next-intl, актуальная версия 4.14.9: он подхватывает словари на сервере, дружит с App Router и умеет раздавать переводы серверным компонентам. Есть и компиляторный путь: @lingui/core 6.10.0 извлекает строки из исходников при сборке и хранит каталоги в переносимом формате, что удобно для команд с внешними переводчиками. react-i18next при всём этом остаётся рабочей лошадкой: он не привязан к маршрутизатору, ставится в старые связки и работает на чистом клиенте без сборочных изменений.
Через год после переезда на словари добавление казахской и узбекской версий занимает вечер: это пара папок с JSON, две записи в конфигурации и один прогон типизации. Локализация из разового проекта превращается в рутину, а фразы о доставке перестают размножаться по компонентам, потому что у каждой из них остался ровно один дом и один хозяин - ключ в словаре.