Разработчик добавил в проект словари ещё двух языков, прогнал сборку, увидел знакомые строки на экране и посчитал задачу закрытой. А через неделю пришла жалоба: на арабской версии счётчик товаров показывает бессмысленный набор слов, а в русской фраза про пять яблок выглядит так, будто её писал иностранец. Перевод был готов, а интерфейс всё равно звучал неправильно. Причина кроется в устройстве числовых форм, и именно об неё спотыкаются почти все, кто берётся за локализацию.

Локализация начинается с разделения подготовки и перевода

В разработке различают два разных процесса, которые часто смешивают в разговоре. Интернационализация это подготовка приложения к работе с разными языками и регионами без переписывания кода. Локализация это уже адаптация под конкретную страну или язык: перевод строк и добавление местных особенностей.

Разница принципиальна. Интернационализация делается один раз и на раннем этапе, локализация повторяется столько раз, сколько языков нужно поддержать. Если код заранее не подготовлен, каждый новый язык превращается в отдельный проект с правками в логике.

Аббревиатуры тоже пришли из практики. Слово с длинным названием сократили, посчитав число букв между первой и последней: получилось i18n для интернационализации и l10n для локализации. Такие сокращения встречаются в названиях файлов, папок и библиотек, и по ним легко узнать, что речь идёт именно об этом слое работы.

Главный практический вывод из разделения звучит так. Ни одна строка интерфейса не должна быть зашита в разметку или в логику напрямую. Текст берётся по ключу из словаря, а какой язык подставить, решает библиотека.

Тексты выносятся в словари и подключаются по ключу

Словарь локализации это обычная структура с парами ключ и значение. Ключ остаётся одинаковым для всех языков, значение переводится. Так выглядит русский файл:

{
  "cart": {
    "title": "Корзина",
    "items": "Товаров в корзине: {{count}}",
    "empty": "Корзина пуста",
    "total": "Итого {{sum}} рублей"
  }
}

Английский вариант повторяет ту же структуру с другими значениями:

{
  "cart": {
    "title": "Cart",
    "items": "Items in cart: {{count}}",
    "empty": "Your cart is empty",
    "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 },
  },
  interpolation: {
    escapeValue: false,
  },
});

console.log(i18next.t("cart.title"));

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

Отдельно стоит решить, когда подключать словари. Загрузка всех языков сразу раздувает сборку, поэтому крупные проекты разбивают словари по разделам и подгружают их по мере перехода на соответствующий экран.

Формы множественного числа различаются между языками и это главная ловушка

Теперь про ту самую жалобу из начала. В русском языке числовая форма зависит не от простого деления на один и много, а от последних цифр числа.

При существительном после числительного возможны три варианта: один товар, два товара, пять товаров. Правило опирается на остаток от деления на сто и на последнюю цифру. Числа, оканчивающиеся на одиннадцать, всегда требуют формы множественного числа, хотя последняя цифра равна единице. Именно поэтому фраза про одиннадцать товаров звучит не так, как про один.

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

Данные о числовых правилах языков собраны в отдельном проекте консорциума по стандартизации юникода. Работа над ним началась в конце 2003 года, а сегодня база покрывает более четырёхсот языков и содержит правила форматирования чисел, дат, валют и названий. Библиотеки локализации опираются на эти правила, а не изобретают свои.

Именно поэтому переводчик не пишет формы вручную по памяти. Он указывает ключи для каждой формы, а библиотека сама выбирает нужную, опираясь на число и язык:

{
  "cart": {
    "items_one": "В корзине {{count}} товар",
    "items_few": "В корзине {{count}} товара",
    "items_many": "В корзине {{count}} товаров",
    "items_other": "В корзине {{count}} товара"
  }
}

Вызов выполняется с передачей числа, а не готовой строки:

function formatItems(count) {
  return i18next.t("cart.items", { count });
}

console.log(formatItems(1));
console.log(formatItems(3));
console.log(formatItems(21));
console.log(formatItems(25));

Здесь кроется вторая ошибка, которая встречается не реже первой. Разработчик передаёт в словарь уже готовое число, склеенное со словом, и библиотека теряет возможность выбрать форму. Число должно передаваться отдельным параметром в вызове, тогда и форма, и разделитель разрядов подставятся правильно.

Третья тонкость связана с выбором категории. Для русского языка формы с индексами one, few, many и other распределяются по правилам, а не по интуиции. Если переводчик поставит формы наугад, библиотека выберет неверную, и ошибка проявится только на конкретных числах.

Подстановка значений требует экранирования и осторожности с разметкой

Второй механизм, без которого локализация не работает, это подстановка переменных в шаблон. Место подстановки обозначается двойными фигурными скобками.

i18next.t("greeting", { name: "Анна" });

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

Значения по умолчанию задаются прямо в шаблоне:

{
  "greeting": "Привет, {{name}}",
  "orders": "У вас {{count}} заказ",
  "fallback": "Привет, {{name, default: 'гость'}}"
}

Экранирование значений по умолчанию включено, и это правильно. Если отключить его бездумно, пользовательские данные попадут в разметку как есть, и появится уязвимость. Для случаев, когда в переводе нужны ссылки или выделение, применяется отдельный приём с компонентами или заранее размеченными фрагментами.

Форматирование чисел и дат тоже требует внимания. Разделитель тысяч, десятичный знак, порядок частей даты и название месяца различаются между странами. Готовая строка с уже вставленным числом лишает библиотеку возможности применить местные правила, поэтому число вставляют через форматирующий вызов.

