Разработчик добавил в проект словари ещё двух языков, прогнал сборку и посчитал задачу закрытой. А через неделю пришла жалоба: на арабской версии счётчик товаров показывает бессмысленный набор слов, а в русской фраза про пять яблок выглядит так, будто её писал иностранец. Перевод был готов, а интерфейс звучал неправильно. Причина в устройстве числовых форм, и именно об неё спотыкаются почти все, кто берётся за локализацию.
Локализация начинается с разделения подготовки и перевода
В разработке различают два процесса, которые часто смешивают. Интернационализация это подготовка приложения к работе с разными языками без переписывания кода, локализация это адаптация под конкретную страну или язык: перевод строк и местные особенности. Разница принципиальна: подготовка делается один раз и на раннем этапе, адаптация повторяется для каждого нового языка. Если код не подготовлен заранее, каждый язык превращается в отдельный проект с правками в логике.
Аббревиатуры пришли из практики: длинное слово сократили, посчитав буквы между первой и последней, и получились i18n для интернационализации и l10n для локализации. По таким сокращениям в названиях файлов и библиотек легко узнать этот слой работы.
Главный практический вывод: ни одна строка интерфейса не должна быть зашита в разметку или логику напрямую. Текст берётся по ключу из словаря, а язык подставляет библиотека.
Тексты выносятся в словари и подключаются по ключу
Словарь локализации это обычная структура с парами ключ и значение. Ключ остаётся одинаковым для всех языков, значение переводится. Так выглядит русский файл:
{
"cart": {
"title": "Корзина",
"items": "Товаров в корзине: {{count}}",
"total": "Итого {{sum}} рублей"
}
}
Английский вариант повторяет ту же структуру с другими значениями:
{
"cart": {
"title": "Cart",
"items": "Items in cart: {{count}}",
"total": "Total {{sum}}"
}
}
Ключи строят иерархически, по смыслу экрана или блока: вложенность помогает находить строку и замечать пропуски. Плоский список из тысяч ключей превращается в свалку уже на второй неделе.
Инициализация библиотеки занимает несколько строк:
import i18next from "i18next";
import ru from "./locales/ru.json";
import en from "./locales/en.json";
await i18next.init({
lng: "ru",
fallbackLng: "en",
resources: {
ru: { translation: ru },
en: { translation: en },
},
});
console.log(i18next.t("cart.title"));
Параметр запасного языка обязателен в любом реальном проекте. Он спасает от пустых мест, когда перевод ещё не готов, а новый ключ уже используется в коде. Без него пользователь увидит технический идентификатор вместо фразы. Запасное значение можно задать и точечно, параметром defaultValue в вызове, тогда не найденный ключ не останется голым.
Формы множественного числа различаются между языками и это главная ловушка
Теперь про ту самую жалобу из начала. В русском языке числовая форма зависит не от простого деления на один и много, а от последних цифр числа.
При существительном после числительного возможны три варианта: один товар, два товара, пять товаров. Правило опирается на остаток от деления на сто и на последнюю цифру. Числа, оканчивающиеся на одиннадцать, всегда требуют формы множественного числа, хотя последняя цифра равна единице.
К этим формам добавляется четвёртая, используемая при дробных значениях: полтора товара. Получается четыре формы вместо двух привычных английских.
Данные о числовых правилах языков собраны в отдельном проекте консорциума по стандартизации юникода. Работа над ним началась в конце 2003 года, а сегодня база покрывает более четырёхсот языков и содержит правила форматирования чисел, дат, валют и названий. Библиотеки локализации опираются на эти правила, а не изобретают свои.
Переводчик не пишет формы по памяти: он указывает ключи для каждой формы, а библиотека сама выбирает нужную по числу и языку:
{
"cart": {
"items_one": "В корзине {{count}} товар",
"items_few": "В корзине {{count}} товара",
"items_many": "В корзине {{count}} товаров",
"items_other": "В корзине {{count}} товара"
}
}
Суффиксы форм задаются прямо в именах ключей, а категорию для конкретного числа библиотека определяет через встроенный в язык механизм Intl.PluralRules, поэтому правила русского не нужно описывать вручную. Вызов выполняется с передачей числа, а не готовой строки:
function formatItems(count) {
return i18next.t("cart.items", { count });
}
console.log(formatItems(1));
console.log(formatItems(3));
console.log(formatItems(21));
Здесь же кроется вторая ошибка: разработчик передаёт уже готовое число, склеенное со словом, и библиотека теряет возможность выбрать форму. Число передаётся отдельным параметром, тогда и форма, и разделитель разрядов подставятся правильно.
Третья тонкость это выбор категории: формы one, few, many и other распределяются по правилам, а не по интуиции. Поставленные наугад формы дадут ошибку, которая проявится только на конкретных числах.
Подстановка значений требует экранирования и осторожности с разметкой
Второй механизм, без которого локализация не работает, это подстановка переменных в шаблон. Место подстановки обозначается двойными фигурными скобками.
i18next.t("greeting", { name: "Анна" });
Словарь содержит строку с плейсхолдером, а не готовую фразу, поэтому переводчик может менять порядок слов, что критично для языков с другой структурой предложения. В немецком обстоятельство уходит в начало, в японском порядок иной, и склейка готовых кусков здесь не помогает.
Экранирование подставляемых значений включено по умолчанию, и это правильно. Если отключить его бездумно, пользовательские данные попадут в разметку как есть, и появится уязвимость. Для ссылок и выделения в переводе применяется отдельный приём с компонентами или заранее размеченными фрагментами.
Форматирование чисел и дат тоже требует внимания: разделитель тысяч, десятичный знак и порядок частей даты различаются между странами. Готовая строка с уже вставленным числом лишает библиотеку возможности применить местные правила, поэтому число вставляют через форматирующий вызов.
const formatted = new Intl.NumberFormat(i18next.language, {
style: "currency",
currency: i18next.t("common.currency"),
}).format(12500.5);
console.log(formatted);
Валюту тоже нельзя записывать в шаблон жёстко. Для разных рынков она разная, и подстановка символа в строку без учёта правил даёт странный результат вроде знака перед числом там, где по местным правилам он ставится после.
Определение языка и переключение на лету определяют удобство
Первый вопрос при запуске это какой язык показать. Простейший ответ подставить один по умолчанию, но он почти всегда неверен.
Правильный порядок такой: сначала приложение смотрит, не сохранил ли пользователь выбор в прошлый раз, затем проверяет язык в адресе страницы, потом обращается к предпочтениям браузера или системы, и только если ничего не найдено, берёт запасной вариант.
async function detectLanguage() {
const saved = localStorage.getItem("language");
if (saved) return saved;
const fromUrl = new URLSearchParams(location.search).get("lang");
if (fromUrl) return fromUrl;
return navigator.language.split("-")[0];
}
await i18next.changeLanguage(await detectLanguage());
Момент переключения важен для интерфейса. Смена языка без перезагрузки выглядит лучше, но требует, чтобы все компоненты умели обновляться при изменении состояния. Если часть подписей читается один раз при инициализации, после переключения они останутся прежними, и пользователь увидит смесь двух языков.
Решение в подписке на событие смены языка: компоненты, хранящие текст в локальном состоянии, перечитывают его при срабатывании события. Для популярных фреймворков есть готовые обёртки, которые делают это сами.
Отдельная забота это состояние форм: перезагрузка при смене языка теряет введённое, и пользователь справедливо раздражается. Смена на лету решает проблему, но требует аккуратности с уже отрисованными датами и числами.
Загрузка словарей по требованию экономит вес сборки
Пока языков два, размер словарей не имеет значения. Когда их двадцать, а проект крупный, ситуация меняется радикально.
Первое решение это разделение словарей по пространствам имён. Экран оформления заказа подтягивает свой набор строк, личный кабинет свой. При переходе на экран нужный файл загружается отдельно.
await i18next.loadNamespaces(["checkout", "payment"]);
Второе решение это отложенная загрузка языка целиком: при старте подключается только выбранный, остальные подгружаются при переключении. Трафик экономится у большинства, ведь мало кто меняет язык вручную.
Третья практика это кэширование: словари сохраняются на устройстве, чтобы не тянуть их при каждом запуске, а при обновлении переводов старый кэш отбрасывается.
Четвёртая практика связана со сборкой: строки для серверного рендеринга, почтовых шаблонов и уведомлений стоит держать в разных словарях, иначе общий файл разрастается.
Проверка локализации требует отдельного набора тестов
Обычное тестирование интерфейса не ловит проблемы перевода. Заметить, что форма слова выбрана неверно, можно только на конкретном числе, а таких чисел в ручном сценарии проверки несколько.
Первая практика это тесты на числовые формы. Для каждого языка задаётся набор чисел: ноль, один, два, пять, одиннадцать, двадцать один, сто один, дробное значение. Проверка сверяет получившиеся строки с ожидаемыми.
Вторая практика это проверка полноты словарей. Сценарий сравнивает набор ключей основного языка с остальными и сообщает о пропущенных. Такая проверка встраивается в сборку и ловит забытые строки до выпуска.
function findMissingKeys(base, target) {
const missing = [];
const walk = (source, path, other) => {
for (const [key, value] of Object.entries(source)) {
const current = path ? path + "." + key : key;
if (typeof value === "object") {
walk(value, current, other?.[key] ?? {});
} else if (other?.[key] === undefined) {
missing.push(current);
}
}
};
walk(base, "", target);
return missing;
}
Третья практика это проверка длины строк. Немецкие слова длиннее английских, а кнопки имеют фиксированную ширину, и верстка, рассчитанная на короткую надпись, ломается от длинного перевода. Полезно заранее задавать ограничение и проверять, что перевод в него укладывается.
Четвёртая практика это визуальный контроль. Псевдолокализация заменяет строки удлинёнными символами и позволяет увидеть, где интерфейс не выдержит реального текста. Приём дешёвый и выявляет проблемы до появления первого перевода.
Организационная часть часто сложнее технической
Код подключения библиотеки пишется за день. Дальше начинается работа, которая не заканчивается никогда.
Первая проблема это синхронизация. Разработчик добавляет новый ключ, переводчик узнаёт об этом через неделю, и до тех пор в интерфейсе висит запасной вариант. Решение состоит в том, чтобы ключи попадали в систему управления переводами автоматически при слиянии изменений.
Вторая проблема это контекст. Слово "заказ" означает и действие, и документ, а английский перевод в этих случаях разный. Переводчик, который видит только слово без пояснения, угадывает, и не всегда верно, поэтому к ключам стоит прикладывать комментарий или скриншот экрана.
Третья проблема это устаревшие ключи: неиспользуемые никто не удаляет, словари пухнут, а периодическая чистка экономит время переводчиков и уменьшает вес сборки.
Четвёртая проблема это роли. Если любой разработчик может править перевод напрямую, качество падает. Правки текстов стоит пропускать через ответственного за язык, а не через ревью кода.
Локализация получилась задачей, в которой техническая часть составляет меньшую половину. Библиотека честно подставит нужное слово, если ей дать правильную форму и число отдельным параметром. Всё остальное зависит от людей: есть ли у переводчика контекст, следят ли за актуальностью словарей, проверяют ли результат на числах, которые ломают наивные шаблоны. Именно на этих числах и видно, была работа настоящей или только выглядела такой.