Назад

i18n без файлов перевода

Jackie Chen avatarJackie Chen
guideinternationalizationnextjsi18ngt-nexttranslation-filesdeveloper-experience

Каждый, кто занимался интернационализацией JavaScript-приложения, знает эту схему. Вы устанавливаете библиотеку i18n, создаёте файл en.json, выносите из компонентов все пользовательские строки, присваиваете каждой ключ и подставляете этот ключ туда, где раньше был текст. Затем дублируете этот JSON-файл для каждого поддерживаемого языка. es.json, fr.json, ja.json.

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

Но потом приложение растёт. Через полгода у вас уже 400 строк и 12 языков. 4 800 записей в дюжине файлов. Разработчик добавляет новую функцию, пишет пять новых строк и забывает обновить три файла перевода. Никто этого не замечает, пока пользователь в Токио не увидит английский резервный текст в японском интерфейсе. Кто-то предлагает купить систему управления переводами.

Цена управления файлами

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

О многом говорит уже сама экосистема обходных решений: расширения для VS Code, которые автоматически генерируют ключи переводов, генераторы типов, проверяющие ваш JSON на соответствие интерфейсам TypeScript, линтеры для неиспользуемых ключей, проверки в CI, которые следят за тем, чтобы все локали оставались синхронизированными. Такой набор инструментов не создают для процесса, который и без того работает хорошо.

Ваш UI говорит одно, файлы перевода — другое, а сопоставление по ключам удерживает их вместе:

function CheckoutSummary({ itemCount, discount }) {
  const { t } = useTranslation('checkout');

  return (
    <div>
      <h2>{t('summary.title')}</h2>
      <p>{t('summary.item_count', { count: itemCount })}</p>
      {discount && <p>{t('summary.discount_applied', { percent: discount })}</p>}
      <p>{t('summary.total_label')}</p>
      <small>{t('summary.tax_notice')}</small>
    </div>
  );
}

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

Синтаксис сообщений ICU только усугубляет ситуацию. Формат, который библиотеки вроде i18next, react-intl и next-intl используют для множественного числа, рода и интерполяции, — это отдельный мини-язык, встроенный в JSON-строки. Из-за неверно поставленной фигурной скобки в {count, plural, one {# item} other {# items}} ошибка проявится только во время выполнения. Линтеры для этого есть, но большинство команд ими не пользуется. Inline-код, напротив, проверяется вашим компилятором TypeScript и IDE сразу, как только вы его вводите.

Прямой перевод кода

Что, если вообще не извлекать строки?

import { T } from 'gt-react';

function CheckoutSummary({ itemCount, discount }) {
  return (
    <T>
      <div>
        <h2>Order Summary</h2>
        <p>You have {itemCount} items in your cart.</p>
        {discount && <p>{discount}% discount applied!</p>}
        <p>Total (before tax):</p>
        <small>Tax calculated at checkout.</small>
      </div>
    </T>
  );
}

Компонент <T> в gt-react помечает блок JSX для перевода. Английский текст остаётся в вашем компоненте. Когда пользователь заходит на сайт на испанском, содержимое внутри <T> заменяется соответствующим текстом на испанском. Структура и форматирование сохраняются.

Никаких t('checkout.summary.title'), никаких en.json, никаких отдельных JSON-файлов для каждой локали, которые нужно поддерживать в sync.

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

Они генерируются во время деплоя. GT CLI сканирует вашу кодовую базу и находит всё, что находится внутри компонентов <T>, а затем создаёт переводы для каждого целевого языка. Результат сохраняется в директорию, исключённую из Git, — как и скомпилированный CSS или собранный JavaScript.

{
  "defaultLocale": "en",
  "locales": ["es", "fr", "ja", "de", "ko", "zh"],
  "files": {
    "gt": {
      "output": "public/_gt/[locale].json"
    }
  }
}
public/_gt/

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

Что меняется на практике

Разница заметнее в повседневной работе, чем на архитектурных диаграммах.

PR, который добавляет на страницу новый раздел, читается именно как PR, который добавляет на страницу новый раздел. Ревьюер видит реальные слова, а не checkout.summary.discount_applied_notice. Для ревью кода больше не нужно держать рядом JSON-файл для сверки.

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

К чему движутся библиотеки i18n

Во всей экосистеме прослеживается тренд на inline-строки. Ранние библиотеки i18n, такие как i18next и react-intl, создавались в эпоху, когда машинный перевод был нежизнеспособен и каждую строку приходилось передавать переводчику. Словари были удобным форматом обмена. Теперь этого ограничения нет, и затраты на поддержку параллельных файлов со строками разработчикам все сложнее оправдывать.

next-intl добавил вызовы t() без словаря наряду со словарным режимом. Компилятор Lingui извлекает сообщения на этапе сборки из встроенных тегированных шаблонных литералов. Paraglide идет другим путем, компилируя файлы сообщений в tree-shakeable-функции для каждой локали. Подходы различаются, но во всех случаях контент перемещается ближе к компоненту. GT доводит эту идею до конца: ваш JSX — источник истины, а перевод — этап компиляции.

Компромисс

Когда вы пишете контент inline, вы пишете на своём родном языке. Структура компонентов, построение фраз, логика UI — всё это отражает то, как вы мыслите на английском (или на любом другом исходном языке). Подход на основе словаря, такой как next-intl, по своей природе более независим от языка, потому что компонент вообще не содержит полноценного предложения ни на одном языке — только ключ, который отсылает к содержимому в другом месте.

Но большинство разработчиков и так мыслят на одном языке, когда создают UI. Макет, тексты, подписи кнопок — всё это изначально продумывается на английском. Эта предвзятость заложена в самом дизайне, независимо от того, находятся ли строки inline или в JSON-файле. Мы считаем, что i18n-фреймворк должен подстраиваться под то, как вы реально работаете. Создавайте приложение естественным образом, а переводом пусть занимается фреймворк, вместо того чтобы выносить контент в ключи ради языковой нейтральности.

Начало работы

npx gt@latest init

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

gt-react — проект с открытым исходным кодом. Для Next.js App Router есть gt-next. Для React Native есть gt-react-native.