const formatted = new Intl.NumberFormat(i18next.language, {
  style: "currency",
  currency: i18next.t("common.currency"),
}).format(12500.5);

console.log(formatted);

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

Определение языка и переключение на лету определяют удобство

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

Правильный порядок выглядит так. Сначала приложение смотрит, не сохранил ли пользователь выбор в прошлый раз. Затем проверяет, не указан ли язык в адресе страницы. После этого обращается к предпочтениям браузера или системы. И только если ничего не найдено, берёт запасной вариант.

async function detectLanguage(i18next) {
  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];
}

const detected = await detectLanguage(i18next);
await i18next.changeLanguage(detected);

Момент переключения важен для интерфейса. Смена языка без перезагрузки выглядит лучше, но требует, чтобы все компоненты умели обновляться при изменении состояния. Если часть подписей читается один раз при инициализации, после переключения они останутся прежними, и пользователь увидит смесь двух языков.

Решение состоит в подписке на событие смены языка. Компоненты, которые хранят текст в локальном состоянии, должны перечитать его при срабатывании события. Для популярных фреймворков существуют готовые обёртки, которые делают это сами.

Отдельная забота это сохранение позиции на странице и состояния форм. Перезагрузка при смене языка теряет введённые данные, и пользователь справедливо раздражается. Смена на лету без перезагрузки решает проблему, но требует аккуратной работы с датами и числами, которые уже отрисованы.

Загрузка словарей по требованию экономит вес сборки

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

Первое решение это разделение словарей по пространствам имён. Экран оформления заказа подтягивает свой набор строк, личный кабинет свой. При переходе на экран нужный файл загружается отдельно.

await i18next.loadNamespaces(["checkout", "payment"]);

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

Третья практика касается кэширования. Загруженные словари стоит сохранять на устройстве, чтобы при следующем запуске не тянуть их снова. При этом нужен механизм инвалидации: если переводы обновились, старый кэш должен быть отброшен.

Четвёртая практика связана со сборкой. Некоторые строки нужны серверу для рендеринга на стороне сервера, некоторые в почтовых шаблонах, некоторые в уведомлениях. Попытка решить всё одним словарём приводит к лишнему весу, поэтому стоит заранее разделить контексты использования.

Проверка локализации требует отдельного набора тестов

Обычное тестирование интерфейса не ловит проблемы перевода. Заметить, что форма слова выбрана неверно, можно только на конкретном числе, а таких чисел в ручном сценарии проверки несколько.

Первая практика это тесты на числовые формы. Для каждого языка задаётся набор чисел: ноль, один, два, пять, одиннадцать, двадцать один, сто один, дробное значение. Проверка сверяет получившиеся строки с ожидаемыми.

Вторая практика это проверка полноты словарей. Сценарий сравнивает набор ключей основного языка с остальными и сообщает о пропущенных. Такая проверка встраивается в сборку и ловит забытые строки до выпуска.

function findMissingKeys(base, target) {
  const missing = [];
  const walk = (source, path) => {
    for (const [key, value] of Object.entries(source)) {
      const current = path ? path + "." + key : key;
      if (typeof value === "object") {
        walk(value, current);
      } else if (!(key in (target ?? {})) && target?.[key] === undefined) {
        if (!hasPath(target, current)) {
          missing.push(current);
        }
      }
    }
  };
  walk(base, "");
  return missing;
}

Третья практика это проверка длины строк. Немецкие слова длиннее английских, а кнопки имеют фиксированную ширину. Верстка, рассчитанная на короткую надпись, ломается от длинного перевода. Полезно заранее задавать ограничение и проверять, что перевод в него укладывается, а если нет, согласовывать сокращение.

Четвёртая практика это визуальный контроль. Псевдолокализация заменяет строки удлинёнными символами и позволяет увидеть, где интерфейс не выдержит реального текста. Приём дешёвый и выявляет проблемы до появления первого перевода.

Организационная часть часто сложнее технической

Код подключения библиотеки пишется за день. Дальше начинается работа, которая не заканчивается никогда.

Первая проблема это синхронизация. Разработчик добавляет новый ключ, переводчик узнаёт об этом через неделю, и до тех пор в интерфейсе висит запасной вариант. Решение состоит в том, чтобы ключи попадали в систему управления переводами автоматически при слиянии изменений.

Вторая проблема это контекст. Слово "заказ" в русском языке означает и действие, и документ. Английский перевод в этих случаях разный. Если переводчик видит только слово без пояснения, он угадывает, и угадывает не всегда. Поэтому к ключам стоит прикладывать комментарий или скриншот экрана.

Третья проблема это длина жизненного цикла. Ключи, которые больше не используются, никто не удаляет, и словари пухнут. Периодическая уборка неиспользуемых ключей экономит время переводчиков и уменьшает вес сборки.

Четвёртая проблема это роли. Если любой разработчик может править перевод напрямую, качество падает. Правки текстов стоит пропускать через ответственного за язык, а не через ревью кода.

Локализация получилась задачей, в которой техническая часть составляет меньшую половину. Библиотека честно подставит нужное слово, если ей дать правильную форму и число отдельным параметром. Всё остальное зависит от людей: от того, есть ли у переводчика контекст, следят ли за актуальностью словарей и проверяет ли кто-то результат на числах, которые ломают наивные шаблоны. Именно на этих числах и становится видно, была работа настоящей или только выглядела такой.