Для интернационализации сделаны десятки по-своему потрясающих библиотек, такие как i18n, react-intl, next-intl. Все они отлично справляются со своей задачей - добавляют переводы в приложение или на сайт. Большинство из них проверены, отлажены и стабильно поддерживаются.

Но все они устарели.

Ведь всё это время развивалось и экосистема реакта. Так, последняя версия next.js включила крупные обновления из react.js - cache, taint, новые хуки и, конечно же, серверные компоненты. Команда самого React.js, вероятно, представит эти изменения уже в мае.

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

Изменения

Первое, с чего стоит начать - как так вышло, что изменения React.js сделали библиотеки переводов устаревшими.

Несмотря на то, что последняя стабильная версия React.js вышла почти два года назад, он имеет и 2 других канала - canary и experimental, где canary также считается стабильным каналом и рекомендуется для использования библиотеками.

Именно этим каналом и пользуется Next.js. Next.js запустил серверные компоненты без дополнительных флагов внутри так называемого App Router-а - это новая директория в альтернативу pages, которая использует свои конвенции и различный сахар (об изменениях и проблемах которого я писал в недавней статье).

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

Next.js уделил этой возможности особое внима��ие.

Личный опыт

Добавить переводы (по документации Next.js) можно следующим образом:

// app/[lang]/dictionaries.js
import 'server-only'

const dictionaries = {
  en: () => import('./dictionaries/en.json').then((module) => module.default),
  nl: () => import('./dictionaries/nl.json').then((module) => module.default),
}

export const getDictionary = async (locale) => dictionarieslocale
// app/[lang]/page.js
import { getDictionary } from './dictionaries'
 
export default async function Page({ params: { lang } }) {
  const dict = await getDictionary(lang) // en
  return <button>{dict.products.cart}</button> // Add to Cart
}

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

Большая проблема серверных компонент — в них не доступны контексты. Команда Next.js объясняет отсутствие этих функций тем, что Layout не ререндерится, а всё что зависит от пропсов должно быть клиентским.

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

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

Ещё одной неприятностью стало кеширование в Next.js. А именно — оно полноценно работает только для GET запросов, а также если вес переводов больше лимита в 2МБ - они не будут закешированы.

Реализация

Цели и задачи:

  • Библиотека должна иметь полный функционал как в клиентских комопонентах, так и в серверных;

  • Использование должно быть простым (без лишних пропсов);

  • Вся сложная логика должна быть перенесена на сервер;

  • Переводы должны загружаться только по необходимости и без лишних запросов;

  • Поддержка обновления переводов без пересборки (ISR/SSR);

  • Всё должно работать в статическом сайте (а не с переключением в SSR);

  • Должны поддерживаться html entities.

К моему удивлению нет ни одной библиотеки, которая удовлетворила бы все эти запросы.

Первое, что нужно - функционал. В стандартном варианте это хук, возвращающий функцию t и компонент Trans для более сложных переводов. Однако такой функционал нужен и в серверных компонентах, а они имеют множество своих особенностей.

Функционал

Основной функционал делится на две версии - для клиентских компонент и для серверных и включает в себя:

useTranslation, getTranslation - которые возвращают функцию t внутри ДОМ-а и язык;

import getTranslation from 'next-translation/getTranslation'

export default function ServerComponent() {
  const { t } = getTranslation()

  return (
    <p>{t('intro.title')}</p>
  )
}
'use client';

import useTranslation from 'next-translation/useTranslation'

export default function ClientComponent() {
  const { t } = useTranslation()

  return (
    <p>{t('intro.title')}</p>
  )
}

Получился достаточно привычным интерфейс, функции поддерживают namespace и query. Его рекомендуется использовать по умолчанию, так как он прост в использовании и в логике. Возвращает готовую строку.

Для более сложных же переводов нужно использовать компоненты ClientTranslation и ServerTranslation. Они умеют заменять псевдо-компоненты реальными.

import ServerTranslation from 'next-tranlation/ServerTranslation';

export default function ServerComponent() {
  return(
    <ServerTranslation
      term='intro.description'
      components={{
        link: <a href='#' />
      }}
    />
  )
}
"use client";

import ClientTranslation from 'next-tranlation/ClientTranslation';

export default function ClientTranslation() {
  return(
    <ClientTranslation
      term='intro.description'
      components={{
        link: <a href='#' />
      }}
    />
  )
}

Также есть случаи, когда переводы нужно добавить и вне react-дерева. Для этого в любом месте можно использовать createTranslation.

import createTranslation from 'next-translation/createTranslation'
// ...
export async function generateMetadata({ params }: { params: { lang: string } }) {
  const { t } = await createTranslation(params.lang);

  return {
    title: t('homePage.meta.title'),
  }
}

Настройка страницы

Теперь о настройке страницы. Для работы с переводами нужно знать язык. Однако в серверных компонентах нельзя использовать контекст. Для решения этого была сделана альтернатива createContext для серверных компонент в пакете next-impl-getters - createServerContext и getServerContext.

Чтобы его использовать нужно создать NextTranslationProvider. Это рекомендуется делать на уровне страницы, чтобы избежать проблем с ререндером Layout.

import NextTranlationProvider from 'next-translation/NextTranlationProvider'

export default function HomePage({ params }: { params: { lang: string } }) {
  return (
    <NextTranlationProvider lang={params.lang} clientTerms={['shared', 'banking.about']}>
      {/* ... */}
    </NextTranlationProvider>
  )
}

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

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

import NextTranslationTransmitter from 'next-tranlation/NextTranslationTransmitter';
import ClientComponent from './ClientComponent';

const ServerComponent: React.FC = () => (
  <NextTranslationTransmitter terms={['header.nav']}>
    <ClientComponent />
  </NextTranslationTransmitter>
)

Как итог, в клиентские компоненты будут переданы только те термины, которые были указаны выше в NextTranslationProvider или NextTranslationTransmitter.

Настройка пакета

Перед работой с переводами их нужно загрузить. Для этого нужно создать конфигурационный файл в корне проекта. Минимальная его конфигурация - функция load, которая вернёт актуальные переводы и массив languages с допустимыми языками. Функция load вызывается в серверных компонентах, а нужные ключи будут переданы на клиент.

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

Здесь стоит немного отойти в сторону. Next.js с последней версии собирает приложение параллельно в несколько процессов. Если бы каждый процесс жил со своим кешем - из каждого послылались бы запросы. Вероятно именно во избежание этого команда Next.js переделала fetch - теперь он работает с общим кешем.

Таким же путём решает проблему и пакет - он создаёт общий кеш и работает из каждого процесса уже с ним. Чтобы это работало нужно использовать withNextTranslation в next.config.js.

Заключение

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

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

Пакет с переводами: https://github.com/vordgi/next-translation

Пакет с серверным контекстами и прочими геттерами: https://github.com/vordgi/next-impl-getters

P.S. Буду благодарен, если опишите, чего вам не хватало в существующих решениях или какой функционал считаете самым важным.