javascript

Локализуем React (NextJS, TypeScript) сайт на несколько языков с помощью i18next

  • пятница, 17 января 2025 г. в 00:00:10
https://habr.com/ru/articles/873984/

У меня появилась задача в проекте:

  • Перевести личный кабинет пользователя на русский и английский (в перспективе и на другие языки).

  • При этом, определять язык пользователя при первом заходе в ЛК и давать его изменить.

  • Запоминать выбранный язык при перезагрузке страницы.

  • Сделать так, чтобы в проектах была типизация файлов с переводами (чтобы нельзя было забыть добавить один из языков).

Как я это делал — расскажу в статье.

Содержание

Первоначальное решение

Сначала я решил задачу, просто закинув все переводы в несколько .ts-файлов с общим интерфейсом и выбирая язык через Redux. Всё работало, но было ощущение, что это я переизобрел велосипед.

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

Выбор библиотеки переводов

Для решения задачи я выбрал i18next.

Почему именно i18next?

  • Имеет поддержку типизации "из коробки" (дружит TS типы и даже кое-как с автокомплишном).

  • "Дружит" с React Server Component в Next.js (для Next.js 13+).

  • Поддерживает lazy loading (разделение переводов по чанкам/файлам) для ускорения страниц.

  • Всё выше делается просто относительной других популярных библиотек.

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

Для наглядности сделал таблицу, где сравнил три самые популярные библиотеки:

i18next

react-intl

@lingui/react

Популярность
(звёзд на GitHub)

🟠 7.9k

🟢14.4k

🟠4.8k

Типизация

🟢

🟠(нужно повозиться)

🔴

Server Side Components

🟢

🟠(нужно повозиться)

🟢

Lazy loading

🟢 (через namespaces)

🟢(нужно повозиться с динамическим импортом)

🟠(нужно повозиться)

Субъективное удобство

🟢 (вызов всего через t('...'))

🔴(все переводы нужно оборачивать в компонент)

🔴(все переводы нужно оборачивать в компонент и нет нормальной типизации)

*оценка может быть субъективной из расчёта на конкретный проект, на объективность не претендую.

*i18n - расшифровывается как "internationalization".

Минутка самопиара

У меня есть Telegram-канал, где я собираю ссылки на свои статьи про Full-Stack разработку, развитие SaaS-продуктов и управление IT-проектами.

Создаём шаблон проекта

Для примера будем делать одну страницу с переключателем языка и несколькими текстовыми полями:

Страница для примера
Страница для примера

Для начала создадим новый проект на Next.js с TypeScript шаблоном.

Выполняем команды:

npx create-next-app@latest my-multilang-app --typescript

Я сразу добавил ESLint, TailwindCSS и Turbopack:

Появляется структура:

my-multilang-app/
├─ app/
│   ├─ page.tsx 
│   └─ ...
├─ public/
├─ ...
└─ package.json

И сразу добавляем библиотеки i18next:

npm install i18next react-i18next i18next-browser-languagedetector

react-i18next — адаптер для React.
i18next-browser-languagedetector — плагин для определения языка в браузере.

Добавляем локализацию

Создаём переводы

Создаём тип переводов и сами переводы в папке i18n:

i18n/
├─ translations/en_translation.json
├─ translations/ru_translation.json
├─ translations/TranslationTypes.ts
└─ i18n.ts

Разумеется, можно назвать файлы и папки по-другому, главное, чтобы была понятная структура. Я выбрал нейминг, стандартный для Feature Sliced Design (но FSD мы здесь не используем).

Далее сами файлы:

i18n/translations/TranslationTypes.ts

export interface TranslationTypes {
  // Используем схему componentName.field
  page: {
    hello: string;
    changeLanguage: string;
    dashboardTitle: string;
    profile: string;
  };
}

i18n/translations/en_translation.json

{
  "page": {
    "hello": "Hello, {{name}}!",
    "changeLanguage": "Change language to Russian",
    "dashboardTitle": "User Dashboard",
    "profile": "My profile"
  }
}

i18n/translations/ru_translation.json

{
  "page": {
    "hello": "Привет, {{name}}!",
    "changeLanguage": "Переключить язык на английский",
    "dashboardTitle": "Личный кабинет",
    "profile": "Мой профиль"
  }
}

Добавляем типы переводов в проект

Чтобы включить типизацию, нужно воспользоваться встроенным механизмом декларации типов i18next. Создадим файл resources.d.ts (или i18n.d.ts) в корне проекта или в папке types, где пропишем:

import "i18next";
import { TranslationTypes } from "@/i18n/translations/TranslationTypes";

declare module "i18next" {
  interface CustomTypeOptions {
    resources: TranslationTypes;
  }
}

Теперь при использовании useTranslation и t в нашем коде TypeScript будет подсказывать, какие ключи перевода у нас существуют.

Инициализируем i18next

Добавим файл i18n.ts в папку /i18n:

import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";

import { TranslationTypes } from "./translations/TranslationTypes";
import en from "./translations/en_translation.json";
import ru from "./translations/ru_translation.json";

// Если забудем добавить поле в один из языков,
// здесь появится TypeScript ошибка
const resources: Record<string, { translation: TranslationTypes }> = {
  en: { translation: en },
  ru: { translation: ru },
};

i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources,
    detection: {
      order: ["localStorage", "navigator"],
      caches: ["localStorage"],
      lookupLocalStorage: "i18nextLng",
    },
    fallbackLng: "en",
    interpolation: {
      escapeValue: false,
    },
  });

export default i18n;

Обратите внимание, что i18next-browser-languagedetector смотрит, какой язык установлен в браузере, а также может работать с cookie/localStorage. Это решает задачу "запоминать язык при перезагрузке страницы".

Здесь мы указываем логику, в которой сначала пытаемся брать язык из localStorage, а затем из браузера:

order: ["localStorage", "navigator"],

i18next умеет сам выбирать нужный язык в зависимости от настройки браузера (ru, en, sp и другие). Нам нужно только указать нужный файл для языка:

const resources: Record<string, { translation: TranslationTypes }> = {
  en: { translation: en },
  ru: { translation: ru },
};

...
...
    // Если нужного языка нет, берём английский
    fallbackLng: "en",
    ...
...

Добавляем выбор языка

Чтобы пользователь мог переключать язык, создадим компонент выбора языка:

LanguageSwitcher.tsx:

"use client";

import { useTranslation } from "react-i18next";

export default function LanguageSwitcher() {
  const { i18n } = useTranslation();

  const changeLanguage = async (lang: "en" | "ru") => {
    await i18n.changeLanguage(lang);
  };

  return (
    <div className="mt-4 space-x-2">
      <button
        onClick={() => changeLanguage("en")}
        className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
      >
        EN
      </button>

      <button
        onClick={() => changeLanguage("ru")}
        className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
      >
        RU
      </button>
    </div>
  );
}

Теперь мы можем переиспользовать этот компонент на любой странице или в header'e.

Проброс языка в API

Если у вас локализация распространяется и на API, вам нужно прокидывать язык в запросы. Его можно брать из нашего файла i18n и добавлять в заголовок. Например, в fetch-запросе:

await fetch('/api/user', {
  headers: {
    'Authorization': getAccessToken(),
    'Accept-Language': i18n.language, // берем текущий язык
  }
});

Итоговая страница

Полный пример страницы с переводами выглядит вот так:

app/page.tsx

"use client";

import LanguageSwitcher from "@/components/LanguageSwitcher";
import { useTranslation } from "react-i18next";
import "../i18n/i18n";

export default function HomePage() {
  const { t } = useTranslation();

  return (
    <main className="p-8 max-w-4xl mx-auto">
      <h1 className="text-3xl font-bold text-gray-900 mb-6">
        {t("page.dashboardTitle")}
      </h1>

      <div className="space-y-4">
        <p className="text-lg text-gray-700">
          {t("page.hello", { name: "John" })}
        </p>
        <p className="text-lg text-gray-700">{t("page.profile")}</p>
      </div>

      {/* Кнопка для переключения языка */}
      <div className="mt-8">
        <LanguageSwitcher />
      </div>
    </main>
  );
}

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

Итоговая страница
Итоговая страница

Конкретно в этом примере мы используем "use client" для упрощения. В следующей статье я покажу, как использовать i18next с SSR'ом.

Заключение

Итого, при смене языка у нас меняются все тексты на странице:

  • Заголовок «Личный кабинет» <> «User Dashboard»

  • Приветствие «Привет, John!» <> «Hello, John!»

  • Кнопка для профиля «Мой профиль» <> «My profile»

А при перезагрузке приложения язык остаётся выбранным, так как i18next-browser-languagedetector сохраняет язык в localStorage'e.

Чтобы добавить новые языки (испанский, китайский и т.д.) нужно расширить ресурс в i18n.ts и добавить новые файлы с переводами (например, es_translation.json, zh_translation.json). Типизация подскажет, не забыли ли мы какие-то поля.

P.S. Напомню, что у меня есть Telegram-канал, где я собираю ссылки на свои статьи про Full-Stack разработку, развитие SaaS-продуктов и управление IT-проектами.

Если остались вопросы, пишите в комментариях!

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Делали ли вы локализацию в проекте?
100% Да6
0% Нет0
0% Не моя зона ответственности0
Проголосовали 6 пользователей. Воздержавшихся нет.